Implement confidential OIDC authentication boundary

This commit is contained in:
ms2k 2026-07-18 18:08:41 +09:00
commit a4e615096c
17 changed files with 1896 additions and 329 deletions

View file

@ -31,6 +31,7 @@ RUN apt-get update \
libpq5 \
ca-certificates \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 nuthatch \
&& useradd --system --uid 10001 --gid 10001 --no-create-home nuthatch

View file

@ -1,4 +1,4 @@
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-app app-up app-down check-frontend build-frontend
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app app-up app-down check-frontend build-frontend
test:
cabal test --offline nuthatch-unit
@ -15,6 +15,9 @@ check-kind-matrix:
check-storage-foundation:
./tests/kind/wp04-run.sh
check-auth:
./tests/kind/wp05-run.sh
check-app:
./tests/kind/wp05-run.sh

View file

@ -7,10 +7,10 @@ import Database.PostgreSQL.Simple (Only (..))
import Network.Wai.Handler.Warp
( HostPreference
, defaultSettings
, runSettings
, setHost
, setPort
)
import Network.Wai.Handler.WarpTLS (runTLS, tlsSettings)
import Nuthatch
import Nuthatch.Db (migrate, query_, withDb)
import Nuthatch.Redis (withRedis)
@ -39,10 +39,10 @@ main = do
<> cfgListenHost cfg
<> ":"
<> show (cfgListenPort cfg)
<> " (auth="
<> show (cfgAuthMode cfg)
<> ")"
runSettings settings (application env)
runTLS
(tlsSettings (cfgTlsCertPath cfg) (cfgTlsKeyPath cfg))
settings
(application env)
waitForPostgres :: Config -> IO ()
waitForPostgres cfg = go (60 :: Int)

View file

@ -5,13 +5,16 @@ metadata:
namespace: nuthatch-testbed
data:
NUTHATCH_LISTEN: 0.0.0.0:8080
NUTHATCH_PUBLIC_BASE_URL: http://nuthatch-@@LANE@@:8080
NUTHATCH_PUBLIC_BASE_URL: https://nuthatch-@@LANE@@:8080
NUTHATCH_REDIS_URL: "@@REDIS_URL@@"
NUTHATCH_OIDC_ISSUER: "@@OIDC_ISSUER@@"
NUTHATCH_OIDC_CLIENT_ID: "@@OIDC_CLIENT_ID@@"
NUTHATCH_OIDC_ADDITIONAL_AUDIENCES: "@@OIDC_ADDITIONAL_AUDIENCES@@"
NUTHATCH_OIDC_REDIRECT_URI: https://nuthatch-@@LANE@@:8080/oidc/callback
NUTHATCH_S3_ENDPOINT: "@@S3_ENDPOINT@@"
NUTHATCH_S3_REGION: "@@S3_REGION@@"
NUTHATCH_S3_BUCKET: "@@S3_BUCKET@@"
NUTHATCH_S3_PATH_STYLE: "true"
NUTHATCH_AUTH_MODE: dev
---
apiVersion: v1
kind: Secret
@ -20,7 +23,8 @@ metadata:
namespace: nuthatch-testbed
type: Opaque
stringData:
NUTHATCH_JWT_SECRET: synthetic-kind-jwt-secret-@@LANE@@
NUTHATCH_JWT_SECRET: synthetic-kind-session-jwt-secret-@@LANE@@
NUTHATCH_OIDC_CLIENT_SECRET: "@@OIDC_CLIENT_SECRET@@"
NUTHATCH_PG_CONN: "@@PG_CONN@@"
NUTHATCH_S3_ACCESS_KEY: "@@S3_ACCESS_KEY@@"
NUTHATCH_S3_SECRET_KEY: "@@S3_SECRET_KEY@@"
@ -40,11 +44,47 @@ spec:
spec:
automountServiceAccountToken: false
enableServiceLinks: false
dnsPolicy: None
dnsConfig:
nameservers: [10.96.0.53]
searches:
- nuthatch-testbed.svc.cluster.local
- svc.cluster.local
- cluster.local
options:
- {name: ndots, value: "5"}
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
seccompProfile: {type: RuntimeDefault}
initContainers:
- name: tls-certificate
image: @@APP_IMAGE@@
imagePullPolicy: Never
command: [/bin/sh, -ec]
args:
- |
openssl req \
-x509 \
-newkey rsa:2048 \
-nodes \
-days 1 \
-subj "/CN=nuthatch-@@LANE@@" \
-addext "subjectAltName=DNS:nuthatch-@@LANE@@,DNS:nuthatch-@@LANE@@.nuthatch-testbed.svc" \
-keyout /tls/tls.key \
-out /tls/tls.crt
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: tls, mountPath: /tls}
resources:
requests: {cpu: 25m, memory: 16Mi}
limits: {cpu: 250m, memory: 32Mi}
containers:
- name: nuthatch
image: @@APP_IMAGE@@
@ -57,18 +97,23 @@ spec:
- configMapRef: {name: nuthatch-@@LANE@@}
- secretRef: {name: nuthatch-@@LANE@@}
ports:
- {name: http, containerPort: 8080}
- {name: https, containerPort: 8080}
livenessProbe:
httpGet: {path: /healthz, port: http}
httpGet: {path: /healthz, port: https, scheme: HTTPS}
periodSeconds: 2
failureThreshold: 15
readinessProbe:
httpGet: {path: /readyz, port: http}
httpGet: {path: /readyz, port: https, scheme: HTTPS}
periodSeconds: 2
failureThreshold: 30
resources:
requests: {cpu: 100m, memory: 96Mi}
limits: {cpu: "2", memory: 512Mi}
volumeMounts:
- {name: tls, mountPath: /tls, readOnly: true}
volumes:
- name: tls
emptyDir: {}
---
apiVersion: v1
kind: Service
@ -78,4 +123,4 @@ metadata:
spec:
selector: {app: nuthatch-@@LANE@@}
ports:
- {name: http, port: 8080, targetPort: http}
- {name: https, port: 8080, targetPort: https}

View file

@ -52,6 +52,7 @@ common shared
, case-insensitive >= 1.2 && < 1.3
, containers >= 0.6 && < 0.8
, conduit >= 1.3 && < 1.4
, cookie >= 0.5.1 && < 0.6
, crypton >= 0.34 && < 1.1
, directory >= 1.3 && < 1.4
, entropy >= 0.4 && < 0.5
@ -63,6 +64,8 @@ common shared
, http-api-data >= 0.7 && < 0.8
, http-media >= 0.8 && < 0.9
, http-types >= 0.12 && < 0.13
, jose >= 0.12 && < 0.13
, lens >= 5.2 && < 5.4
, memory >= 0.18 && < 0.19
, mime-types >= 0.1 && < 0.2
, mtl >= 2.3 && < 2.4
@ -85,6 +88,7 @@ common shared
, wai >= 3.2 && < 3.3
, wai-extra >= 3.1 && < 3.2
, warp >= 3.3 && < 3.5
, warp-tls >= 3.4 && < 3.5
, xml-conduit >= 1.9 && < 1.11
library
@ -97,6 +101,7 @@ library
Nuthatch.Audit
Nuthatch.Config
Nuthatch.Db
Nuthatch.Oidc
Nuthatch.Paths
Nuthatch.Protocol
Nuthatch.Redis

View file

@ -2,9 +2,10 @@ module Nuthatch.Api
( Api
, HealthEndpoint
, ReadyEndpoint
, LoginPageEndpoint
, BrowserScriptEndpoint
, LoginEndpoint
, LoginStart
, OidcCallbackEndpoint
, LoginSuccess
, LogoutEndpoint
, LogoutSuccess
@ -25,10 +26,6 @@ module Nuthatch.Api
, PaginationToken (..)
, SearchQuery (..)
, DeclaredSize (..)
, DevUsername (..)
, LoginRequest (..)
, LoginResponse (..)
, LoginView (..)
, HealthResponse (..)
, ReadyResponse (..)
, BrowseResponse (..)
@ -73,7 +70,6 @@ import Nuthatch.Types
import Servant.API
import Servant.Server (HasServer (..))
import Servant.Server.Experimental.Auth (AuthServerData)
import Web.FormUrlEncoded (FromForm (..), lookupUnique)
data HTML
data JavaScript
@ -121,44 +117,6 @@ newtype DeclaredSize = DeclaredSize { unDeclaredSize :: Integer }
deriving stock (Eq, Ord, Show)
deriving newtype (ToJSON, FromJSON)
newtype DevUsername = DevUsername { unDevUsername :: Text }
deriving stock (Eq, Show)
data LoginRequest = LoginRequest
{ loginUsername :: DevUsername
}
deriving stock (Eq, Show)
instance FromJSON LoginRequest where
parseJSON = withObject "LoginRequest" $ \o -> do
raw <- o .: "username"
LoginRequest <$> either (fail . T.unpack) pure (validateDevUsername raw)
instance FromForm LoginRequest where
fromForm form = do
raw <- lookupUnique "username" form
LoginRequest <$> validateDevUsername raw
data LoginResponse = LoginResponse
{ loginUserId :: UserId
}
deriving stock (Eq, Show)
instance ToJSON LoginResponse where
toJSON response = object ["user_id" .= loginUserId response]
data LoginView = LoginView
{ loginViewHtml :: HtmlPage
, loginViewJson :: LoginResponse
}
deriving stock (Eq, Show)
instance MimeRender HTML LoginView where
mimeRender proxy = mimeRender proxy . loginViewHtml
instance ToJSON LoginView where
toJSON = toJSON . loginViewJson
newtype HealthResponse = HealthResponse { healthOk :: Bool }
deriving stock (Eq, Show)
@ -302,13 +260,16 @@ instance ToJSON ReadyError where
errorValue "dependency_unavailable" "PostgreSQL is unavailable" Nothing
data LoginError
= InvalidUsername
= OidcAuthorizationFailed
| OidcCallbackRejected
| SessionCreationFailed Text
deriving stock (Eq, Show)
instance ToJSON LoginError where
toJSON InvalidUsername =
errorValue "invalid_username" "username must be one token" Nothing
toJSON OidcAuthorizationFailed =
errorValue "oidc_authorization_failed" "OIDC authorization could not start" Nothing
toJSON OidcCallbackRejected =
errorValue "oidc_callback_rejected" "OIDC callback validation failed" Nothing
toJSON (SessionCreationFailed message) =
errorValue "session_error" message Nothing
@ -407,29 +368,43 @@ type HealthEndpoint =
type ReadyEndpoint =
"readyz" :> Throws ReadyError :> Get '[JSON] ReadyResponse
type LoginPageEndpoint =
"login" :> Get '[HTML] HtmlPage
type BrowserScriptEndpoint =
"nuthatch.js" :> Get '[JavaScript] BrowserScript
type LoginStart =
Headers
'[ Header "Set-Cookie" Text
, Header "Location" Text
]
NoContent
type LoginEndpoint =
"login"
:> Throws LoginError
:> Verb 'GET 303 '[JSON] LoginStart
type LoginSuccess =
Headers
'[ Header "Set-Cookie" Text
, Header "Set-Cookie" Text
, Header "X-CSRF-Token" CsrfToken
, Header "Set-Cookie" Text
, Header "Location" Text
]
LoginView
NoContent
type LoginEndpoint =
"login"
type OidcCallbackEndpoint =
"oidc"
:> "callback"
:> Throws LoginError
:> ReqBody '[JSON, FormUrlEncoded] LoginRequest
:> Post '[HTML, JSON] LoginSuccess
:> QueryParam' '[Required, Strict] "code" Text
:> QueryParam' '[Required, Strict] "state" Text
:> Header' '[Required, Strict] "Cookie" Text
:> Verb 'GET 303 '[JSON] LoginSuccess
type LogoutSuccess =
Headers
'[ Header "Set-Cookie" Text
, Header "Set-Cookie" Text
, Header "Set-Cookie" Text
]
NoContent
@ -501,9 +476,9 @@ type DeletePrefixEndpoint =
type Api =
HealthEndpoint
:<|> ReadyEndpoint
:<|> LoginPageEndpoint
:<|> BrowserScriptEndpoint
:<|> LoginEndpoint
:<|> OidcCallbackEndpoint
:<|> LogoutEndpoint
:<|> BrowseEndpoint
:<|> DownloadEndpoint
@ -554,16 +529,6 @@ validatePaginationToken raw
| T.any isControl raw = Left "pagination token contains a control character"
| otherwise = Right (PaginationToken raw)
validateDevUsername :: Text -> Either Text DevUsername
validateDevUsername raw
| T.null raw = Left "username must not be empty"
| T.length raw > 128 = Left "username is too long"
| T.any invalidCharacter raw = Left "username must be one printable token"
| otherwise = Right (DevUsername raw)
where
invalidCharacter character =
isControl character || character `elem` (" \t\r\n" :: String)
mutationResultText :: MutationResult -> Text
mutationResultText MutationPending = "pending"
mutationResultText MutationSucceeded = "succeeded"

View file

@ -18,7 +18,6 @@ import qualified Data.Aeson as A
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL
import Data.Either (isRight)
import Data.Maybe (fromMaybe, listToMaybe)
@ -30,17 +29,18 @@ import Data.UUID.V4 (nextRandom)
import Database.PostgreSQL.Simple (Only (..))
import Network.HTTP.Types
( hContentType
, hCookie
, hLocation
, status400
, urlDecode
)
import qualified Network.HTTP.Types as HTTP
import Network.Wai (Request (..), responseLBS)
import Nuthatch.Api
import Nuthatch.Audit (audit)
import Nuthatch.Config (Config (..))
import Nuthatch.Db (Db, ensurePersonalPool, withTransaction)
import qualified Nuthatch.Db as Db
import Nuthatch.Oidc
import Nuthatch.Paths
import Nuthatch.Protocol
import Nuthatch.Redis (Redis, cacheGet, cacheInvalidatePrefixes, cacheSet, throttleTake)
@ -57,13 +57,26 @@ import Nuthatch.S3
)
import Nuthatch.Session
import Nuthatch.Types
import Nuthatch.Ui (browsePage, browserScript, loginPage)
import Nuthatch.Ui (browsePage, browserScript)
import Servant
import Servant.Server.Experimental.Auth
( AuthHandler
, mkAuthHandler
)
import Servant.Types.SourceT (runSourceT, source)
import Web.Cookie
( defaultSetCookie
, parseCookies
, renderSetCookieBS
, sameSiteLax
, setCookieHttpOnly
, setCookieMaxAge
, setCookieName
, setCookiePath
, setCookieSameSite
, setCookieSecure
, setCookieValue
)
data Env = Env
{ envConfig :: Config
@ -105,9 +118,9 @@ server :: Env -> Server Api
server env =
pure (HealthResponse True)
:<|> handleReady env
:<|> pure (HtmlPage (loginPage ""))
:<|> pure (BrowserScript browserScript)
:<|> handleLoginRequest env
:<|> handleOidcCallback env
:<|> handleLogoutRequest env
:<|> handleBrowseRequest env
:<|> handleDownloadRequest env
@ -117,7 +130,7 @@ server env =
authenticationHandler :: Config -> AuthHandler Request SessionContext
authenticationHandler cfg = mkAuthHandler $ \request -> do
let cookies = parseCookies (requestHeaders request)
let cookies = requestCookies request
sessionName = TE.encodeUtf8 (cfgCookieName cfg)
csrfName = TE.encodeUtf8 (cfgCsrfCookieName cfg)
token <- maybe (throwError loginRequired) pure (lookup sessionName cookies)
@ -155,36 +168,68 @@ handleReady env = do
handleLoginRequest
:: Env
-> LoginRequest
-> Handler LoginStart
handleLoginRequest env = do
let cfg = envConfig env
authorizationResult <- liftIO (beginAuthorization cfg)
authorization <- either
(const (throwOperationError err502 OidcAuthorizationFailed))
pure
authorizationResult
pure $
addHeader
(cookieValue
(cfgOidcTransactionCookieName cfg)
(authorizationTransaction authorization)
"/oidc/callback"
True) $
addHeader (authorizationLocation authorization) NoContent
handleOidcCallback
:: Env
-> Text
-> Text
-> Text
-> Handler LoginSuccess
handleLoginRequest env request = do
let username = unDevUsername (loginUsername request)
let uid = encodeUserId "nuthatch-dev" username
cfg = envConfig env
handleOidcCallback env code state cookieHeader = do
let cfg = envConfig env
cookies = parseCookies (TE.encodeUtf8 cookieHeader)
transactionName = TE.encodeUtf8 (cfgOidcTransactionCookieName cfg)
transaction <- maybe
(throwOperationError err400 OidcCallbackRejected)
(parseCookieValueText OidcCallbackRejected)
(lookup transactionName cookies)
identityResult <- liftIO (redeemAuthorizationCode cfg transaction state code)
identity <- case identityResult of
Left reason -> do
liftIO $ audit "login" "rejected" Nothing [("reason", A.String reason)]
throwOperationError err400 OidcCallbackRejected
Right accepted -> pure accepted
let uid = encodeUserId (identityIssuer identity) (identitySubject identity)
liftIO (ensurePersonalPool (envDb env) cfg uid)
tokenResult <- liftIO (mintSession cfg "nuthatch-dev" username (Just username))
tokenResult <- liftIO $
mintSession
cfg
(identityIssuer identity)
(identitySubject identity)
(identityDisplay identity)
token <- either
(throwOperationError err500 . SessionCreationFailed)
(const (throwOperationError err500 (SessionCreationFailed "session creation failed")))
pure
tokenResult
csrf <- liftIO newCsrfToken
liftIO $ audit "login" "ok" (Just (unUserId uid)) []
pure (loginResponse cfg uid token csrf)
pure (loginResponse cfg token csrf)
loginResponse
:: Config
-> UserId
-> SessionToken
-> CsrfToken
-> LoginSuccess
loginResponse cfg uid token csrf =
addHeader (cookieValue cfg (cfgCookieName cfg) (unSessionToken token) True) $
addHeader (cookieValue cfg (cfgCsrfCookieName cfg) (unCsrfToken csrf) False) $
addHeader csrf (LoginView
{ loginViewHtml = HtmlPage
"<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"refresh\" content=\"0;url=/\"></head><body><a href=\"/\">Continue to Nuthatch</a></body></html>"
, loginViewJson = LoginResponse uid
})
loginResponse :: Config -> SessionToken -> CsrfToken -> LoginSuccess
loginResponse cfg token csrf =
addHeader (cookieValue (cfgCookieName cfg) (unSessionToken token) "/" True) $
addHeader (cookieValue (cfgCsrfCookieName cfg) (unCsrfToken csrf) "/" False) $
addHeader
(expiredCookieValue
(cfgOidcTransactionCookieName cfg)
"/oidc/callback") $
addHeader "/" NoContent
handleLogoutRequest
:: Env
@ -195,8 +240,13 @@ handleLogoutRequest env session providedCsrf = do
requireCsrf LogoutCsrfRejected session providedCsrf
let cfg = envConfig env
pure $
addHeader (expiredCookieValue (cfgCookieName cfg)) $
addHeader (expiredCookieValue (cfgCsrfCookieName cfg)) NoContent
addHeader (expiredCookieValue (cfgCookieName cfg) "/") $
addHeader (expiredCookieValue (cfgCsrfCookieName cfg) "/") $
addHeader
(expiredCookieValue
(cfgOidcTransactionCookieName cfg)
"/oidc/callback")
NoContent
handleBrowseRequest
:: Env
@ -396,15 +446,32 @@ browsePrefixText :: BrowsePrefix -> Text
browsePrefixText (BrowsePrefix Nothing) = ""
browsePrefixText (BrowsePrefix (Just path)) = unRelativePath path
cookieValue :: Config -> Text -> Text -> Bool -> Text
cookieValue _cfg name value httpOnly =
name <> "=" <> value
<> "; Path=/; SameSite=Lax"
<> if httpOnly then "; HttpOnly" else ""
cookieValue :: Text -> Text -> BS.ByteString -> Bool -> Text
cookieValue name value path httpOnly =
TE.decodeUtf8 $
renderSetCookieBS $
defaultSetCookie
{ setCookieName = TE.encodeUtf8 name
, setCookieValue = TE.encodeUtf8 value
, setCookiePath = Just path
, setCookieHttpOnly = httpOnly
, setCookieSecure = True
, setCookieSameSite = Just sameSiteLax
}
expiredCookieValue :: Text -> Text
expiredCookieValue name =
name <> "=; Path=/; Max-Age=0; SameSite=Lax"
expiredCookieValue :: Text -> BS.ByteString -> Text
expiredCookieValue name path =
TE.decodeUtf8 $
renderSetCookieBS $
defaultSetCookie
{ setCookieName = TE.encodeUtf8 name
, setCookieValue = ""
, setCookiePath = Just path
, setCookieMaxAge = Just 0
, setCookieHttpOnly = True
, setCookieSecure = True
, setCookieSameSite = Just sameSiteLax
}
mutationAuditResult :: MutationResult -> Text
mutationAuditResult MutationSucceeded = "ok"
@ -725,18 +792,16 @@ lastSeg t =
(x : _) -> x
[] -> t
parseCookies :: [HTTP.Header] -> [(BS.ByteString, BS.ByteString)]
parseCookies headers =
case lookup "Cookie" headers of
Nothing -> []
Just raw ->
[ (trimBS k, trimBS (BS8.drop 1 v))
| part <- BS8.split ';' raw
, let (k, v) = BS8.break (== '=') part
, not (BS.null v)
]
requestCookies :: Request -> [(BS.ByteString, BS.ByteString)]
requestCookies request =
maybe [] parseCookies (lookup hCookie (requestHeaders request))
trimBS :: BS.ByteString -> BS.ByteString
trimBS = BS8.dropWhile isSpace . BS8.dropWhileEnd isSpace
where
isSpace c = c == ' ' || c == '\t'
parseCookieValueText
:: A.ToJSON error
=> error
-> BS.ByteString
-> Handler Text
parseCookieValueText operationError bytes =
case TE.decodeUtf8' bytes of
Right value | not (T.null value) && T.length value <= 8192 -> pure value
_ -> throwOperationError err400 operationError

View file

@ -1,27 +1,36 @@
module Nuthatch.Config
( Config (..)
, AuthMode (..)
, loadConfig
) where
import Control.Monad (when)
import qualified Data.ByteString as BS
import Data.List (nub)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.Environment (lookupEnv)
import Text.Read (readMaybe)
data AuthMode = AuthDev
deriving stock (Eq, Show)
data Config = Config
{ cfgListenHost :: String
, cfgListenPort :: Int
, cfgTlsCertPath :: FilePath
, cfgTlsKeyPath :: FilePath
, cfgPublicBaseUrl :: Text
, cfgJwtSecret :: Text
, cfgSessionHours :: Integer
, cfgCookieName :: Text
, cfgCsrfCookieName :: Text
, cfgOidcTransactionCookieName :: Text
, cfgOidcIssuer :: Text
, cfgOidcClientId :: Text
, cfgOidcAdditionalAudiences :: [Text]
, cfgOidcClientSecret :: Text
, cfgOidcRedirectUri :: Text
, cfgOidcTransactionMinutes :: Integer
, cfgOidcMaxIdTokenSeconds :: Integer
, cfgPgConn :: String
, cfgRedisHost :: String
, cfgRedisPort :: Int
@ -40,22 +49,45 @@ data Config = Config
, cfgListCacheTtlSec :: Integer
, cfgSearchMaxKeys :: Int
, cfgRecoveryGraceSec :: Integer
, cfgAuthMode :: AuthMode
}
deriving stock (Show)
loadConfig :: IO Config
loadConfig = do
listen <- fromMaybe "127.0.0.1:8080" <$> lookupEnv "NUTHATCH_LISTEN"
let (host, portText) = splitHostPort listen
port <- requireRead "NUTHATCH_LISTEN port" portText
publicBase <- T.pack . fromMaybe ("http://" <> listen) <$> lookupEnv "NUTHATCH_PUBLIC_BASE_URL"
tlsCertPath <- fromMaybe "/tls/tls.crt" <$> lookupEnv "NUTHATCH_TLS_CERT_PATH"
tlsKeyPath <- fromMaybe "/tls/tls.key" <$> lookupEnv "NUTHATCH_TLS_KEY_PATH"
publicBase <- T.pack <$> requireEnv "NUTHATCH_PUBLIC_BASE_URL"
when (not ("https://" `T.isPrefixOf` publicBase)) $
fail "NUTHATCH_PUBLIC_BASE_URL must use https"
jwtSecret <- T.pack <$> requireEnv "NUTHATCH_JWT_SECRET"
when (T.length jwtSecret < 16) $
fail "NUTHATCH_JWT_SECRET must be at least 16 characters"
when (BS.length (TE.encodeUtf8 jwtSecret) < 32) $
fail "NUTHATCH_JWT_SECRET must be at least 32 bytes"
sessionHours <- envInteger "NUTHATCH_SESSION_HOURS" 12
cookieName <- T.pack . fromMaybe "nuthatch_session" <$> lookupEnv "NUTHATCH_COOKIE_NAME"
csrfCookie <- T.pack . fromMaybe "nuthatch_csrf" <$> lookupEnv "NUTHATCH_CSRF_COOKIE_NAME"
transactionCookie <- T.pack . fromMaybe "nuthatch_oidc" <$> lookupEnv "NUTHATCH_OIDC_TRANSACTION_COOKIE_NAME"
oidcIssuer <- T.dropWhileEnd (== '/') . T.pack <$> requireEnv "NUTHATCH_OIDC_ISSUER"
oidcClientId <- T.pack <$> requireEnv "NUTHATCH_OIDC_CLIENT_ID"
additionalAudienceValue <- lookupEnv "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES"
let oidcAdditionalAudiences =
case T.strip . T.pack <$> additionalAudienceValue of
Nothing -> []
Just "" -> []
Just value -> map T.strip (T.splitOn "," value)
when
( any T.null oidcAdditionalAudiences
|| oidcClientId `elem` oidcAdditionalAudiences
|| length oidcAdditionalAudiences /= length (nub oidcAdditionalAudiences)
) $
fail "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES must contain unique non-client values"
oidcClientSecret <- T.pack <$> requireEnv "NUTHATCH_OIDC_CLIENT_SECRET"
oidcRedirectUri <- T.pack <$> requireEnv "NUTHATCH_OIDC_REDIRECT_URI"
when (oidcRedirectUri /= publicBase <> "/oidc/callback") $
fail "NUTHATCH_OIDC_REDIRECT_URI must equal the public callback URL"
oidcTransactionMinutes <- envInteger "NUTHATCH_OIDC_TRANSACTION_MINUTES" 5
oidcMaxIdTokenSeconds <- envInteger "NUTHATCH_OIDC_MAX_ID_TOKEN_SECONDS" 3600
pgConn <- requireEnv "NUTHATCH_PG_CONN"
redisUrl <- requireEnv "NUTHATCH_REDIS_URL"
(redisHost, redisPort, redisDb) <- parseRedisUrl redisUrl
@ -73,18 +105,24 @@ loadConfig = do
listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30
searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500
recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90
authModeText <- fromMaybe "dev" <$> lookupEnv "NUTHATCH_AUTH_MODE"
authMode <- case authModeText of
"dev" -> pure AuthDev
other -> fail $ "unsupported NUTHATCH_AUTH_MODE: " <> other
pure Config
{ cfgListenHost = host
, cfgListenPort = port
, cfgTlsCertPath = tlsCertPath
, cfgTlsKeyPath = tlsKeyPath
, cfgPublicBaseUrl = publicBase
, cfgJwtSecret = jwtSecret
, cfgSessionHours = sessionHours
, cfgCookieName = cookieName
, cfgCsrfCookieName = csrfCookie
, cfgOidcTransactionCookieName = transactionCookie
, cfgOidcIssuer = oidcIssuer
, cfgOidcClientId = oidcClientId
, cfgOidcAdditionalAudiences = oidcAdditionalAudiences
, cfgOidcClientSecret = oidcClientSecret
, cfgOidcRedirectUri = oidcRedirectUri
, cfgOidcTransactionMinutes = oidcTransactionMinutes
, cfgOidcMaxIdTokenSeconds = oidcMaxIdTokenSeconds
, cfgPgConn = pgConn
, cfgRedisHost = redisHost
, cfgRedisPort = redisPort
@ -103,7 +141,6 @@ loadConfig = do
, cfgListCacheTtlSec = listTtl
, cfgSearchMaxKeys = searchMax
, cfgRecoveryGraceSec = recoveryGrace
, cfgAuthMode = authMode
}
splitHostPort :: String -> (String, String)

469
src/Nuthatch/Oidc.hs Normal file
View file

@ -0,0 +1,469 @@
module Nuthatch.Oidc
( AuthorizationRequest (..)
, OidcIdentity (..)
, beginAuthorization
, redeemAuthorizationCode
, verifyIdToken
) where
import Control.Applicative ((<|>))
import Control.Exception (catch)
import Control.Lens
( (&)
, (?~)
, (^.)
, (.~)
)
import Crypto.Hash (Digest, SHA256, hash)
import Crypto.JOSE (runJOSE)
import Crypto.JOSE.Compact (decodeCompact, encodeCompact)
import Crypto.JOSE.JWA.JWS (Alg (HS256, RS256))
import Crypto.JOSE.JWK (JWKSet, fromOctets)
import Crypto.JOSE.JWS
( algorithms
, newJWSHeaderProtected
)
import Crypto.JWT
( Audience (..)
, ClaimsSet
, HasClaimsSet (..)
, JWTError
, NumericDate (..)
, SignedJWT
, StringOrURI
, defaultJWTValidationSettings
, emptyClaimsSet
, issuerPredicate
, signJWT
, verifyJWT
)
import qualified Data.Aeson as A
import Data.Aeson.Key (Key)
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64
import qualified Data.ByteString.Lazy as BL
import qualified Data.Set as Set
import Data.Set (singleton)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock
( UTCTime
, addUTCTime
, diffUTCTime
, getCurrentTime
)
import Network.HTTP.Client (HttpException, applyBasicAuth)
import Network.HTTP.Simple
( getResponseBody
, getResponseStatusCode
, httpLBS
, parseRequest
, setRequestBodyURLEncoded
, setRequestMethod
)
import Network.HTTP.Types.URI (renderQuery)
import Nuthatch.Config (Config (..))
import System.Entropy (getEntropy)
data AuthorizationRequest = AuthorizationRequest
{ authorizationLocation :: Text
, authorizationTransaction :: Text
}
deriving stock (Eq, Show)
data OidcIdentity = OidcIdentity
{ identityIssuer :: Text
, identitySubject :: Text
, identityDisplay :: Maybe Text
}
deriving stock (Eq, Show)
data Discovery = Discovery
{ discoveryIssuer :: Text
, discoveryAuthorizationEndpoint :: Text
, discoveryTokenEndpoint :: Text
, discoveryJwksUri :: Text
}
instance A.FromJSON Discovery where
parseJSON = A.withObject "OIDC discovery" $ \object ->
Discovery
<$> object A..: "issuer"
<*> object A..: "authorization_endpoint"
<*> object A..: "token_endpoint"
<*> object A..: "jwks_uri"
data TokenResponse = TokenResponse
{ tokenResponseIdToken :: Text
}
instance A.FromJSON TokenResponse where
parseJSON = A.withObject "OIDC token response" $ \object ->
TokenResponse <$> object A..: "id_token"
data TransactionClaims = TransactionClaims
{ transactionRegistered :: ClaimsSet
, transactionState :: Text
, transactionNonce :: Text
, transactionVerifier :: Text
, transactionIssuer :: Text
, transactionClientId :: Text
, transactionRedirectUri :: Text
, transactionReturnTarget :: Text
}
deriving stock (Eq, Show)
instance HasClaimsSet TransactionClaims where
claimsSet update claims =
fmap
(\registered -> claims { transactionRegistered = registered })
(update (transactionRegistered claims))
instance A.FromJSON TransactionClaims where
parseJSON value = A.withObject "OIDC transaction" parse value
where
parse object =
TransactionClaims
<$> A.parseJSON value
<*> object A..: "state"
<*> object A..: "nonce"
<*> object A..: "verifier"
<*> object A..: "oidc_issuer"
<*> object A..: "client_id"
<*> object A..: "redirect_uri"
<*> object A..: "return_target"
instance A.ToJSON TransactionClaims where
toJSON claims =
extendClaims
(transactionRegistered claims)
[ ("state", A.String (transactionState claims))
, ("nonce", A.String (transactionNonce claims))
, ("verifier", A.String (transactionVerifier claims))
, ("oidc_issuer", A.String (transactionIssuer claims))
, ("client_id", A.String (transactionClientId claims))
, ("redirect_uri", A.String (transactionRedirectUri claims))
, ("return_target", A.String (transactionReturnTarget claims))
]
data IdTokenClaims = IdTokenClaims
{ idTokenRegistered :: ClaimsSet
, idTokenNonce :: Text
, idTokenAuthorizedParty :: Maybe Text
, idTokenName :: Maybe Text
, idTokenPreferred :: Maybe Text
}
deriving stock (Eq, Show)
instance HasClaimsSet IdTokenClaims where
claimsSet update claims =
fmap
(\registered -> claims { idTokenRegistered = registered })
(update (idTokenRegistered claims))
instance A.FromJSON IdTokenClaims where
parseJSON value = A.withObject "OIDC ID token" parse value
where
parse object =
IdTokenClaims
<$> A.parseJSON value
<*> object A..: "nonce"
<*> object A..:? "azp"
<*> object A..:? "name"
<*> object A..:? "preferred_username"
instance A.ToJSON IdTokenClaims where
toJSON claims =
extendClaims
(idTokenRegistered claims)
[ ("nonce", A.String (idTokenNonce claims))
, ("azp", maybe A.Null A.String (idTokenAuthorizedParty claims))
, ("name", maybe A.Null A.String (idTokenName claims))
, ("preferred_username", maybe A.Null A.String (idTokenPreferred claims))
]
beginAuthorization :: Config -> IO (Either Text AuthorizationRequest)
beginAuthorization cfg = do
discoveryResult <- fetchDiscovery cfg
case discoveryResult of
Left message -> pure (Left message)
Right discovery -> do
state <- randomBase64Url 32
nonce <- randomBase64Url 32
verifier <- randomBase64Url 32
transactionResult <- mintTransaction cfg state nonce verifier
pure $ do
transaction <- transactionResult
let challenge =
TE.decodeUtf8 $
B64.encodeUnpadded $
BA.convert (hash (TE.encodeUtf8 verifier) :: Digest SHA256)
query = renderQuery True
[ ("response_type", Just "code")
, ("client_id", Just (TE.encodeUtf8 (cfgOidcClientId cfg)))
, ("redirect_uri", Just (TE.encodeUtf8 (cfgOidcRedirectUri cfg)))
, ("scope", Just "openid profile")
, ("state", Just (TE.encodeUtf8 state))
, ("nonce", Just (TE.encodeUtf8 nonce))
, ("code_challenge", Just (TE.encodeUtf8 challenge))
, ("code_challenge_method", Just "S256")
]
Right AuthorizationRequest
{ authorizationLocation =
discoveryAuthorizationEndpoint discovery
<> TE.decodeUtf8 query
, authorizationTransaction = transaction
}
redeemAuthorizationCode
:: Config
-> Text
-> Text
-> Text
-> IO (Either Text OidcIdentity)
redeemAuthorizationCode cfg transactionToken returnedState code = do
transactionResult <- verifyTransaction cfg transactionToken
case transactionResult of
Left message -> pure (Left message)
Right transaction
| not (constantTimeTextEq returnedState (transactionState transaction)) ->
pure (Left "OIDC state mismatch")
| transactionIssuer transaction /= cfgOidcIssuer cfg ->
pure (Left "OIDC transaction issuer mismatch")
| transactionClientId transaction /= cfgOidcClientId cfg ->
pure (Left "OIDC transaction client mismatch")
| transactionRedirectUri transaction /= cfgOidcRedirectUri cfg ->
pure (Left "OIDC transaction redirect URI mismatch")
| transactionReturnTarget transaction /= "/" ->
pure (Left "OIDC transaction return target mismatch")
| otherwise -> do
discoveryResult <- fetchDiscovery cfg
case discoveryResult of
Left message -> pure (Left message)
Right discovery -> do
tokenResult <- exchangeCode cfg discovery transaction code
case tokenResult of
Left message -> pure (Left message)
Right idToken ->
fetchAndVerifyIdToken
cfg
discovery
(transactionNonce transaction)
idToken
fetchDiscovery :: Config -> IO (Either Text Discovery)
fetchDiscovery cfg = catchOidcHttp "OIDC discovery request failed" $ do
request <- parseRequest $
T.unpack (cfgOidcIssuer cfg <> "/.well-known/openid-configuration")
response <- httpLBS request
pure $
if getResponseStatusCode response /= 200
then Left "OIDC discovery request failed"
else case A.eitherDecode (getResponseBody response) of
Left _ -> Left "OIDC discovery document is malformed"
Right discovery
| discoveryIssuer discovery == cfgOidcIssuer cfg -> Right discovery
| otherwise -> Left "OIDC discovery issuer mismatch"
exchangeCode
:: Config
-> Discovery
-> TransactionClaims
-> Text
-> IO (Either Text Text)
exchangeCode cfg discovery transaction code = do
catchOidcHttp "OIDC token exchange failed" $ do
initial <- parseRequest (T.unpack (discoveryTokenEndpoint discovery))
let request =
applyBasicAuth
(TE.encodeUtf8 (cfgOidcClientId cfg))
(TE.encodeUtf8 (cfgOidcClientSecret cfg)) $
setRequestMethod "POST" $
setRequestBodyURLEncoded
[ ("grant_type", "authorization_code")
, ("code", TE.encodeUtf8 code)
, ("redirect_uri", TE.encodeUtf8 (cfgOidcRedirectUri cfg))
, ("code_verifier", TE.encodeUtf8 (transactionVerifier transaction))
]
initial
response <- httpLBS request
pure $
if getResponseStatusCode response /= 200
then Left "OIDC token exchange failed"
else case A.eitherDecode (getResponseBody response) of
Left _ -> Left "OIDC token response is malformed"
Right tokenResponse -> Right (tokenResponseIdToken tokenResponse)
fetchAndVerifyIdToken
:: Config
-> Discovery
-> Text
-> Text
-> IO (Either Text OidcIdentity)
fetchAndVerifyIdToken cfg discovery expectedNonce compact = do
catchOidcHttp "OIDC JWKS request failed" $ do
jwksRequest <- parseRequest (T.unpack (discoveryJwksUri discovery))
jwksResponse <- httpLBS jwksRequest
if getResponseStatusCode jwksResponse /= 200
then pure (Left "OIDC JWKS request failed")
else case A.eitherDecode (getResponseBody jwksResponse) of
Left _ -> pure (Left "OIDC JWKS document is malformed")
Right jwks -> do
now <- getCurrentTime
verifyIdToken cfg jwks now expectedNonce compact
verifyIdToken
:: Config
-> JWKSet
-> UTCTime
-> Text
-> Text
-> IO (Either Text OidcIdentity)
verifyIdToken cfg jwks now expectedNonce compact = do
let expectedIssuer = fromString (T.unpack (cfgOidcIssuer cfg))
expectedAudience = fromString (T.unpack (cfgOidcClientId cfg))
expectedAudiences =
expectedAudience :
map (fromString . T.unpack) (cfgOidcAdditionalAudiences cfg)
settings =
defaultJWTValidationSettings (`elem` expectedAudiences)
& issuerPredicate .~ (== expectedIssuer)
& algorithms .~ singleton RS256
result <- runJOSE $ do
jwt <- decodeCompact (BL.fromStrict (TE.encodeUtf8 compact))
verifyJWT settings jwks (jwt :: SignedJWT)
pure $ case result of
Left (_ :: JWTError) -> Left "OIDC ID token validation failed"
Right claims ->
validateIdentity
(cfgOidcClientId cfg)
expectedAudiences
claims
where
validateIdentity expectedClientId expectedAudiences claims
| not (constantTimeTextEq expectedNonce (idTokenNonce claims)) =
Left "OIDC nonce mismatch"
| length expectedAudiences > 1
, idTokenAuthorizedParty claims /= Just expectedClientId =
Left "OIDC ID token authorized party is invalid"
| Just authorizedParty <- idTokenAuthorizedParty claims
, authorizedParty /= expectedClientId =
Left "OIDC ID token authorized party is invalid"
| Just (Audience audiences) <- idTokenRegistered claims ^. claimAud
, Set.fromList audiences == Set.fromList expectedAudiences
, length audiences == length expectedAudiences =
validateRegisteredClaims claims
| Just (Audience audiences) <- idTokenRegistered claims ^. claimAud
, all (`elem` audiences) expectedAudiences =
Left "OIDC ID token audience is not exclusive"
| otherwise =
Left "OIDC ID token audience is invalid"
validateRegisteredClaims claims = do
issuer <- requiredStringOrUri "issuer" (idTokenRegistered claims ^. claimIss)
subject <- requiredStringOrUri "subject" (idTokenRegistered claims ^. claimSub)
issuedAt <- requiredDate "issued-at" (idTokenRegistered claims ^. claimIat)
expiresAt <- requiredDate "expiration" (idTokenRegistered claims ^. claimExp)
if T.null issuer || T.null subject
then Left "OIDC ID token identity is empty"
else if issuedAt > addUTCTime 30 now
then Left "OIDC ID token issued-at is in the future"
else if expiresAt <= issuedAt
|| diffUTCTime expiresAt issuedAt >
fromInteger (cfgOidcMaxIdTokenSeconds cfg)
then Left "OIDC ID token lifetime exceeds policy"
else Right OidcIdentity
{ identityIssuer = issuer
, identitySubject = subject
, identityDisplay =
idTokenName claims
<|> idTokenPreferred claims
}
mintTransaction
:: Config
-> Text
-> Text
-> Text
-> IO (Either Text Text)
mintTransaction cfg state nonce verifier = do
now <- getCurrentTime
let expires =
addUTCTime
(fromInteger (cfgOidcTransactionMinutes cfg * 60))
now
claims = TransactionClaims
{ transactionRegistered =
emptyClaimsSet
& claimIss ?~ "nuthatch"
& claimAud ?~ Audience ["nuthatch-oidc-callback"]
& claimIat ?~ NumericDate now
& claimExp ?~ NumericDate expires
, transactionState = state
, transactionNonce = nonce
, transactionVerifier = verifier
, transactionIssuer = cfgOidcIssuer cfg
, transactionClientId = cfgOidcClientId cfg
, transactionRedirectUri = cfgOidcRedirectUri cfg
, transactionReturnTarget = "/"
}
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
result <- runJOSE $
encodeCompact <$> signJWT key (newJWSHeaderProtected HS256) claims
pure $ case result of
Left (_ :: JWTError) -> Left "OIDC transaction creation failed"
Right token -> Right (TE.decodeUtf8 (BL.toStrict token))
verifyTransaction :: Config -> Text -> IO (Either Text TransactionClaims)
verifyTransaction cfg compact = do
let key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
settings =
defaultJWTValidationSettings (== "nuthatch-oidc-callback")
& issuerPredicate .~ (== "nuthatch")
& algorithms .~ singleton HS256
result <- runJOSE $ do
jwt <- decodeCompact (BL.fromStrict (TE.encodeUtf8 compact))
verifyJWT settings key (jwt :: SignedJWT)
pure $ case result of
Left (_ :: JWTError) -> Left "OIDC transaction validation failed"
Right claims -> Right claims
extendClaims :: ClaimsSet -> [(Key, A.Value)] -> A.Value
extendClaims registered fields =
case A.toJSON registered of
A.Object object ->
A.Object (foldr (uncurry KM.insert) object fields)
value -> value
requiredStringOrUri
:: Text
-> Maybe StringOrURI
-> Either Text Text
requiredStringOrUri label value =
case A.toJSON <$> value of
Just (A.String text) -> Right text
_ -> Left ("OIDC ID token is missing " <> label)
requiredDate :: Text -> Maybe NumericDate -> Either Text UTCTime
requiredDate _ (Just (NumericDate value)) = Right value
requiredDate label Nothing = Left ("OIDC ID token is missing " <> label)
randomBase64Url :: Int -> IO Text
randomBase64Url size =
TE.decodeUtf8 . B64.encodeUnpadded <$> getEntropy size
constantTimeTextEq :: Text -> Text -> Bool
constantTimeTextEq left right =
let leftBytes = TE.encodeUtf8 left
rightBytes = TE.encodeUtf8 right
in BS.length leftBytes == BS.length rightBytes
&& BA.constEq
(hash leftBytes :: Digest SHA256)
(hash rightBytes :: Digest SHA256)
catchOidcHttp :: Text -> IO (Either Text value) -> IO (Either Text value)
catchOidcHttp message action =
action `catch` \(_ :: HttpException) -> pure (Left message)

View file

@ -5,121 +5,147 @@ module Nuthatch.Session
, constantTimeEq
) where
import Control.Lens
( (&)
, (?~)
, (^.)
, (.~)
)
import Crypto.Hash (Digest, SHA256, hash)
import Crypto.MAC.HMAC (HMAC, hmac)
import Crypto.JOSE (runJOSE)
import Crypto.JOSE.Compact (decodeCompact, encodeCompact)
import Crypto.JOSE.JWA.JWS (Alg (HS256))
import Crypto.JOSE.JWK (fromOctets)
import Crypto.JOSE.JWS
( algorithms
, newJWSHeaderProtected
)
import Crypto.JWT
( Audience (..)
, ClaimsSet
, HasClaimsSet (..)
, JWTError
, NumericDate (..)
, SignedJWT
, defaultJWTValidationSettings
, emptyClaimsSet
, issuerPredicate
, signJWT
, verifyJWT
)
import qualified Data.Aeson as A
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KM
import Data.ByteArray (convert)
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64
import qualified Data.ByteString.Lazy as BL
import Data.Set (singleton)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock.POSIX (getPOSIXTime)
import Data.Time.Clock (addUTCTime, getCurrentTime)
import Nuthatch.Config (Config (..))
import Nuthatch.Paths (encodeUserId)
import Nuthatch.Types
import System.Entropy (getEntropy)
data SessionClaims = SessionClaims
{ registeredClaims :: ClaimsSet
, oidcIssuer :: Text
, oidcSubject :: Text
, displayName :: Maybe Text
}
deriving stock (Eq, Show)
instance HasClaimsSet SessionClaims where
claimsSet update claims =
fmap
(\registered -> claims { registeredClaims = registered })
(update (registeredClaims claims))
instance A.FromJSON SessionClaims where
parseJSON value = A.withObject "SessionClaims" parse value
where
parse object =
SessionClaims
<$> A.parseJSON value
<*> object A..: "oidc_issuer"
<*> object A..: "oidc_subject"
<*> object A..:? "display"
instance A.ToJSON SessionClaims where
toJSON claims =
case A.toJSON (registeredClaims claims) of
A.Object object ->
A.Object $
KM.insert "oidc_issuer" (A.String (oidcIssuer claims)) $
KM.insert "oidc_subject" (A.String (oidcSubject claims)) $
maybe
object
(\display -> KM.insert "display" (A.String display) object)
(displayName claims)
value -> value
mintSession :: Config -> Text -> Text -> Maybe Text -> IO (Either Text SessionToken)
mintSession cfg iss sub display = do
now <- floor <$> getPOSIXTime
let expAt = now + cfgSessionHours cfg * 3600
uid = encodeUserId iss sub
header = A.object
[ "alg" A..= ("HS256" :: Text)
, "typ" A..= ("JWT" :: Text)
]
payload = A.object $
[ "iss" A..= ("nuthatch" :: Text)
, "aud" A..= ("nuthatch" :: Text)
, "iat" A..= now
, "exp" A..= expAt
, "sub" A..= unUserId uid
, "oidc_issuer" A..= iss
, "oidc_subject" A..= sub
] <> maybe [] (\d -> ["display" A..= d]) display
token = signJwt (TE.encodeUtf8 (cfgJwtSecret cfg)) header payload
pure (validateSessionToken token)
mintSession cfg issuer subject display = do
now <- getCurrentTime
let expires = addUTCTime (fromInteger (cfgSessionHours cfg * 3600)) now
userId = encodeUserId issuer subject
applicationIssuer = "nuthatch"
applicationAudience = "nuthatch"
userSubject = fromString (T.unpack (unUserId userId))
result <- runJOSE $ do
let claims = SessionClaims
{ registeredClaims =
emptyClaimsSet
& claimIss ?~ applicationIssuer
& claimSub ?~ userSubject
& claimAud ?~ Audience [applicationAudience]
& claimIat ?~ NumericDate now
& claimExp ?~ NumericDate expires
, oidcIssuer = issuer
, oidcSubject = subject
, displayName = display
}
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
header = newJWSHeaderProtected HS256
encodeCompact <$> signJWT key header claims
pure $ case result of
Left (_ :: JWTError) -> Left "session token creation failed"
Right compact ->
validateSessionToken (TE.decodeUtf8 (BL.toStrict compact))
verifySession :: Config -> SessionToken -> IO (Either Text SessionUser)
verifySession cfg (SessionToken token) = do
now <- floor <$> getPOSIXTime
pure $ do
claims <- verifyJwt (TE.encodeUtf8 (cfgJwtSecret cfg)) token
expAt <- claimInt claims "exp"
if expAt < now
then Left "session expired"
else do
iss <- claimText claims "oidc_issuer"
sub <- claimText claims "oidc_subject"
let display = either (const Nothing) Just (claimText claims "display")
uid = encodeUserId iss sub
pure SessionUser
{ suIssuer = iss
, suSubject = sub
, suUserId = uid
, suDisplay = display
let applicationIssuer = "nuthatch"
applicationAudience = "nuthatch"
result <- runJOSE $ do
jwt <- decodeCompact (BL.fromStrict (TE.encodeUtf8 token))
let settings =
defaultJWTValidationSettings (== applicationAudience)
& issuerPredicate .~ (== applicationIssuer)
& algorithms .~ singleton HS256
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
verifyJWT settings key (jwt :: SignedJWT)
pure $ case result of
Left (_ :: JWTError) -> Left "session token validation failed"
Right claims ->
let userId = encodeUserId (oidcIssuer claims) (oidcSubject claims)
expectedSubject = fromString (T.unpack (unUserId userId))
registered = registeredClaims claims
in if registered ^. claimSub /= Just expectedSubject
|| registered ^. claimAud /= Just (Audience ["nuthatch"])
|| registered ^. claimIat == Nothing
|| registered ^. claimExp == Nothing
then Left "session token claims are invalid"
else Right SessionUser
{ suIssuer = oidcIssuer claims
, suSubject = oidcSubject claims
, suUserId = userId
, suDisplay = displayName claims
}
signJwt :: BS.ByteString -> A.Value -> A.Value -> Text
signJwt secret header payload =
let h = b64Json header
p = b64Json payload
signingInput = h <> "." <> p
sig = b64Raw (hmacSha256 secret signingInput)
in TE.decodeUtf8 (signingInput <> "." <> sig)
verifyJwt :: BS.ByteString -> Text -> Either Text A.Object
verifyJwt secret token =
case T.splitOn "." token of
[h, p, s] -> do
let signingInput = TE.encodeUtf8 (h <> "." <> p)
expected = b64Raw (hmacSha256 secret signingInput)
if not (BA.constEq expected (TE.encodeUtf8 s))
then Left "invalid session signature"
else do
payloadBytes <- b64Decode (TE.encodeUtf8 p)
case A.eitherDecodeStrict payloadBytes of
Left err -> Left (T.pack err)
Right (A.Object obj) -> Right obj
Right _ -> Left "jwt payload must be an object"
_ -> Left "malformed session token"
hmacSha256 :: BS.ByteString -> BS.ByteString -> BS.ByteString
hmacSha256 secret msg =
convert (hmac secret msg :: HMAC SHA256)
b64Json :: A.Value -> BS.ByteString
b64Json = B64.encodeUnpadded . BL.toStrict . A.encode
b64Raw :: BS.ByteString -> BS.ByteString
b64Raw = B64.encodeUnpadded
b64Decode :: BS.ByteString -> Either Text BS.ByteString
b64Decode bs =
case B64.decode bs of
Left err -> Left (T.pack err)
Right x -> Right x
claimText :: A.Object -> Text -> Either Text Text
claimText obj key =
case KM.lookup (Key.fromText key) obj of
Just (A.String t) -> Right t
_ -> Left ("missing claim " <> key)
claimInt :: A.Object -> Text -> Either Text Integer
claimInt obj key =
case KM.lookup (Key.fromText key) obj of
Just v ->
case A.fromJSON v of
A.Success (i :: Integer) -> Right i
_ -> Left ("invalid integer claim " <> key)
_ -> Left ("missing claim " <> key)
newCsrfToken :: IO CsrfToken
newCsrfToken = do
bytes <- getEntropy 32
@ -127,9 +153,9 @@ newCsrfToken = do
constantTimeEq :: CsrfToken -> CsrfToken -> Bool
constantTimeEq (CsrfToken a) (CsrfToken b) =
let ba = TE.encodeUtf8 a
bb = TE.encodeUtf8 b
in BS.length ba == BS.length bb
let left = TE.encodeUtf8 a
right = TE.encodeUtf8 b
in BS.length left == BS.length right
&& BA.constEq
(hash ba :: Digest SHA256)
(hash bb :: Digest SHA256)
(hash left :: Digest SHA256)
(hash right :: Digest SHA256)

View file

@ -1,6 +1,5 @@
module Nuthatch.Ui
( loginPage
, browserScript
( browserScript
, browsePage
, htmlResponse
, escapeHtml
@ -30,19 +29,6 @@ escapeHtml =
'\'' -> "&#39;"
c -> T.singleton c
loginPage :: Text -> Text
loginPage err =
page "Nuthatch login" $
T.unlines
[ "<h1>Nuthatch</h1>"
, "<p>Development login (OIDC is not wired in the MVP).</p>"
, if T.null err then "" else "<p class=\"err\">" <> escapeHtml err <> "</p>"
, "<form method=\"post\" action=\"/login\">"
, " <label>Username <input name=\"username\" required autofocus></label>"
, " <button type=\"submit\">Sign in</button>"
, "</form>"
]
browsePage
:: SessionUser
-> Root

View file

@ -1,14 +1,37 @@
module Main (main) where
import Control.Monad.IO.Class (liftIO)
import Control.Lens ((&), (?~))
import Crypto.JOSE (runJOSE)
import Crypto.JOSE.Compact (encodeCompact)
import Crypto.JOSE.JWA.JWS (Alg (RS256, RS384))
import Crypto.JOSE.JWK
( JWK
, JWKSet (..)
, KeyMaterialGenParam (RSAGenParam)
, genJWK
)
import Crypto.JOSE.JWS (newJWSHeaderProtected)
import Crypto.JWT
( Audience (..)
, ClaimsSet
, HasClaimsSet (..)
, JWTError
, NumericDate (..)
, emptyClaimsSet
, signJWT
)
import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.Either (isLeft)
import Data.Either (isLeft, isRight)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
import Hedgehog
( Gen
, Property
@ -24,6 +47,7 @@ import Network.HTTP.Types
, methodGet
, methodPost
, status200
, status303
, status400
, status404
, status405
@ -43,7 +67,8 @@ import Nuthatch.App
, loginResponse
, utf8Boundary
)
import Nuthatch.Config (AuthMode (..), Config (..))
import Nuthatch.Config (Config (..))
import Nuthatch.Oidc (OidcIdentity (..), verifyIdToken)
import Nuthatch.Paths
( buildObjectKey
, encodeUserId
@ -62,7 +87,7 @@ import Nuthatch.Protocol
)
import Nuthatch.Types
import Nuthatch.Session (mintSession)
import Nuthatch.Ui (browsePage, browserScript, loginPage)
import Nuthatch.Ui (browsePage, browserScript)
import Servant
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
import Servant.Types.SourceT (source)
@ -78,6 +103,7 @@ tests = testGroup "nuthatch-unit"
[ pathTests
, identityTests
, protocolTests
, oidcTests
, apiTests
]
@ -127,6 +153,222 @@ protocolTests = testGroup "mutation protocol decisions"
, testProperty "only stale active records are recovery eligible" propRecoveryTiming
]
oidcTests :: TestTree
oidcTests = testGroup "OIDC validation"
[ testCase "accepts an exact RS256 ID token" testValidIdToken
, testCase "accepts an exact configured audience set" testConfiguredAudiences
, testCase "rejects invalid nonce, algorithm, issuer, signature, and audience"
testIdTokenCryptographicRejections
, testCase "rejects malformed, missing-time, future, expired, and overlong tokens"
testIdTokenTimeAndSerializationRejections
]
data TestIdTokenClaims = TestIdTokenClaims
{ testIdTokenRegistered :: ClaimsSet
, testIdTokenNonce :: Text
, testIdTokenAuthorizedParty :: Maybe Text
, testIdTokenName :: Maybe Text
}
deriving stock (Eq, Show)
instance HasClaimsSet TestIdTokenClaims where
claimsSet update claims =
fmap
(\registered -> claims { testIdTokenRegistered = registered })
(update (testIdTokenRegistered claims))
instance A.FromJSON TestIdTokenClaims where
parseJSON value = A.withObject "test ID token" (\object ->
TestIdTokenClaims
<$> A.parseJSON value
<*> object A..: "nonce"
<*> object A..:? "azp"
<*> object A..:? "name") value
instance A.ToJSON TestIdTokenClaims where
toJSON claims =
case A.toJSON (testIdTokenRegistered claims) of
A.Object object ->
let withName =
maybe
object
(\name -> KM.insert "name" (A.String name) object)
(testIdTokenName claims)
withAuthorizedParty =
maybe
withName
(\authorizedParty ->
KM.insert "azp" (A.String authorizedParty) withName)
(testIdTokenAuthorizedParty claims)
in A.Object $
KM.insert
"nonce"
(A.String (testIdTokenNonce claims))
withAuthorizedParty
value -> value
testValidIdToken :: IO ()
testValidIdToken = do
key <- genJWK (RSAGenParam 256)
now <- getCurrentTime
compact <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
result <- verifyIdToken
testConfig
(JWKSet [key])
now
"nonce"
compact
result @?= Right OidcIdentity
{ identityIssuer = "https://issuer.example"
, identitySubject = "upstream-subject"
, identityDisplay = Just "Example User"
}
testConfiguredAudiences :: IO ()
testConfiguredAudiences = do
key <- genJWK (RSAGenParam 256)
now <- getCurrentTime
compact <- signTestIdToken key RS256
((testIdClaims now "https://issuer.example" ["project", "nuthatch"] "nonce")
{ testIdTokenAuthorizedParty = Just "nuthatch" })
missingAuthorizedParty <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["project", "nuthatch"] "nonce")
result <- verifyIdToken
testConfig { cfgOidcAdditionalAudiences = ["project"] }
(JWKSet [key])
now
"nonce"
compact
rejected <- verifyIdToken
testConfig { cfgOidcAdditionalAudiences = ["project"] }
(JWKSet [key])
now
"nonce"
missingAuthorizedParty
assertBool "the configured audience set is accepted" (isRight result)
assertBool "a multi-audience token without azp is rejected" (isLeft rejected)
testIdTokenCryptographicRejections :: IO ()
testIdTokenCryptographicRejections = do
key <- genJWK (RSAGenParam 256)
otherKey <- genJWK (RSAGenParam 256)
now <- getCurrentTime
valid <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
wrongAlgorithm <- signTestIdToken key RS384
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
wrongIssuer <- signTestIdToken key RS256
(testIdClaims now "https://other.example" ["nuthatch"] "nonce")
wrongSignature <- signTestIdToken otherKey RS256
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
wrongAudience <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["other"] "nonce")
multipleAudience <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["nuthatch", "other"] "nonce")
let rejected =
[ verifyIdToken testConfig (JWKSet [key]) now "other-nonce" valid
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongAlgorithm
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongIssuer
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongSignature
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongAudience
, verifyIdToken testConfig (JWKSet [key]) now "nonce" multipleAudience
]
results <- sequence rejected
assertBool "every invalid cryptographic or binding case is rejected"
(all isLeft results)
testIdTokenTimeAndSerializationRejections :: IO ()
testIdTokenTimeAndSerializationRejections = do
key <- genJWK (RSAGenParam 256)
now <- getCurrentTime
missingIssuedAt <- signTestIdToken key RS256
((testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
{ testIdTokenRegistered =
emptyClaimsSet
& claimIss ?~ "https://issuer.example"
& claimSub ?~ "upstream-subject"
& claimAud ?~ Audience ["nuthatch"]
& claimExp ?~ NumericDate (addUTCTime 300 now)
})
future <- signTestIdToken key RS256
(testIdClaimsAt
(addUTCTime 60 now)
(addUTCTime 300 now)
"https://issuer.example"
["nuthatch"]
"nonce")
expired <- signTestIdToken key RS256
(testIdClaimsAt
(addUTCTime (-600) now)
(addUTCTime (-300) now)
"https://issuer.example"
["nuthatch"]
"nonce")
overlong <- signTestIdToken key RS256
(testIdClaimsAt
now
(addUTCTime 7200 now)
"https://issuer.example"
["nuthatch"]
"nonce")
results <- sequence
[ verifyIdToken testConfig (JWKSet [key]) now "nonce" "not.compact"
, verifyIdToken testConfig (JWKSet [key]) now "nonce" missingIssuedAt
, verifyIdToken testConfig (JWKSet [key]) now "nonce" future
, verifyIdToken testConfig (JWKSet [key]) now "nonce" expired
, verifyIdToken testConfig (JWKSet [key]) now "nonce" overlong
]
assertBool "every invalid serialization or time case is rejected"
(all isLeft results)
testIdClaims
:: UTCTime
-> Text
-> [Text]
-> Text
-> TestIdTokenClaims
testIdClaims now issuer audiences nonce =
testIdClaimsAt
now
(addUTCTime 300 now)
issuer
audiences
nonce
testIdClaimsAt
:: UTCTime
-> UTCTime
-> Text
-> [Text]
-> Text
-> TestIdTokenClaims
testIdClaimsAt issuedAt expiresAt issuer audiences nonce =
TestIdTokenClaims
{ testIdTokenRegistered =
emptyClaimsSet
& claimIss ?~ fromString (T.unpack issuer)
& claimSub ?~ "upstream-subject"
& claimAud ?~ Audience (map (fromString . T.unpack) audiences)
& claimIat ?~ NumericDate issuedAt
& claimExp ?~ NumericDate expiresAt
, testIdTokenNonce = nonce
, testIdTokenAuthorizedParty = Nothing
, testIdTokenName = Just "Example User"
}
signTestIdToken
:: JWK
-> Alg
-> TestIdTokenClaims
-> IO Text
signTestIdToken key algorithm claims = do
result <- runJOSE $
encodeCompact <$> signJWT key (newJWSHeaderProtected algorithm) claims
case result of
Left (err :: JWTError) -> fail (show err)
Right compact -> pure (TE.decodeUtf8 (BL.toStrict compact))
apiTests :: TestTree
apiTests = testGroup "typed HTTP boundary"
[ testCase "Servant selects routes and methods" testRouteSelection
@ -140,18 +382,18 @@ apiTests = testGroup "typed HTTP boundary"
, testCase "operation-specific JSON requests and responses remain distinct"
testOperationRepresentations
, testCase "operation-specific errors have closed distinct representations" $ do
let loginError = A.encode InvalidUsername
let loginError = A.encode OidcCallbackRejected
deleteError = A.encode (DeleteMutationRejected "locked")
prefixError = A.encode (DeletePrefixMutationRejected "partial")
assertBool "login errors carry the login-specific code"
("invalid_username" `BS.isInfixOf` BL.toStrict loginError)
("oidc_callback_rejected" `BS.isInfixOf` BL.toStrict loginError)
assertBool "delete errors carry the mutation-specific code"
("mutation_rejected" `BS.isInfixOf` BL.toStrict deleteError)
assertBool "operation errors must not share one representation"
(loginError /= deleteError && deleteError /= prefixError)
assertBool "prefix errors carry their operation-specific code"
("prefix_mutation_rejected" `BS.isInfixOf` BL.toStrict prefixError)
, testCase "production authentication validates cookies and login emits both cookies"
, testCase "production authentication validates cookies and callback emits secure cookies"
testAuthenticationBoundary
, testCase "retained browser forms are wired to the typed API bridge"
testBrowserBridge
@ -254,27 +496,16 @@ testStreamingEndpoints = do
testOperationRepresentations :: IO ()
testOperationRepresentations = do
calls <- newIORef 0
login <- apiRequest calls methodPost
"/login"
[ ("Content-Type", "application/json")
, ("Accept", "application/json")
]
"{\"username\":\"alice\"}"
simpleStatus login @?= status200
assertBool "login response must carry a user identity"
("user_id" `BS.isInfixOf` BL.toStrict (simpleBody login))
formLogin <- apiRequest calls methodPost
"/login"
[("Content-Type", "application/x-www-form-urlencoded")]
"username=bob"
simpleStatus formLogin @?= status200
assertBool "browser login response navigates into the UI"
("continue" `BS.isInfixOf` BL.toStrict (simpleBody formLogin))
invalidLogin <- apiRequest calls methodPost
"/login"
[("Content-Type", "application/json")]
"{\"username\":\"two words\"}"
simpleStatus invalidLogin @?= status400
login <- apiRequest calls methodGet "/login" [] ""
simpleStatus login @?= status303
lookup "Location" (simpleHeaders login) @?= Just "https://issuer.example/authorize"
callback <- apiRequest calls methodGet
"/oidc/callback?code=code&state=state"
[("Cookie", "nuthatch_oidc=transaction")]
""
simpleStatus callback @?= status303
legacyLogin <- apiRequest calls methodPost "/login" [] ""
simpleStatus legacyLogin @?= status405
deletion <- apiRequest calls methodPost
"/delete"
[("Content-Type", "application/json")]
@ -350,18 +581,19 @@ typedTestServer :: IORef Int -> Server Api
typedTestServer calls =
counted (HealthResponse True)
:<|> counted (ReadyResponse True)
:<|> counted (HtmlPage "login")
:<|> counted (BrowserScript "script")
:<|> (\_ -> counted
:<|> counted
(addHeader "transaction=cookie"
(addHeader "https://issuer.example/authorize" NoContent))
:<|> (\_ _ _ -> counted
(addHeader "session=cookie"
(addHeader "csrf=cookie"
(addHeader (CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
(LoginView
(HtmlPage "continue")
(LoginResponse (UserId "user")))))))
(addHeader "transaction=expired"
(addHeader "/" NoContent)))))
:<|> (\_ _ -> counted
(addHeader "session=expired"
(addHeader "csrf=expired" NoContent)))
(addHeader "csrf=expired"
(addHeader "transaction=expired" NoContent))))
:<|> (\_ _ _ _ _ -> counted
(BrowseView
(HtmlPage "browse")
@ -392,17 +624,28 @@ testAuthenticationBoundary = do
token <- either (fail . T.unpack) pure tokenResult
let csrf = CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
responseHeaders = getHeaders
(loginResponse testConfig (UserId "user") token csrf)
(loginResponse testConfig token csrf)
cookieHeaders =
[ value
| (name, value) <- responseHeaders
, name == "Set-Cookie"
]
length cookieHeaders @?= 2
length cookieHeaders @?= 3
assertBool "login sets the session cookie"
(any ("nuthatch_session=" `BS.isPrefixOf`) cookieHeaders)
assertBool "login sets the CSRF cookie"
(any ("nuthatch_csrf=" `BS.isPrefixOf`) cookieHeaders)
assertBool "cookies are secure and same-site"
(all ("Secure" `BS.isInfixOf`) cookieHeaders
&& all ("SameSite=Lax" `BS.isInfixOf`) cookieHeaders)
assertBool "cookies are host-only"
(all (not . ("Domain=" `BS.isInfixOf`)) cookieHeaders)
assertBool "session and CSRF cookies use the root path"
(length (filter ("Path=/;" `BS.isInfixOf`) cookieHeaders) == 2)
assertBool "the transaction cookie uses the callback path"
(any ("Path=/oidc/callback;" `BS.isInfixOf`) cookieHeaders)
assertBool "session and transaction cookies are HTTP-only"
(length (filter ("HttpOnly" `BS.isInfixOf`) cookieHeaders) == 2)
calls <- newIORef 0
let app = productionAuthTestApplication testConfig calls
@ -425,6 +668,26 @@ testAuthenticationBoundary = do
simpleStatus malformed @?= status400
readIORef calls >>= (@?= 0)
malformedGrammar <- apiRequestWith app methodGet
"/?root=personal"
[("Cookie", "nuthatch_session")]
""
simpleStatus malformedGrammar @?= status400
readIORef calls >>= (@?= 0)
let tamperedToken =
case T.unsnoc (unSessionToken token) of
Just (prefix, final)
| final == 'a' -> prefix <> "b"
| otherwise -> prefix <> "a"
Nothing -> "invalid"
tampered <- apiRequestWith app methodGet
"/?root=personal"
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 tamperedToken)]
""
simpleStatus tampered @?= status303
readIORef calls >>= (@?= 0)
missingCsrf <- apiRequestWith app methodGet
"/?root=personal"
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token))]
@ -457,8 +720,6 @@ testBrowserBridge = do
[entry]
Nothing
""
assertBool "login page loads the external bridge"
("/nuthatch.js" `T.isInfixOf` loginPage "")
mapM_
(\marker -> assertBool ("browse page includes " <> T.unpack marker)
(marker `T.isInfixOf` browse))
@ -486,11 +747,21 @@ testConfig :: Config
testConfig = Config
{ cfgListenHost = "127.0.0.1"
, cfgListenPort = 8080
, cfgTlsCertPath = "/tls/tls.crt"
, cfgTlsKeyPath = "/tls/tls.key"
, cfgPublicBaseUrl = "https://nuthatch.example"
, cfgJwtSecret = "unit-test-secret-material"
, cfgJwtSecret = "unit-test-session-secret-material-32"
, cfgSessionHours = 1
, cfgCookieName = "nuthatch_session"
, cfgCsrfCookieName = "nuthatch_csrf"
, cfgOidcTransactionCookieName = "nuthatch_oidc"
, cfgOidcIssuer = "https://issuer.example"
, cfgOidcClientId = "nuthatch"
, cfgOidcAdditionalAudiences = []
, cfgOidcClientSecret = "unit-test-client-secret"
, cfgOidcRedirectUri = "https://nuthatch.example/oidc/callback"
, cfgOidcTransactionMinutes = 5
, cfgOidcMaxIdTokenSeconds = 3600
, cfgPgConn = ""
, cfgRedisHost = ""
, cfgRedisPort = 6379
@ -509,7 +780,6 @@ testConfig = Config
, cfgListCacheTtlSec = 30
, cfgSearchMaxKeys = 100
, cfgRecoveryGraceSec = 30
, cfgAuthMode = AuthDev
}
newtype ValidPath = ValidPath { getValidPath :: Text }

View file

@ -207,6 +207,9 @@ spec:
spec:
automountServiceAccountToken: false
enableServiceLinks: false
volumes:
- name: bootstrap
emptyDir: {}
containers:
- name: zitadel
image: @@ZITADEL_IMAGE@@
@ -220,6 +223,21 @@ spec:
- {name: ZITADEL_EXTERNALPORT, value: "8080"}
- {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME, value: nuthatch-admin}
- {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD, value: Synthetic-Zitadel-1!}
- {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED, value: "false"}
- {name: ZITADEL_FIRSTINSTANCE_PATPATH, value: /bootstrap/admin.pat}
- {name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME, value: nuthatch-provisioner}
- {name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME, value: Nuthatch Provisioner}
- {name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE, value: "2036-01-01T00:00:00Z"}
- {name: ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH, value: /bootstrap/login-client.pat}
- {name: ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME, value: nuthatch-login-client}
- {name: ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME, value: Nuthatch Login Client}
- {name: ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE, value: "2036-01-01T00:00:00Z"}
- {name: ZITADEL_DEFAULTINSTANCE_OIDCSETTINGS_ACCESSTOKENLIFETIME, value: 5m}
- {name: ZITADEL_DEFAULTINSTANCE_OIDCSETTINGS_IDTOKENLIFETIME, value: 5m}
- {name: ZITADEL_DEFAULTINSTANCE_OIDCSETTINGS_REFRESHTOKENIDLEEXPIRATION, value: 1h}
- {name: ZITADEL_DEFAULTINSTANCE_OIDCSETTINGS_REFRESHTOKENEXPIRATION, value: 2h}
volumeMounts:
- {name: bootstrap, mountPath: /bootstrap}
ports:
- {name: http, containerPort: 8080}
readinessProbe:
@ -233,6 +251,25 @@ spec:
resources:
requests: {cpu: 200m, memory: 512Mi}
limits: {cpu: "2", memory: 1Gi}
- name: oidc-provisioner
image: @@CURL_IMAGE@@
imagePullPolicy: Never
command: [/bin/sh, -ec]
args:
- |
while [ ! -s /bootstrap/admin.pat ] || [ ! -s /bootstrap/login-client.pat ]; do
sleep 1
done
exec sleep 2147483647
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
runAsUser: 0
volumeMounts:
- {name: bootstrap, mountPath: /bootstrap, readOnly: true}
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 100m, memory: 32Mi}
---
apiVersion: v1
kind: Service

View file

@ -197,6 +197,7 @@ render_manifests() {
-e "s|@@GARAGE_IMAGE@@|$garage|g" \
-e "s|@@VERSITYGW_IMAGE@@|$versitygw|g" \
-e "s|@@COREDNS_IMAGE@@|$coredns|g" \
-e "s|@@CURL_IMAGE@@|$curl|g" \
-e "s|@@AWS_CLI_IMAGE@@|$aws_cli|g" \
-e "s|@@POSTGRES_REPLICAS@@|$postgres_replicas|g" \
-e "s|@@REDIS_REPLICAS@@|$redis_replicas|g" \
@ -528,6 +529,153 @@ provision_lane_target_principal() {
printf -v "$secret_key_var" '%s' "$secret_key"
}
keycloak_admin_token() {
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
sh -ec '
/opt/keycloak/bin/kcadm.sh config credentials \
--server http://127.0.0.1:8080 \
--realm master \
--user nuthatch-admin \
--password synthetic-keycloak >/dev/null
/opt/keycloak/bin/kcadm.sh get serverinfo >/dev/null
' >/dev/null
}
provision_lane_keycloak_registration() {
local lane="$1"
local redirect_uri="$2"
local client_id_var="$3"
local client_secret_var="$4"
local additional_audiences_var="$5"
local user_a_var="$6"
local user_b_var="$7"
local client_id="nuthatch-$lane"
local client_secret="synthetic-keycloak-client-secret-$lane"
local username
keycloak_admin_token || return
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
/opt/keycloak/bin/kcadm.sh create clients \
--realm master \
-s "clientId=$client_id" \
-s enabled=true \
-s protocol=openid-connect \
-s publicClient=false \
-s clientAuthenticatorType=client-secret \
-s standardFlowEnabled=true \
-s directAccessGrantsEnabled=false \
-s "secret=$client_secret" \
-s "redirectUris=[\"$redirect_uri\"]" \
-s 'attributes={"pkce.code.challenge.method":"S256"}' >/dev/null || return
for username in "nuthatch-$lane-a" "nuthatch-$lane-b"; do
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
/opt/keycloak/bin/kcadm.sh create users \
--realm master \
-s "username=$username" \
-s enabled=true \
-s "firstName=$username" >/dev/null || return
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
/opt/keycloak/bin/kcadm.sh set-password \
--realm master \
--username "$username" \
--new-password Synthetic-OIDC-1! >/dev/null || return
done
printf -v "$client_id_var" '%s' "$client_id"
printf -v "$client_secret_var" '%s' "$client_secret"
printf -v "$additional_audiences_var" ''
printf -v "$user_a_var" '%s' "nuthatch-$lane-a"
printf -v "$user_b_var" '%s' "nuthatch-$lane-b"
}
zitadel_api() {
local token_path="$1"
local method="$2"
local path="$3"
local body="$4"
kube --namespace "$FIXTURE_NAMESPACE" exec \
deployment/zitadel \
--container oidc-provisioner \
-- sh -ec '
token="$(tr -d "\r\n" < "$1")"
curl --fail --silent --show-error \
--request "$2" \
--header "Host: zitadel.nuthatch.test" \
--header "Authorization: Bearer $token" \
--header "Content-Type: application/json" \
--data "$4" \
"http://127.0.0.1:8080$3"
' sh "$token_path" "$method" "$path" "$body"
}
provision_lane_zitadel_registration() {
local lane="$1"
local redirect_uri="$2"
local client_id_var="$3"
local client_secret_var="$4"
local additional_audiences_var="$5"
local user_a_var="$6"
local user_b_var="$7"
local project_response
local project_id
local app_response
local client_id
local client_secret
local username
local user_response
local user_ids=()
project_response="$(
zitadel_api \
/bootstrap/admin.pat \
POST \
/management/v1/projects \
"{\"name\":\"Nuthatch lane $lane\"}"
)" || return
project_id="$(jq -er .id <<< "$project_response")" || return
app_response="$(
zitadel_api \
/bootstrap/admin.pat \
POST \
"/management/v1/projects/$project_id/apps/oidc" \
"{\"projectId\":\"$project_id\",\"name\":\"Nuthatch lane $lane\",\"redirectUris\":[\"$redirect_uri\"],\"responseTypes\":[\"OIDC_RESPONSE_TYPE_CODE\"],\"grantTypes\":[\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\"],\"appType\":\"OIDC_APP_TYPE_WEB\",\"authMethodType\":\"OIDC_AUTH_METHOD_TYPE_BASIC\",\"version\":\"OIDC_VERSION_1_0\",\"devMode\":false}"
)" || return
client_id="$(jq -er .clientId <<< "$app_response")" || return
client_secret="$(jq -er .clientSecret <<< "$app_response")" || return
for username in "nuthatch-$lane-a" "nuthatch-$lane-b"; do
user_response="$(
zitadel_api \
/bootstrap/admin.pat \
POST \
/management/v1/users/human \
"{\"userName\":\"$username\",\"profile\":{\"firstName\":\"Nuthatch\",\"lastName\":\"$username\",\"displayName\":\"$username\",\"preferredLanguage\":\"en\"},\"email\":{\"email\":\"$username@example.test\",\"isEmailVerified\":true},\"initialPassword\":\"Synthetic-OIDC-1!\"}"
)" || return
user_ids+=("$(jq -er .userId <<< "$user_response")") || return
done
printf -v "$client_id_var" '%s' "$client_id"
printf -v "$client_secret_var" '%s' "$client_secret"
printf -v "$additional_audiences_var" '%s' "$project_id"
printf -v "$user_a_var" '%s' "${user_ids[0]}"
printf -v "$user_b_var" '%s' "${user_ids[1]}"
}
create_oidc_automation_secret() {
local lane="$1"
local provider="$2"
local login_pat=unused
if [[ "$provider" = zitadel ]]; then
login_pat="$(
kube --namespace "$FIXTURE_NAMESPACE" exec \
deployment/zitadel \
--container oidc-provisioner \
-- sh -ec 'tr -d "\r\n" < /bootstrap/login-client.pat'
)" || return
fi
kube create secret generic "nuthatch-oidc-automation-$lane" \
--namespace "$FIXTURE_NAMESPACE" \
--from-literal="NUTHATCH_OIDC_LOGIN_PAT=$login_pat" \
--dry-run=client \
--output yaml |
kube apply --filename - >/dev/null
}
write_lane_profile() {
local lane="$1"
local coordination="$2"
@ -538,6 +686,12 @@ write_lane_profile() {
local database=
local coordination_database=
local oidc_issuer=
local oidc_client_id=
local oidc_client_secret=
local oidc_additional_audiences=
local oidc_redirect_uri=
local oidc_user_a=
local oidc_user_b=
local target_url=
local target_bucket=
local target_region=
@ -550,11 +704,36 @@ write_lane_profile() {
coordination_database="$(reset_lane_coordination_store "$coordination" "$lane")" || return
fi
if fixture_has_family oidc; then
oidc_redirect_uri="https://nuthatch-$lane:8080/oidc/callback"
case "$oidc" in
keycloak) oidc_issuer=http://keycloak.nuthatch.test:8080/realms/master ;;
zitadel) oidc_issuer=http://zitadel.nuthatch.test:8080 ;;
*) return 1 ;;
esac
case "$oidc" in
keycloak)
provision_lane_keycloak_registration \
"$lane" \
"$oidc_redirect_uri" \
oidc_client_id \
oidc_client_secret \
oidc_additional_audiences \
oidc_user_a \
oidc_user_b || return
;;
zitadel)
provision_lane_zitadel_registration \
"$lane" \
"$oidc_redirect_uri" \
oidc_client_id \
oidc_client_secret \
oidc_additional_audiences \
oidc_user_a \
oidc_user_b || return
;;
*) return 1 ;;
esac
create_oidc_automation_secret "$lane" "$oidc" || return
fi
if fixture_has_family target; then
target_bucket="nuthatch-lane-$lane"
@ -581,6 +760,12 @@ write_lane_profile() {
printf 'NUTHATCH_PG_CONN=host=postgres port=5432 user=nuthatch password=synthetic-postgres dbname=%s\n' "$database"
printf 'NUTHATCH_REDIS_URL=redis://%s:6379/%s\n' "$coordination" "$coordination_database"
printf 'NUTHATCH_OIDC_ISSUER=%s\n' "$oidc_issuer"
printf 'NUTHATCH_OIDC_CLIENT_ID=%s\n' "$oidc_client_id"
printf 'NUTHATCH_OIDC_ADDITIONAL_AUDIENCES=%s\n' "$oidc_additional_audiences"
printf 'NUTHATCH_OIDC_REDIRECT_URI=%s\n' "$oidc_redirect_uri"
printf 'NUTHATCH_OIDC_USER_A=%s\n' "$oidc_user_a"
printf 'NUTHATCH_OIDC_USER_B=%s\n' "$oidc_user_b"
printf 'NUTHATCH_OIDC_USER_PASSWORD=Synthetic-OIDC-1!\n'
printf 'NUTHATCH_S3_ENDPOINT=%s\n' "$target_url"
printf 'NUTHATCH_S3_BUCKET=%s\n' "$target_bucket"
printf 'NUTHATCH_S3_REGION=%s\n' "$target_region"
@ -588,6 +773,10 @@ write_lane_profile() {
printf 'NUTHATCH_S3_SECRET_KEY=%s\n' "$target_secret_key"
printf 'NUTHATCH_S3_PATH_STYLE=true\n'
} > "$profile_file" || return
if fixture_has_family oidc; then
printf '%s' "$oidc_client_secret" > "$profile_file.oidc-secret" || return
chmod 0600 "$profile_file.oidc-secret" || return
fi
kube create configmap "$profile" \
--namespace "$FIXTURE_NAMESPACE" \
--from-env-file="$profile_file" \
@ -682,8 +871,12 @@ cleanup_lane_resources() {
"job/$job" \
"configmap/$profile" \
"configmap/$profile-file" \
"secret/nuthatch-oidc-automation-${profile##*-}" \
--ignore-not-found >/dev/null || return
rm -f "$RUNTIME/$profile.env" "$RUNTIME/$job.yaml"
rm -f \
"$RUNTIME/$profile.env" \
"$RUNTIME/$profile.env.oidc-secret" \
"$RUNTIME/$job.yaml"
}
run_job_matrix() {
@ -722,6 +915,7 @@ destroy_fixture() {
"$RUNTIME/probe.yaml" \
"$RUNTIME/fixture.env" \
"$RUNTIME"/nuthatch-fixture-lane-*.env \
"$RUNTIME"/nuthatch-fixture-lane-*.env.oidc-secret \
"$RUNTIME"/wp05-app-*.yaml || status=1
return "$status"
}

View file

@ -21,8 +21,14 @@ spec:
envFrom:
- configMapRef: {name: @@PROFILE_NAME@@}
env:
- {name: NUTHATCH_JWT_SECRET, value: synthetic-wp04-jwt-secret}
- {name: NUTHATCH_JWT_SECRET, value: synthetic-wp04-session-jwt-secret}
- {name: NUTHATCH_REDIS_URL, value: "redis://127.0.0.1:6379/0"}
- {name: NUTHATCH_PUBLIC_BASE_URL, value: "https://wp04.invalid"}
- {name: NUTHATCH_OIDC_ISSUER, value: "https://issuer.invalid"}
- {name: NUTHATCH_OIDC_CLIENT_ID, value: wp04}
- {name: NUTHATCH_OIDC_ADDITIONAL_AUDIENCES, value: ""}
- {name: NUTHATCH_OIDC_CLIENT_SECRET, value: synthetic-wp04-oidc-client-secret}
- {name: NUTHATCH_OIDC_REDIRECT_URI, value: "https://wp04.invalid/oidc/callback"}
resources:
requests: {cpu: 100m, memory: 32Mi}
limits: {cpu: "2", memory: 96Mi}

View file

@ -11,6 +11,15 @@ spec:
spec:
automountServiceAccountToken: false
enableServiceLinks: false
dnsPolicy: None
dnsConfig:
nameservers: [10.96.0.53]
searches:
- nuthatch-testbed.svc.cluster.local
- svc.cluster.local
- cluster.local
options:
- {name: ndots, value: "5"}
restartPolicy: Never
initContainers:
- name: credential-negative
@ -57,18 +66,301 @@ spec:
command: [/bin/sh, -ec]
args:
- |
base="http://nuthatch-@@LANE@@:8080"
curl --fail --silent --show-error "$base/healthz" | grep -F '"ok":true'
curl --fail --silent --show-error "$base/readyz" | grep -F '"ready":true'
base="https://nuthatch-@@LANE@@:8080"
curl() {
command curl --insecure "$@"
}
header_value() {
awk -v expected="$2" '
BEGIN { IGNORECASE=1 }
$1 == expected ":" {
sub(/^[^:]*:[[:space:]]*/, "")
sub(/\r$/, "")
print
exit
}
' "$1"
}
json_field() {
tr -d '\n' < "$1" |
sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
}
oidc_callback_for() {
user="$1"
suffix="$2"
authorization_url="$3"
printf 'Completing %s authorization %s\n' \
"$NUTHATCH_OIDC_PROVIDER" "$suffix" >&2
case "$NUTHATCH_OIDC_PROVIDER" in
keycloak)
curl --fail --silent --show-error \
--cookie-jar /tmp/cookies \
--dump-header /tmp/login-headers \
--header 'Accept: application/json' \
--cookie-jar "/tmp/idp-$suffix" \
--dump-header "/tmp/idp-start-$suffix.headers" \
"$authorization_url" >"/tmp/idp-$suffix.html"
action="$(
sed -n 's/.*action="\([^"]*\)".*/\1/p' "/tmp/idp-$suffix.html" |
head -n 1 |
sed 's/&amp;/\&/g'
)"
test -n "$action"
curl --silent --show-error \
--cookie "/tmp/idp-$suffix" \
--cookie-jar "/tmp/idp-$suffix" \
--dump-header "/tmp/idp-finish-$suffix.headers" \
--data-urlencode "username=$user" \
--data-urlencode "password=$NUTHATCH_OIDC_USER_PASSWORD" \
--data-urlencode credentialId= \
"$action" >"/tmp/idp-finish-$suffix.body"
header_value "/tmp/idp-finish-$suffix.headers" Location
;;
zitadel)
curl --silent --show-error \
--dump-header "/tmp/idp-start-$suffix.headers" \
"$authorization_url" >"/tmp/idp-start-$suffix.body"
login_location="$(
header_value "/tmp/idp-start-$suffix.headers" Location
)"
auth_request="${login_location##*authRequest=}"
auth_request="${auth_request%%&*}"
test -n "$auth_request"
curl --fail --silent --show-error \
--header "Authorization: Bearer $NUTHATCH_OIDC_LOGIN_PAT" \
--header 'Content-Type: application/json' \
--data '{"username":"wp05-smoke"}' \
"$base/login" >/tmp/login.json
csrf="$(awk 'BEGIN{IGNORECASE=1} $1=="X-CSRF-Token:"{gsub("\r","",$2); print $2}' /tmp/login-headers)"
--data "{\"checks\":{\"user\":{\"userId\":\"$user\"},\"password\":{\"password\":\"$NUTHATCH_OIDC_USER_PASSWORD\"}},\"lifetime\":\"300s\"}" \
"$NUTHATCH_OIDC_ISSUER/v2/sessions" \
>"/tmp/session-$suffix.json"
session_id="$(json_field "/tmp/session-$suffix.json" sessionId)"
session_token="$(json_field "/tmp/session-$suffix.json" sessionToken)"
test -n "$session_id"
test -n "$session_token"
curl --fail --silent --show-error \
--header "Authorization: Bearer $NUTHATCH_OIDC_LOGIN_PAT" \
--header 'Content-Type: application/json' \
--data "{\"session\":{\"sessionId\":\"$session_id\",\"sessionToken\":\"$session_token\"}}" \
"$NUTHATCH_OIDC_ISSUER/v2/oidc/auth_requests/$auth_request" \
>"/tmp/finalize-$suffix.json"
json_field "/tmp/finalize-$suffix.json" callbackUrl |
sed 's/\\u0026/\&/g; s#\\/#/#g'
;;
*)
exit 1
;;
esac
}
login() {
user="$1"
suffix="$2"
cookies="/tmp/cookies-$suffix"
printf 'Starting login %s\n' "$suffix" >&2
curl --insecure --silent --show-error \
--cookie-jar "$cookies" \
--dump-header "/tmp/login-$suffix.headers" \
"$base/login" >"/tmp/login-$suffix.body"
test "$(header_value "/tmp/login-$suffix.headers" Status 2>/dev/null || true)" = ""
authorization_url="$(
header_value "/tmp/login-$suffix.headers" Location
)"
test -n "$authorization_url"
grep -i '^set-cookie: nuthatch_oidc=' "/tmp/login-$suffix.headers" |
grep -i 'secure' |
grep -i 'httponly' |
grep -i 'samesite=lax' |
grep -i 'path=/oidc/callback' >/dev/null
state="$(
printf '%s' "$authorization_url" |
sed -n 's/.*[?&]state=\([^&]*\).*/\1/p'
)"
test -n "$state"
if [ "$suffix" = a ]; then
wrong_redirect_url="$(
printf '%s' "$authorization_url" |
sed 's#redirect_uri=[^&]*#redirect_uri=https%3A%2F%2Finvalid.example%2Fcallback#'
)"
wrong_redirect_status="$(
curl --silent --output /dev/null \
--write-out '%{http_code}' \
"$wrong_redirect_url"
)"
test "$wrong_redirect_status" -ge 400
printf 'Redirect URI rejection passed\n' >&2
fi
if curl --insecure --fail --silent \
--cookie "$cookies" \
"$base/oidc/callback?code=unused&state=invalid-$state" >/dev/null; then
exit 1
fi
if [ "$suffix" = a ]; then
transaction="$(
awk -F '\t' '$6 == "nuthatch_oidc" { print $7 }' "$cookies"
)"
test "${#transaction}" -gt 1
tampered_transaction="x${transaction#?}"
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--header 'Cookie: nuthatch_oidc=malformed' \
"$base/oidc/callback?code=unused&state=$state"
)" = 400
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--header "Cookie: nuthatch_oidc=$tampered_transaction" \
"$base/oidc/callback?code=unused&state=$state"
)" = 400
fi
callback="$(
oidc_callback_for "$user" "$suffix" "$authorization_url"
)"
test -n "$callback"
printf 'Authorization callback received %s\n' "$suffix" >&2
callback_status="$(
curl --insecure --silent --show-error \
--output "/tmp/callback-$suffix.body" \
--write-out '%{http_code}' \
--cookie "$cookies" \
--cookie-jar "$cookies" \
--dump-header "/tmp/callback-$suffix.headers" \
"$callback"
)"
printf 'Application callback status=%s %s\n' \
"$callback_status" "$suffix" >&2
if [ "$callback_status" != 303 ]; then
sed -n '1,8p' "/tmp/callback-$suffix.body" >&2
exit 1
fi
test "$(header_value "/tmp/callback-$suffix.headers" Location)" = "/"
printf 'Application callback accepted %s\n' "$suffix" >&2
sed -n \
's/^\([Ss]et-[Cc]ookie: [^=]*\)=.*/\1=<redacted>/p' \
"/tmp/callback-$suffix.headers" >&2
session_cookie="$(
grep -i '^set-cookie: nuthatch_session=' "/tmp/callback-$suffix.headers"
)"
csrf_cookie="$(
grep -i '^set-cookie: nuthatch_csrf=' "/tmp/callback-$suffix.headers"
)"
transaction_cookie="$(
grep -i '^set-cookie: nuthatch_oidc=' "/tmp/callback-$suffix.headers"
)"
printf '%s' "$session_cookie" |
grep -i secure |
grep -i httponly |
grep -i samesite=lax |
grep -i 'path=/;' >/dev/null
printf '%s' "$csrf_cookie" |
grep -i secure |
grep -i samesite=lax |
grep -iv httponly |
grep -i 'path=/;' >/dev/null
printf '%s' "$transaction_cookie" |
grep -i 'max-age=0' |
grep -i 'path=/oidc/callback;' >/dev/null
if printf '%s\n%s\n%s\n' \
"$session_cookie" "$csrf_cookie" "$transaction_cookie" |
grep -qi 'domain='; then
exit 1
fi
printf 'Cookie attributes passed %s\n' "$suffix" >&2
awk -F '\t' '
NF == 7 {
printf "Stored cookie %s value-length=%d\n", $6, length($7) > "/dev/stderr"
}
$6 == "nuthatch_csrf" { print $7 }
' "$cookies"
}
curl --insecure --fail --silent --show-error "$base/healthz" |
grep -F '"ok":true'
curl --insecure --fail --silent --show-error "$base/readyz" |
grep -F '"ready":true'
curl --insecure --fail --silent --show-error "$base/nuthatch.js" |
grep -F 'nuthatch'
{
curl --insecure --fail --silent --show-error "$base/nuthatch.js"
curl --insecure --silent --show-error "$base/login"
} > /tmp/browser-visible
if grep -Ei \
'NUTHATCH_S3|AWS_ACCESS_KEY|AWS_SECRET_ACCESS_KEY|client_secret' \
/tmp/browser-visible; then
exit 1
fi
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--request POST \
"$base/login"
)" = 405
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
"$base/"
)" = 303
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
"$base/download?root=personal&path=unauthenticated"
)" = 303
unauthenticated_digest="$(
printf x | sha256sum | cut -d' ' -f1
)"
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--header 'X-Declared-Size: 1' \
--header "X-Content-SHA256: $unauthenticated_digest" \
--header 'X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
--data x \
"$base/upload?root=personal&path=unauthenticated&operation_id=40000000-0000-4000-8000-000000000001"
)" = 303
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--header 'Content-Type: application/json' \
--data '{"root":"personal","path":"unauthenticated","operation_id":"40000000-0000-4000-8000-000000000002","csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
"$base/delete"
)" = 303
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--header 'Content-Type: application/json' \
--data '{"root":"personal","path":"unauthenticated","csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
"$base/delete-prefix"
)" = 303
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--header 'X-CSRF-Token: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
--request POST \
"$base/logout"
)" = 303
printf 'Unauthenticated boundary probes passed\n'
csrf="$(login "$NUTHATCH_OIDC_USER_A" a)"
test -n "$csrf"
cookies=/tmp/cookies-a
printf 'Login a completed csrf-length=%d\n' "${#csrf}"
authenticated_status="$(
curl --insecure --silent --output /tmp/authenticated.body \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/"
)"
printf 'Authenticated browse status=%s\n' "$authenticated_status"
test "$authenticated_status" = 200
curl --insecure --fail --silent --show-error \
--cookie "$cookies" \
--header 'Accept: application/json' \
"$base/" >/tmp/authenticated.json
if grep -Ei \
'NUTHATCH_S3|AWS_ACCESS_KEY|AWS_SECRET_ACCESS_KEY|client_secret' \
/tmp/authenticated.body /tmp/authenticated.json; then
exit 1
fi
exercise_root() {
root="$1"
@ -78,22 +370,28 @@ spec:
printf '%s' "$first" >/tmp/body
size="$(wc -c </tmp/body | tr -d ' ')"
digest="$(sha256sum /tmp/body | cut -d' ' -f1)"
curl --fail --silent --show-error \
--cookie /tmp/cookies \
upload_status="$(
curl --silent --show-error \
--output /tmp/upload.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $size" \
--header "X-Content-SHA256: $digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/body \
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=00000000-0000-4000-8000-0000000000${suffix}" |
grep -F '"result":"succeeded"'
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=00000000-0000-4000-8000-0000000000${suffix}"
)"
printf 'Upload %s status=%s\n' "$root" "$upload_status"
test "$upload_status" = 200
grep -F '"result":"succeeded"' /tmp/upload.json
curl --fail --silent --show-error \
--cookie /tmp/cookies \
--cookie "$cookies" \
--header 'Accept: application/json' \
"$base/?root=$root" |
grep -F "smoke-$suffix.txt"
curl --fail --silent --show-error \
--cookie /tmp/cookies \
--cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
test "$(cat /tmp/download)" = "$first"
@ -101,7 +399,7 @@ spec:
size="$(wc -c </tmp/body | tr -d ' ')"
digest="$(sha256sum /tmp/body | cut -d' ' -f1)"
curl --fail --silent --show-error \
--cookie /tmp/cookies \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $size" \
--header "X-Content-SHA256: $digest" \
@ -110,18 +408,18 @@ spec:
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=10000000-0000-4000-8000-0000000000${suffix}" |
grep -F '"result":"succeeded"'
curl --fail --silent --show-error \
--cookie /tmp/cookies \
--cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
test "$(cat /tmp/download)" = "$second"
curl --fail --silent --show-error \
--cookie /tmp/cookies \
--cookie "$cookies" \
--header 'Content-Type: application/json' \
--data "{\"root\":\"$root\",\"path\":\"smoke-$suffix.txt\",\"operation_id\":\"20000000-0000-4000-8000-0000000000${suffix}\",\"csrf\":\"$csrf\"}" \
"$base/delete" |
grep -F '"result":"succeeded"'
if curl --fail --silent \
--cookie /tmp/cookies \
--cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/dev/null; then
exit 1
fi
@ -129,7 +427,152 @@ spec:
exercise_root personal 01
exercise_root shared 02
printf 'WP-05 app smoke passed\n'
printf private-a >/tmp/body
size="$(wc -c </tmp/body | tr -d ' ')"
digest="$(sha256sum /tmp/body | cut -d' ' -f1)"
curl --insecure --fail --silent --show-error \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $size" \
--header "X-Content-SHA256: $digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/body \
"$base/upload?root=personal&path=cross-user.txt&operation_id=30000000-0000-4000-8000-000000000001" |
grep -F '"result":"succeeded"'
curl --insecure --fail --silent --show-error \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $size" \
--header "X-Content-SHA256: $digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/body \
"$base/upload?root=shared&path=cross-user.txt&operation_id=30000000-0000-4000-8000-000000000002" |
grep -F '"result":"succeeded"'
csrf_b="$(login "$NUTHATCH_OIDC_USER_B" b)"
test -n "$csrf_b"
curl --insecure --silent --show-error \
--cookie-jar /tmp/cookies-pkce \
--dump-header /tmp/login-pkce.headers \
"$base/login" >/tmp/login-pkce.body
pkce_authorization_url="$(
header_value /tmp/login-pkce.headers Location
)"
pkce_callback="$(
oidc_callback_for \
"$NUTHATCH_OIDC_USER_A" \
pkce \
"$pkce_authorization_url"
)"
pkce_code="$(
printf '%s' "$pkce_callback" |
sed -n 's/.*[?&]code=\([^&]*\).*/\1/p'
)"
test -n "$pkce_code"
curl --fail --silent --show-error \
"$NUTHATCH_OIDC_ISSUER/.well-known/openid-configuration" \
>/tmp/discovery.json
token_endpoint="$(json_field /tmp/discovery.json token_endpoint)"
test -n "$token_endpoint"
pkce_status="$(
curl --silent --output /tmp/pkce-error.json \
--write-out '%{http_code}' \
--user "$NUTHATCH_OIDC_CLIENT_ID:$NUTHATCH_OIDC_CLIENT_SECRET" \
--data-urlencode grant_type=authorization_code \
--data-urlencode "code=$pkce_code" \
--data-urlencode "redirect_uri=$NUTHATCH_OIDC_REDIRECT_URI" \
--data-urlencode code_verifier=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
"$token_endpoint"
)"
test "$pkce_status" -ge 400
if curl --insecure --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/?root=personal" |
grep -F cross-user.txt; then
exit 1
fi
curl --insecure --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/?root=shared" |
grep -F cross-user.txt
if curl --insecure --fail --silent \
--cookie "$cookies" \
--header 'X-CSRF-Token: invalid' \
--request POST \
"$base/logout" >/dev/null; then
exit 1
fi
curl --insecure --fail --silent --show-error \
--cookie "$cookies" \
--cookie-jar "$cookies" \
--dump-header /tmp/logout.headers \
--header "X-CSRF-Token: $csrf" \
--request POST \
"$base/logout" >/dev/null
grep -i '^set-cookie: nuthatch_session=' /tmp/logout.headers |
grep -i 'max-age=0' >/dev/null
grep -i '^set-cookie: nuthatch_csrf=' /tmp/logout.headers |
grep -i 'max-age=0' >/dev/null
grep -i '^set-cookie: nuthatch_oidc=' /tmp/logout.headers |
grep -i 'max-age=0' |
grep -i 'path=/oidc/callback' >/dev/null
test "$(
curl --insecure --silent --output /dev/null \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/"
)" = 303
printf 'OIDC app smoke passed\n'
env:
- name: NUTHATCH_OIDC_PROVIDER
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_OIDC_PROVIDER
- name: NUTHATCH_OIDC_ISSUER
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_OIDC_ISSUER
- name: NUTHATCH_OIDC_USER_A
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_OIDC_USER_A
- name: NUTHATCH_OIDC_USER_B
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_OIDC_USER_B
- name: NUTHATCH_OIDC_USER_PASSWORD
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_OIDC_USER_PASSWORD
- name: NUTHATCH_OIDC_CLIENT_ID
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_OIDC_CLIENT_ID
- name: NUTHATCH_OIDC_REDIRECT_URI
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_OIDC_REDIRECT_URI
- name: NUTHATCH_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: nuthatch-@@LANE@@
key: NUTHATCH_OIDC_CLIENT_SECRET
- name: NUTHATCH_OIDC_LOGIN_PAT
valueFrom:
secretKeyRef:
name: nuthatch-oidc-automation-@@LANE@@
key: NUTHATCH_OIDC_LOGIN_PAT
resources:
requests: {cpu: 50m, memory: 32Mi}
limits: {cpu: "1", memory: 128Mi}

View file

@ -36,6 +36,10 @@ render_application() {
local profile_file="$2"
local pg_conn
local redis_url
local oidc_issuer
local oidc_client_id
local oidc_additional_audiences
local oidc_client_secret
local s3_endpoint
local s3_region
local s3_bucket
@ -43,6 +47,12 @@ render_application() {
local s3_secret_key
pg_conn="$(sed -n 's/^NUTHATCH_PG_CONN=//p' "$profile_file")"
redis_url="$(sed -n 's/^NUTHATCH_REDIS_URL=//p' "$profile_file")"
oidc_issuer="$(sed -n 's/^NUTHATCH_OIDC_ISSUER=//p' "$profile_file")"
oidc_client_id="$(sed -n 's/^NUTHATCH_OIDC_CLIENT_ID=//p' "$profile_file")"
oidc_additional_audiences="$(
sed -n 's/^NUTHATCH_OIDC_ADDITIONAL_AUDIENCES=//p' "$profile_file"
)"
oidc_client_secret="$(<"$profile_file.oidc-secret")"
s3_endpoint="$(sed -n 's/^NUTHATCH_S3_ENDPOINT=//p' "$profile_file")"
s3_region="$(sed -n 's/^NUTHATCH_S3_REGION=//p' "$profile_file")"
s3_bucket="$(sed -n 's/^NUTHATCH_S3_BUCKET=//p' "$profile_file")"
@ -53,6 +63,10 @@ render_application() {
-e "s|@@APP_IMAGE@@|$APP_IMAGE|g" \
-e "s|@@PG_CONN@@|$pg_conn|g" \
-e "s|@@REDIS_URL@@|$redis_url|g" \
-e "s|@@OIDC_ISSUER@@|$oidc_issuer|g" \
-e "s|@@OIDC_CLIENT_ID@@|$oidc_client_id|g" \
-e "s|@@OIDC_ADDITIONAL_AUDIENCES@@|$oidc_additional_audiences|g" \
-e "s|@@OIDC_CLIENT_SECRET@@|$oidc_client_secret|g" \
-e "s|@@S3_ENDPOINT@@|$s3_endpoint|g" \
-e "s|@@S3_REGION@@|$s3_region|g" \
-e "s|@@S3_BUCKET@@|$s3_bucket|g" \
@ -74,7 +88,7 @@ cleanup_application() {
acquire_fixture_lock
OWNS_FIXTURE=true
setup_fixture durable coordination target
setup_fixture durable coordination oidc target
source "$RUNTIME/fixture.env"
"$NUTHATCH_REAL_NERDCTL" --namespace "$NERDCTL_NAMESPACE" build \
--file "$ROOT/Dockerfile" \
@ -104,12 +118,13 @@ while IFS=$'\t' read -r lane coordination oidc target; do
"$CURL_LOCAL_IMAGE" \
"$profile"
sed -i "s|@@LANE@@|$lane|g" "$RUNTIME/$job.yaml"
printf 'Running lane %s: coordination=%s target=%s\n' "$lane" "$coordination" "$target"
printf 'Running lane %s: coordination=%s oidc=%s target=%s\n' \
"$lane" "$coordination" "$oidc" "$target"
run_job_manifest "$job" "$RUNTIME/$job.yaml"
cleanup_application "$lane"
cleanup_lane_state "$lane" "$coordination" "$oidc" "$target"
cleanup_lane_resources "$job" "$profile"
done < <(expand_fixture_matrix coordination target)
done < <(expand_fixture_matrix coordination oidc target)
SUCCEEDED=true
printf 'Nuthatch WP-05 app gate passed\n'
printf 'Nuthatch OIDC app gate passed\n'