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 \ libpq5 \
ca-certificates \ ca-certificates \
curl \ curl \
openssl \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 nuthatch \ && groupadd --system --gid 10001 nuthatch \
&& useradd --system --uid 10001 --gid 10001 --no-create-home 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: test:
cabal test --offline nuthatch-unit cabal test --offline nuthatch-unit
@ -15,6 +15,9 @@ check-kind-matrix:
check-storage-foundation: check-storage-foundation:
./tests/kind/wp04-run.sh ./tests/kind/wp04-run.sh
check-auth:
./tests/kind/wp05-run.sh
check-app: check-app:
./tests/kind/wp05-run.sh ./tests/kind/wp05-run.sh

View file

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

View file

@ -5,13 +5,16 @@ metadata:
namespace: nuthatch-testbed namespace: nuthatch-testbed
data: data:
NUTHATCH_LISTEN: 0.0.0.0:8080 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_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_ENDPOINT: "@@S3_ENDPOINT@@"
NUTHATCH_S3_REGION: "@@S3_REGION@@" NUTHATCH_S3_REGION: "@@S3_REGION@@"
NUTHATCH_S3_BUCKET: "@@S3_BUCKET@@" NUTHATCH_S3_BUCKET: "@@S3_BUCKET@@"
NUTHATCH_S3_PATH_STYLE: "true" NUTHATCH_S3_PATH_STYLE: "true"
NUTHATCH_AUTH_MODE: dev
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
@ -20,7 +23,8 @@ metadata:
namespace: nuthatch-testbed namespace: nuthatch-testbed
type: Opaque type: Opaque
stringData: 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_PG_CONN: "@@PG_CONN@@"
NUTHATCH_S3_ACCESS_KEY: "@@S3_ACCESS_KEY@@" NUTHATCH_S3_ACCESS_KEY: "@@S3_ACCESS_KEY@@"
NUTHATCH_S3_SECRET_KEY: "@@S3_SECRET_KEY@@" NUTHATCH_S3_SECRET_KEY: "@@S3_SECRET_KEY@@"
@ -40,11 +44,47 @@ spec:
spec: spec:
automountServiceAccountToken: false automountServiceAccountToken: false
enableServiceLinks: 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: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 10001 runAsUser: 10001
runAsGroup: 10001 runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
seccompProfile: {type: RuntimeDefault} 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: containers:
- name: nuthatch - name: nuthatch
image: @@APP_IMAGE@@ image: @@APP_IMAGE@@
@ -57,18 +97,23 @@ spec:
- configMapRef: {name: nuthatch-@@LANE@@} - configMapRef: {name: nuthatch-@@LANE@@}
- secretRef: {name: nuthatch-@@LANE@@} - secretRef: {name: nuthatch-@@LANE@@}
ports: ports:
- {name: http, containerPort: 8080} - {name: https, containerPort: 8080}
livenessProbe: livenessProbe:
httpGet: {path: /healthz, port: http} httpGet: {path: /healthz, port: https, scheme: HTTPS}
periodSeconds: 2 periodSeconds: 2
failureThreshold: 15 failureThreshold: 15
readinessProbe: readinessProbe:
httpGet: {path: /readyz, port: http} httpGet: {path: /readyz, port: https, scheme: HTTPS}
periodSeconds: 2 periodSeconds: 2
failureThreshold: 30 failureThreshold: 30
resources: resources:
requests: {cpu: 100m, memory: 96Mi} requests: {cpu: 100m, memory: 96Mi}
limits: {cpu: "2", memory: 512Mi} limits: {cpu: "2", memory: 512Mi}
volumeMounts:
- {name: tls, mountPath: /tls, readOnly: true}
volumes:
- name: tls
emptyDir: {}
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
@ -78,4 +123,4 @@ metadata:
spec: spec:
selector: {app: nuthatch-@@LANE@@} selector: {app: nuthatch-@@LANE@@}
ports: 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 , case-insensitive >= 1.2 && < 1.3
, containers >= 0.6 && < 0.8 , containers >= 0.6 && < 0.8
, conduit >= 1.3 && < 1.4 , conduit >= 1.3 && < 1.4
, cookie >= 0.5.1 && < 0.6
, crypton >= 0.34 && < 1.1 , crypton >= 0.34 && < 1.1
, directory >= 1.3 && < 1.4 , directory >= 1.3 && < 1.4
, entropy >= 0.4 && < 0.5 , entropy >= 0.4 && < 0.5
@ -63,6 +64,8 @@ common shared
, http-api-data >= 0.7 && < 0.8 , http-api-data >= 0.7 && < 0.8
, http-media >= 0.8 && < 0.9 , http-media >= 0.8 && < 0.9
, http-types >= 0.12 && < 0.13 , http-types >= 0.12 && < 0.13
, jose >= 0.12 && < 0.13
, lens >= 5.2 && < 5.4
, memory >= 0.18 && < 0.19 , memory >= 0.18 && < 0.19
, mime-types >= 0.1 && < 0.2 , mime-types >= 0.1 && < 0.2
, mtl >= 2.3 && < 2.4 , mtl >= 2.3 && < 2.4
@ -85,6 +88,7 @@ common shared
, wai >= 3.2 && < 3.3 , wai >= 3.2 && < 3.3
, wai-extra >= 3.1 && < 3.2 , wai-extra >= 3.1 && < 3.2
, warp >= 3.3 && < 3.5 , warp >= 3.3 && < 3.5
, warp-tls >= 3.4 && < 3.5
, xml-conduit >= 1.9 && < 1.11 , xml-conduit >= 1.9 && < 1.11
library library
@ -97,6 +101,7 @@ library
Nuthatch.Audit Nuthatch.Audit
Nuthatch.Config Nuthatch.Config
Nuthatch.Db Nuthatch.Db
Nuthatch.Oidc
Nuthatch.Paths Nuthatch.Paths
Nuthatch.Protocol Nuthatch.Protocol
Nuthatch.Redis Nuthatch.Redis

View file

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

View file

@ -18,7 +18,6 @@ import qualified Data.Aeson as A
import qualified Data.ByteArray as BA import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16 import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import Data.Either (isRight) import Data.Either (isRight)
import Data.Maybe (fromMaybe, listToMaybe) import Data.Maybe (fromMaybe, listToMaybe)
@ -30,17 +29,18 @@ import Data.UUID.V4 (nextRandom)
import Database.PostgreSQL.Simple (Only (..)) import Database.PostgreSQL.Simple (Only (..))
import Network.HTTP.Types import Network.HTTP.Types
( hContentType ( hContentType
, hCookie
, hLocation , hLocation
, status400 , status400
, urlDecode , urlDecode
) )
import qualified Network.HTTP.Types as HTTP
import Network.Wai (Request (..), responseLBS) import Network.Wai (Request (..), responseLBS)
import Nuthatch.Api import Nuthatch.Api
import Nuthatch.Audit (audit) import Nuthatch.Audit (audit)
import Nuthatch.Config (Config (..)) import Nuthatch.Config (Config (..))
import Nuthatch.Db (Db, ensurePersonalPool, withTransaction) import Nuthatch.Db (Db, ensurePersonalPool, withTransaction)
import qualified Nuthatch.Db as Db import qualified Nuthatch.Db as Db
import Nuthatch.Oidc
import Nuthatch.Paths import Nuthatch.Paths
import Nuthatch.Protocol import Nuthatch.Protocol
import Nuthatch.Redis (Redis, cacheGet, cacheInvalidatePrefixes, cacheSet, throttleTake) import Nuthatch.Redis (Redis, cacheGet, cacheInvalidatePrefixes, cacheSet, throttleTake)
@ -57,13 +57,26 @@ import Nuthatch.S3
) )
import Nuthatch.Session import Nuthatch.Session
import Nuthatch.Types import Nuthatch.Types
import Nuthatch.Ui (browsePage, browserScript, loginPage) import Nuthatch.Ui (browsePage, browserScript)
import Servant import Servant
import Servant.Server.Experimental.Auth import Servant.Server.Experimental.Auth
( AuthHandler ( AuthHandler
, mkAuthHandler , mkAuthHandler
) )
import Servant.Types.SourceT (runSourceT, source) import Servant.Types.SourceT (runSourceT, source)
import Web.Cookie
( defaultSetCookie
, parseCookies
, renderSetCookieBS
, sameSiteLax
, setCookieHttpOnly
, setCookieMaxAge
, setCookieName
, setCookiePath
, setCookieSameSite
, setCookieSecure
, setCookieValue
)
data Env = Env data Env = Env
{ envConfig :: Config { envConfig :: Config
@ -105,9 +118,9 @@ server :: Env -> Server Api
server env = server env =
pure (HealthResponse True) pure (HealthResponse True)
:<|> handleReady env :<|> handleReady env
:<|> pure (HtmlPage (loginPage ""))
:<|> pure (BrowserScript browserScript) :<|> pure (BrowserScript browserScript)
:<|> handleLoginRequest env :<|> handleLoginRequest env
:<|> handleOidcCallback env
:<|> handleLogoutRequest env :<|> handleLogoutRequest env
:<|> handleBrowseRequest env :<|> handleBrowseRequest env
:<|> handleDownloadRequest env :<|> handleDownloadRequest env
@ -117,7 +130,7 @@ server env =
authenticationHandler :: Config -> AuthHandler Request SessionContext authenticationHandler :: Config -> AuthHandler Request SessionContext
authenticationHandler cfg = mkAuthHandler $ \request -> do authenticationHandler cfg = mkAuthHandler $ \request -> do
let cookies = parseCookies (requestHeaders request) let cookies = requestCookies request
sessionName = TE.encodeUtf8 (cfgCookieName cfg) sessionName = TE.encodeUtf8 (cfgCookieName cfg)
csrfName = TE.encodeUtf8 (cfgCsrfCookieName cfg) csrfName = TE.encodeUtf8 (cfgCsrfCookieName cfg)
token <- maybe (throwError loginRequired) pure (lookup sessionName cookies) token <- maybe (throwError loginRequired) pure (lookup sessionName cookies)
@ -155,36 +168,68 @@ handleReady env = do
handleLoginRequest handleLoginRequest
:: Env :: 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 -> Handler LoginSuccess
handleLoginRequest env request = do handleOidcCallback env code state cookieHeader = do
let username = unDevUsername (loginUsername request) let cfg = envConfig env
let uid = encodeUserId "nuthatch-dev" username cookies = parseCookies (TE.encodeUtf8 cookieHeader)
cfg = envConfig env 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) 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 token <- either
(throwOperationError err500 . SessionCreationFailed) (const (throwOperationError err500 (SessionCreationFailed "session creation failed")))
pure pure
tokenResult tokenResult
csrf <- liftIO newCsrfToken csrf <- liftIO newCsrfToken
liftIO $ audit "login" "ok" (Just (unUserId uid)) [] liftIO $ audit "login" "ok" (Just (unUserId uid)) []
pure (loginResponse cfg uid token csrf) pure (loginResponse cfg token csrf)
loginResponse loginResponse :: Config -> SessionToken -> CsrfToken -> LoginSuccess
:: Config loginResponse cfg token csrf =
-> UserId addHeader (cookieValue (cfgCookieName cfg) (unSessionToken token) "/" True) $
-> SessionToken addHeader (cookieValue (cfgCsrfCookieName cfg) (unCsrfToken csrf) "/" False) $
-> CsrfToken addHeader
-> LoginSuccess (expiredCookieValue
loginResponse cfg uid token csrf = (cfgOidcTransactionCookieName cfg)
addHeader (cookieValue cfg (cfgCookieName cfg) (unSessionToken token) True) $ "/oidc/callback") $
addHeader (cookieValue cfg (cfgCsrfCookieName cfg) (unCsrfToken csrf) False) $ addHeader "/" NoContent
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
})
handleLogoutRequest handleLogoutRequest
:: Env :: Env
@ -195,8 +240,13 @@ handleLogoutRequest env session providedCsrf = do
requireCsrf LogoutCsrfRejected session providedCsrf requireCsrf LogoutCsrfRejected session providedCsrf
let cfg = envConfig env let cfg = envConfig env
pure $ pure $
addHeader (expiredCookieValue (cfgCookieName cfg)) $ addHeader (expiredCookieValue (cfgCookieName cfg) "/") $
addHeader (expiredCookieValue (cfgCsrfCookieName cfg)) NoContent addHeader (expiredCookieValue (cfgCsrfCookieName cfg) "/") $
addHeader
(expiredCookieValue
(cfgOidcTransactionCookieName cfg)
"/oidc/callback")
NoContent
handleBrowseRequest handleBrowseRequest
:: Env :: Env
@ -396,15 +446,32 @@ browsePrefixText :: BrowsePrefix -> Text
browsePrefixText (BrowsePrefix Nothing) = "" browsePrefixText (BrowsePrefix Nothing) = ""
browsePrefixText (BrowsePrefix (Just path)) = unRelativePath path browsePrefixText (BrowsePrefix (Just path)) = unRelativePath path
cookieValue :: Config -> Text -> Text -> Bool -> Text cookieValue :: Text -> Text -> BS.ByteString -> Bool -> Text
cookieValue _cfg name value httpOnly = cookieValue name value path httpOnly =
name <> "=" <> value TE.decodeUtf8 $
<> "; Path=/; SameSite=Lax" renderSetCookieBS $
<> if httpOnly then "; HttpOnly" else "" defaultSetCookie
{ setCookieName = TE.encodeUtf8 name
, setCookieValue = TE.encodeUtf8 value
, setCookiePath = Just path
, setCookieHttpOnly = httpOnly
, setCookieSecure = True
, setCookieSameSite = Just sameSiteLax
}
expiredCookieValue :: Text -> Text expiredCookieValue :: Text -> BS.ByteString -> Text
expiredCookieValue name = expiredCookieValue name path =
name <> "=; Path=/; Max-Age=0; SameSite=Lax" 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 :: MutationResult -> Text
mutationAuditResult MutationSucceeded = "ok" mutationAuditResult MutationSucceeded = "ok"
@ -725,18 +792,16 @@ lastSeg t =
(x : _) -> x (x : _) -> x
[] -> t [] -> t
parseCookies :: [HTTP.Header] -> [(BS.ByteString, BS.ByteString)] requestCookies :: Request -> [(BS.ByteString, BS.ByteString)]
parseCookies headers = requestCookies request =
case lookup "Cookie" headers of maybe [] parseCookies (lookup hCookie (requestHeaders request))
Nothing -> []
Just raw ->
[ (trimBS k, trimBS (BS8.drop 1 v))
| part <- BS8.split ';' raw
, let (k, v) = BS8.break (== '=') part
, not (BS.null v)
]
trimBS :: BS.ByteString -> BS.ByteString parseCookieValueText
trimBS = BS8.dropWhile isSpace . BS8.dropWhileEnd isSpace :: A.ToJSON error
where => error
isSpace c = c == ' ' || c == '\t' -> 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 module Nuthatch.Config
( Config (..) ( Config (..)
, AuthMode (..)
, loadConfig , loadConfig
) where ) where
import Control.Monad (when) import Control.Monad (when)
import qualified Data.ByteString as BS
import Data.List (nub)
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.Environment (lookupEnv) import System.Environment (lookupEnv)
import Text.Read (readMaybe) import Text.Read (readMaybe)
data AuthMode = AuthDev
deriving stock (Eq, Show)
data Config = Config data Config = Config
{ cfgListenHost :: String { cfgListenHost :: String
, cfgListenPort :: Int , cfgListenPort :: Int
, cfgTlsCertPath :: FilePath
, cfgTlsKeyPath :: FilePath
, cfgPublicBaseUrl :: Text , cfgPublicBaseUrl :: Text
, cfgJwtSecret :: Text , cfgJwtSecret :: Text
, cfgSessionHours :: Integer , cfgSessionHours :: Integer
, cfgCookieName :: Text , cfgCookieName :: Text
, cfgCsrfCookieName :: Text , cfgCsrfCookieName :: Text
, cfgOidcTransactionCookieName :: Text
, cfgOidcIssuer :: Text
, cfgOidcClientId :: Text
, cfgOidcAdditionalAudiences :: [Text]
, cfgOidcClientSecret :: Text
, cfgOidcRedirectUri :: Text
, cfgOidcTransactionMinutes :: Integer
, cfgOidcMaxIdTokenSeconds :: Integer
, cfgPgConn :: String , cfgPgConn :: String
, cfgRedisHost :: String , cfgRedisHost :: String
, cfgRedisPort :: Int , cfgRedisPort :: Int
@ -40,22 +49,45 @@ data Config = Config
, cfgListCacheTtlSec :: Integer , cfgListCacheTtlSec :: Integer
, cfgSearchMaxKeys :: Int , cfgSearchMaxKeys :: Int
, cfgRecoveryGraceSec :: Integer , cfgRecoveryGraceSec :: Integer
, cfgAuthMode :: AuthMode
} }
deriving stock (Show)
loadConfig :: IO Config loadConfig :: IO Config
loadConfig = do loadConfig = do
listen <- fromMaybe "127.0.0.1:8080" <$> lookupEnv "NUTHATCH_LISTEN" listen <- fromMaybe "127.0.0.1:8080" <$> lookupEnv "NUTHATCH_LISTEN"
let (host, portText) = splitHostPort listen let (host, portText) = splitHostPort listen
port <- requireRead "NUTHATCH_LISTEN port" portText 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" jwtSecret <- T.pack <$> requireEnv "NUTHATCH_JWT_SECRET"
when (T.length jwtSecret < 16) $ when (BS.length (TE.encodeUtf8 jwtSecret) < 32) $
fail "NUTHATCH_JWT_SECRET must be at least 16 characters" fail "NUTHATCH_JWT_SECRET must be at least 32 bytes"
sessionHours <- envInteger "NUTHATCH_SESSION_HOURS" 12 sessionHours <- envInteger "NUTHATCH_SESSION_HOURS" 12
cookieName <- T.pack . fromMaybe "nuthatch_session" <$> lookupEnv "NUTHATCH_COOKIE_NAME" cookieName <- T.pack . fromMaybe "nuthatch_session" <$> lookupEnv "NUTHATCH_COOKIE_NAME"
csrfCookie <- T.pack . fromMaybe "nuthatch_csrf" <$> lookupEnv "NUTHATCH_CSRF_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" pgConn <- requireEnv "NUTHATCH_PG_CONN"
redisUrl <- requireEnv "NUTHATCH_REDIS_URL" redisUrl <- requireEnv "NUTHATCH_REDIS_URL"
(redisHost, redisPort, redisDb) <- parseRedisUrl redisUrl (redisHost, redisPort, redisDb) <- parseRedisUrl redisUrl
@ -73,18 +105,24 @@ loadConfig = do
listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30 listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30
searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500 searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500
recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90 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 pure Config
{ cfgListenHost = host { cfgListenHost = host
, cfgListenPort = port , cfgListenPort = port
, cfgTlsCertPath = tlsCertPath
, cfgTlsKeyPath = tlsKeyPath
, cfgPublicBaseUrl = publicBase , cfgPublicBaseUrl = publicBase
, cfgJwtSecret = jwtSecret , cfgJwtSecret = jwtSecret
, cfgSessionHours = sessionHours , cfgSessionHours = sessionHours
, cfgCookieName = cookieName , cfgCookieName = cookieName
, cfgCsrfCookieName = csrfCookie , cfgCsrfCookieName = csrfCookie
, cfgOidcTransactionCookieName = transactionCookie
, cfgOidcIssuer = oidcIssuer
, cfgOidcClientId = oidcClientId
, cfgOidcAdditionalAudiences = oidcAdditionalAudiences
, cfgOidcClientSecret = oidcClientSecret
, cfgOidcRedirectUri = oidcRedirectUri
, cfgOidcTransactionMinutes = oidcTransactionMinutes
, cfgOidcMaxIdTokenSeconds = oidcMaxIdTokenSeconds
, cfgPgConn = pgConn , cfgPgConn = pgConn
, cfgRedisHost = redisHost , cfgRedisHost = redisHost
, cfgRedisPort = redisPort , cfgRedisPort = redisPort
@ -103,7 +141,6 @@ loadConfig = do
, cfgListCacheTtlSec = listTtl , cfgListCacheTtlSec = listTtl
, cfgSearchMaxKeys = searchMax , cfgSearchMaxKeys = searchMax
, cfgRecoveryGraceSec = recoveryGrace , cfgRecoveryGraceSec = recoveryGrace
, cfgAuthMode = authMode
} }
splitHostPort :: String -> (String, String) 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 , constantTimeEq
) where ) where
import Control.Lens
( (&)
, (?~)
, (^.)
, (.~)
)
import Crypto.Hash (Digest, SHA256, hash) 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 as A
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KM import qualified Data.Aeson.KeyMap as KM
import Data.ByteArray (convert)
import qualified Data.ByteArray as BA import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64 import qualified Data.ByteString.Base64.URL as B64
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import Data.Set (singleton)
import Data.String (fromString)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE 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.Config (Config (..))
import Nuthatch.Paths (encodeUserId) import Nuthatch.Paths (encodeUserId)
import Nuthatch.Types import Nuthatch.Types
import System.Entropy (getEntropy) 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 :: Config -> Text -> Text -> Maybe Text -> IO (Either Text SessionToken)
mintSession cfg iss sub display = do mintSession cfg issuer subject display = do
now <- floor <$> getPOSIXTime now <- getCurrentTime
let expAt = now + cfgSessionHours cfg * 3600 let expires = addUTCTime (fromInteger (cfgSessionHours cfg * 3600)) now
uid = encodeUserId iss sub userId = encodeUserId issuer subject
header = A.object applicationIssuer = "nuthatch"
[ "alg" A..= ("HS256" :: Text) applicationAudience = "nuthatch"
, "typ" A..= ("JWT" :: Text) userSubject = fromString (T.unpack (unUserId userId))
] result <- runJOSE $ do
payload = A.object $ let claims = SessionClaims
[ "iss" A..= ("nuthatch" :: Text) { registeredClaims =
, "aud" A..= ("nuthatch" :: Text) emptyClaimsSet
, "iat" A..= now & claimIss ?~ applicationIssuer
, "exp" A..= expAt & claimSub ?~ userSubject
, "sub" A..= unUserId uid & claimAud ?~ Audience [applicationAudience]
, "oidc_issuer" A..= iss & claimIat ?~ NumericDate now
, "oidc_subject" A..= sub & claimExp ?~ NumericDate expires
] <> maybe [] (\d -> ["display" A..= d]) display , oidcIssuer = issuer
token = signJwt (TE.encodeUtf8 (cfgJwtSecret cfg)) header payload , oidcSubject = subject
pure (validateSessionToken token) , 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 :: Config -> SessionToken -> IO (Either Text SessionUser)
verifySession cfg (SessionToken token) = do verifySession cfg (SessionToken token) = do
now <- floor <$> getPOSIXTime let applicationIssuer = "nuthatch"
pure $ do applicationAudience = "nuthatch"
claims <- verifyJwt (TE.encodeUtf8 (cfgJwtSecret cfg)) token result <- runJOSE $ do
expAt <- claimInt claims "exp" jwt <- decodeCompact (BL.fromStrict (TE.encodeUtf8 token))
if expAt < now let settings =
then Left "session expired" defaultJWTValidationSettings (== applicationAudience)
else do & issuerPredicate .~ (== applicationIssuer)
iss <- claimText claims "oidc_issuer" & algorithms .~ singleton HS256
sub <- claimText claims "oidc_subject" key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
let display = either (const Nothing) Just (claimText claims "display") verifyJWT settings key (jwt :: SignedJWT)
uid = encodeUserId iss sub pure $ case result of
pure SessionUser Left (_ :: JWTError) -> Left "session token validation failed"
{ suIssuer = iss Right claims ->
, suSubject = sub let userId = encodeUserId (oidcIssuer claims) (oidcSubject claims)
, suUserId = uid expectedSubject = fromString (T.unpack (unUserId userId))
, suDisplay = display 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 :: IO CsrfToken
newCsrfToken = do newCsrfToken = do
bytes <- getEntropy 32 bytes <- getEntropy 32
@ -127,9 +153,9 @@ newCsrfToken = do
constantTimeEq :: CsrfToken -> CsrfToken -> Bool constantTimeEq :: CsrfToken -> CsrfToken -> Bool
constantTimeEq (CsrfToken a) (CsrfToken b) = constantTimeEq (CsrfToken a) (CsrfToken b) =
let ba = TE.encodeUtf8 a let left = TE.encodeUtf8 a
bb = TE.encodeUtf8 b right = TE.encodeUtf8 b
in BS.length ba == BS.length bb in BS.length left == BS.length right
&& BA.constEq && BA.constEq
(hash ba :: Digest SHA256) (hash left :: Digest SHA256)
(hash bb :: Digest SHA256) (hash right :: Digest SHA256)

View file

@ -1,6 +1,5 @@
module Nuthatch.Ui module Nuthatch.Ui
( loginPage ( browserScript
, browserScript
, browsePage , browsePage
, htmlResponse , htmlResponse
, escapeHtml , escapeHtml
@ -30,19 +29,6 @@ escapeHtml =
'\'' -> "&#39;" '\'' -> "&#39;"
c -> T.singleton c 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 browsePage
:: SessionUser :: SessionUser
-> Root -> Root

View file

@ -1,14 +1,37 @@
module Main (main) where module Main (main) where
import Control.Monad.IO.Class (liftIO) 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 as A
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL 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.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.String (fromString)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
import Hedgehog import Hedgehog
( Gen ( Gen
, Property , Property
@ -24,6 +47,7 @@ import Network.HTTP.Types
, methodGet , methodGet
, methodPost , methodPost
, status200 , status200
, status303
, status400 , status400
, status404 , status404
, status405 , status405
@ -43,7 +67,8 @@ import Nuthatch.App
, loginResponse , loginResponse
, utf8Boundary , utf8Boundary
) )
import Nuthatch.Config (AuthMode (..), Config (..)) import Nuthatch.Config (Config (..))
import Nuthatch.Oidc (OidcIdentity (..), verifyIdToken)
import Nuthatch.Paths import Nuthatch.Paths
( buildObjectKey ( buildObjectKey
, encodeUserId , encodeUserId
@ -62,7 +87,7 @@ import Nuthatch.Protocol
) )
import Nuthatch.Types import Nuthatch.Types
import Nuthatch.Session (mintSession) import Nuthatch.Session (mintSession)
import Nuthatch.Ui (browsePage, browserScript, loginPage) import Nuthatch.Ui (browsePage, browserScript)
import Servant import Servant
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler) import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
import Servant.Types.SourceT (source) import Servant.Types.SourceT (source)
@ -78,6 +103,7 @@ tests = testGroup "nuthatch-unit"
[ pathTests [ pathTests
, identityTests , identityTests
, protocolTests , protocolTests
, oidcTests
, apiTests , apiTests
] ]
@ -127,6 +153,222 @@ protocolTests = testGroup "mutation protocol decisions"
, testProperty "only stale active records are recovery eligible" propRecoveryTiming , 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 :: TestTree
apiTests = testGroup "typed HTTP boundary" apiTests = testGroup "typed HTTP boundary"
[ testCase "Servant selects routes and methods" testRouteSelection [ 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" , testCase "operation-specific JSON requests and responses remain distinct"
testOperationRepresentations testOperationRepresentations
, testCase "operation-specific errors have closed distinct representations" $ do , testCase "operation-specific errors have closed distinct representations" $ do
let loginError = A.encode InvalidUsername let loginError = A.encode OidcCallbackRejected
deleteError = A.encode (DeleteMutationRejected "locked") deleteError = A.encode (DeleteMutationRejected "locked")
prefixError = A.encode (DeletePrefixMutationRejected "partial") prefixError = A.encode (DeletePrefixMutationRejected "partial")
assertBool "login errors carry the login-specific code" 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" assertBool "delete errors carry the mutation-specific code"
("mutation_rejected" `BS.isInfixOf` BL.toStrict deleteError) ("mutation_rejected" `BS.isInfixOf` BL.toStrict deleteError)
assertBool "operation errors must not share one representation" assertBool "operation errors must not share one representation"
(loginError /= deleteError && deleteError /= prefixError) (loginError /= deleteError && deleteError /= prefixError)
assertBool "prefix errors carry their operation-specific code" assertBool "prefix errors carry their operation-specific code"
("prefix_mutation_rejected" `BS.isInfixOf` BL.toStrict prefixError) ("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 testAuthenticationBoundary
, testCase "retained browser forms are wired to the typed API bridge" , testCase "retained browser forms are wired to the typed API bridge"
testBrowserBridge testBrowserBridge
@ -254,27 +496,16 @@ testStreamingEndpoints = do
testOperationRepresentations :: IO () testOperationRepresentations :: IO ()
testOperationRepresentations = do testOperationRepresentations = do
calls <- newIORef 0 calls <- newIORef 0
login <- apiRequest calls methodPost login <- apiRequest calls methodGet "/login" [] ""
"/login" simpleStatus login @?= status303
[ ("Content-Type", "application/json") lookup "Location" (simpleHeaders login) @?= Just "https://issuer.example/authorize"
, ("Accept", "application/json") callback <- apiRequest calls methodGet
] "/oidc/callback?code=code&state=state"
"{\"username\":\"alice\"}" [("Cookie", "nuthatch_oidc=transaction")]
simpleStatus login @?= status200 ""
assertBool "login response must carry a user identity" simpleStatus callback @?= status303
("user_id" `BS.isInfixOf` BL.toStrict (simpleBody login)) legacyLogin <- apiRequest calls methodPost "/login" [] ""
formLogin <- apiRequest calls methodPost simpleStatus legacyLogin @?= status405
"/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
deletion <- apiRequest calls methodPost deletion <- apiRequest calls methodPost
"/delete" "/delete"
[("Content-Type", "application/json")] [("Content-Type", "application/json")]
@ -350,18 +581,19 @@ typedTestServer :: IORef Int -> Server Api
typedTestServer calls = typedTestServer calls =
counted (HealthResponse True) counted (HealthResponse True)
:<|> counted (ReadyResponse True) :<|> counted (ReadyResponse True)
:<|> counted (HtmlPage "login")
:<|> counted (BrowserScript "script") :<|> counted (BrowserScript "script")
:<|> (\_ -> counted :<|> counted
(addHeader "transaction=cookie"
(addHeader "https://issuer.example/authorize" NoContent))
:<|> (\_ _ _ -> counted
(addHeader "session=cookie" (addHeader "session=cookie"
(addHeader "csrf=cookie" (addHeader "csrf=cookie"
(addHeader (CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") (addHeader "transaction=expired"
(LoginView (addHeader "/" NoContent)))))
(HtmlPage "continue")
(LoginResponse (UserId "user")))))))
:<|> (\_ _ -> counted :<|> (\_ _ -> counted
(addHeader "session=expired" (addHeader "session=expired"
(addHeader "csrf=expired" NoContent))) (addHeader "csrf=expired"
(addHeader "transaction=expired" NoContent))))
:<|> (\_ _ _ _ _ -> counted :<|> (\_ _ _ _ _ -> counted
(BrowseView (BrowseView
(HtmlPage "browse") (HtmlPage "browse")
@ -392,17 +624,28 @@ testAuthenticationBoundary = do
token <- either (fail . T.unpack) pure tokenResult token <- either (fail . T.unpack) pure tokenResult
let csrf = CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" let csrf = CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
responseHeaders = getHeaders responseHeaders = getHeaders
(loginResponse testConfig (UserId "user") token csrf) (loginResponse testConfig token csrf)
cookieHeaders = cookieHeaders =
[ value [ value
| (name, value) <- responseHeaders | (name, value) <- responseHeaders
, name == "Set-Cookie" , name == "Set-Cookie"
] ]
length cookieHeaders @?= 2 length cookieHeaders @?= 3
assertBool "login sets the session cookie" assertBool "login sets the session cookie"
(any ("nuthatch_session=" `BS.isPrefixOf`) cookieHeaders) (any ("nuthatch_session=" `BS.isPrefixOf`) cookieHeaders)
assertBool "login sets the CSRF cookie" assertBool "login sets the CSRF cookie"
(any ("nuthatch_csrf=" `BS.isPrefixOf`) cookieHeaders) (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 calls <- newIORef 0
let app = productionAuthTestApplication testConfig calls let app = productionAuthTestApplication testConfig calls
@ -425,6 +668,26 @@ testAuthenticationBoundary = do
simpleStatus malformed @?= status400 simpleStatus malformed @?= status400
readIORef calls >>= (@?= 0) 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 missingCsrf <- apiRequestWith app methodGet
"/?root=personal" "/?root=personal"
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token))] [("Cookie", "nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token))]
@ -457,8 +720,6 @@ testBrowserBridge = do
[entry] [entry]
Nothing Nothing
"" ""
assertBool "login page loads the external bridge"
("/nuthatch.js" `T.isInfixOf` loginPage "")
mapM_ mapM_
(\marker -> assertBool ("browse page includes " <> T.unpack marker) (\marker -> assertBool ("browse page includes " <> T.unpack marker)
(marker `T.isInfixOf` browse)) (marker `T.isInfixOf` browse))
@ -486,11 +747,21 @@ testConfig :: Config
testConfig = Config testConfig = Config
{ cfgListenHost = "127.0.0.1" { cfgListenHost = "127.0.0.1"
, cfgListenPort = 8080 , cfgListenPort = 8080
, cfgTlsCertPath = "/tls/tls.crt"
, cfgTlsKeyPath = "/tls/tls.key"
, cfgPublicBaseUrl = "https://nuthatch.example" , cfgPublicBaseUrl = "https://nuthatch.example"
, cfgJwtSecret = "unit-test-secret-material" , cfgJwtSecret = "unit-test-session-secret-material-32"
, cfgSessionHours = 1 , cfgSessionHours = 1
, cfgCookieName = "nuthatch_session" , cfgCookieName = "nuthatch_session"
, cfgCsrfCookieName = "nuthatch_csrf" , 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 = "" , cfgPgConn = ""
, cfgRedisHost = "" , cfgRedisHost = ""
, cfgRedisPort = 6379 , cfgRedisPort = 6379
@ -509,7 +780,6 @@ testConfig = Config
, cfgListCacheTtlSec = 30 , cfgListCacheTtlSec = 30
, cfgSearchMaxKeys = 100 , cfgSearchMaxKeys = 100
, cfgRecoveryGraceSec = 30 , cfgRecoveryGraceSec = 30
, cfgAuthMode = AuthDev
} }
newtype ValidPath = ValidPath { getValidPath :: Text } newtype ValidPath = ValidPath { getValidPath :: Text }

View file

@ -207,6 +207,9 @@ spec:
spec: spec:
automountServiceAccountToken: false automountServiceAccountToken: false
enableServiceLinks: false enableServiceLinks: false
volumes:
- name: bootstrap
emptyDir: {}
containers: containers:
- name: zitadel - name: zitadel
image: @@ZITADEL_IMAGE@@ image: @@ZITADEL_IMAGE@@
@ -220,6 +223,21 @@ spec:
- {name: ZITADEL_EXTERNALPORT, value: "8080"} - {name: ZITADEL_EXTERNALPORT, value: "8080"}
- {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME, value: nuthatch-admin} - {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME, value: nuthatch-admin}
- {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD, value: Synthetic-Zitadel-1!} - {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: ports:
- {name: http, containerPort: 8080} - {name: http, containerPort: 8080}
readinessProbe: readinessProbe:
@ -233,6 +251,25 @@ spec:
resources: resources:
requests: {cpu: 200m, memory: 512Mi} requests: {cpu: 200m, memory: 512Mi}
limits: {cpu: "2", memory: 1Gi} 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 apiVersion: v1
kind: Service kind: Service

View file

@ -197,6 +197,7 @@ render_manifests() {
-e "s|@@GARAGE_IMAGE@@|$garage|g" \ -e "s|@@GARAGE_IMAGE@@|$garage|g" \
-e "s|@@VERSITYGW_IMAGE@@|$versitygw|g" \ -e "s|@@VERSITYGW_IMAGE@@|$versitygw|g" \
-e "s|@@COREDNS_IMAGE@@|$coredns|g" \ -e "s|@@COREDNS_IMAGE@@|$coredns|g" \
-e "s|@@CURL_IMAGE@@|$curl|g" \
-e "s|@@AWS_CLI_IMAGE@@|$aws_cli|g" \ -e "s|@@AWS_CLI_IMAGE@@|$aws_cli|g" \
-e "s|@@POSTGRES_REPLICAS@@|$postgres_replicas|g" \ -e "s|@@POSTGRES_REPLICAS@@|$postgres_replicas|g" \
-e "s|@@REDIS_REPLICAS@@|$redis_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" 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() { write_lane_profile() {
local lane="$1" local lane="$1"
local coordination="$2" local coordination="$2"
@ -538,6 +686,12 @@ write_lane_profile() {
local database= local database=
local coordination_database= local coordination_database=
local oidc_issuer= 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_url=
local target_bucket= local target_bucket=
local target_region= local target_region=
@ -550,11 +704,36 @@ write_lane_profile() {
coordination_database="$(reset_lane_coordination_store "$coordination" "$lane")" || return coordination_database="$(reset_lane_coordination_store "$coordination" "$lane")" || return
fi fi
if fixture_has_family oidc; then if fixture_has_family oidc; then
oidc_redirect_uri="https://nuthatch-$lane:8080/oidc/callback"
case "$oidc" in case "$oidc" in
keycloak) oidc_issuer=http://keycloak.nuthatch.test:8080/realms/master ;; keycloak) oidc_issuer=http://keycloak.nuthatch.test:8080/realms/master ;;
zitadel) oidc_issuer=http://zitadel.nuthatch.test:8080 ;; zitadel) oidc_issuer=http://zitadel.nuthatch.test:8080 ;;
*) return 1 ;; *) return 1 ;;
esac 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 fi
if fixture_has_family target; then if fixture_has_family target; then
target_bucket="nuthatch-lane-$lane" 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_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_REDIS_URL=redis://%s:6379/%s\n' "$coordination" "$coordination_database"
printf 'NUTHATCH_OIDC_ISSUER=%s\n' "$oidc_issuer" 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_ENDPOINT=%s\n' "$target_url"
printf 'NUTHATCH_S3_BUCKET=%s\n' "$target_bucket" printf 'NUTHATCH_S3_BUCKET=%s\n' "$target_bucket"
printf 'NUTHATCH_S3_REGION=%s\n' "$target_region" 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_SECRET_KEY=%s\n' "$target_secret_key"
printf 'NUTHATCH_S3_PATH_STYLE=true\n' printf 'NUTHATCH_S3_PATH_STYLE=true\n'
} > "$profile_file" || return } > "$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" \ kube create configmap "$profile" \
--namespace "$FIXTURE_NAMESPACE" \ --namespace "$FIXTURE_NAMESPACE" \
--from-env-file="$profile_file" \ --from-env-file="$profile_file" \
@ -682,8 +871,12 @@ cleanup_lane_resources() {
"job/$job" \ "job/$job" \
"configmap/$profile" \ "configmap/$profile" \
"configmap/$profile-file" \ "configmap/$profile-file" \
"secret/nuthatch-oidc-automation-${profile##*-}" \
--ignore-not-found >/dev/null || return --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() { run_job_matrix() {
@ -722,6 +915,7 @@ destroy_fixture() {
"$RUNTIME/probe.yaml" \ "$RUNTIME/probe.yaml" \
"$RUNTIME/fixture.env" \ "$RUNTIME/fixture.env" \
"$RUNTIME"/nuthatch-fixture-lane-*.env \ "$RUNTIME"/nuthatch-fixture-lane-*.env \
"$RUNTIME"/nuthatch-fixture-lane-*.env.oidc-secret \
"$RUNTIME"/wp05-app-*.yaml || status=1 "$RUNTIME"/wp05-app-*.yaml || status=1
return "$status" return "$status"
} }

View file

@ -21,8 +21,14 @@ spec:
envFrom: envFrom:
- configMapRef: {name: @@PROFILE_NAME@@} - configMapRef: {name: @@PROFILE_NAME@@}
env: 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_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: resources:
requests: {cpu: 100m, memory: 32Mi} requests: {cpu: 100m, memory: 32Mi}
limits: {cpu: "2", memory: 96Mi} limits: {cpu: "2", memory: 96Mi}

View file

@ -11,6 +11,15 @@ spec:
spec: spec:
automountServiceAccountToken: false automountServiceAccountToken: false
enableServiceLinks: 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 restartPolicy: Never
initContainers: initContainers:
- name: credential-negative - name: credential-negative
@ -57,18 +66,301 @@ spec:
command: [/bin/sh, -ec] command: [/bin/sh, -ec]
args: args:
- | - |
base="http://nuthatch-@@LANE@@:8080" base="https://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' curl() {
curl --fail --silent --show-error \ command curl --insecure "$@"
--cookie-jar /tmp/cookies \ }
--dump-header /tmp/login-headers \
--header 'Accept: application/json' \ header_value() {
--header 'Content-Type: application/json' \ awk -v expected="$2" '
--data '{"username":"wp05-smoke"}' \ BEGIN { IGNORECASE=1 }
"$base/login" >/tmp/login.json $1 == expected ":" {
csrf="$(awk 'BEGIN{IGNORECASE=1} $1=="X-CSRF-Token:"{gsub("\r","",$2); print $2}' /tmp/login-headers)" 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/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 "{\"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" 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() { exercise_root() {
root="$1" root="$1"
@ -78,22 +370,28 @@ spec:
printf '%s' "$first" >/tmp/body printf '%s' "$first" >/tmp/body
size="$(wc -c </tmp/body | tr -d ' ')" size="$(wc -c </tmp/body | tr -d ' ')"
digest="$(sha256sum /tmp/body | cut -d' ' -f1)" digest="$(sha256sum /tmp/body | cut -d' ' -f1)"
curl --fail --silent --show-error \ upload_status="$(
--cookie /tmp/cookies \ curl --silent --show-error \
--output /tmp/upload.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \ --header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $size" \ --header "X-Declared-Size: $size" \
--header "X-Content-SHA256: $digest" \ --header "X-Content-SHA256: $digest" \
--header "X-CSRF-Token: $csrf" \ --header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/body \ --data-binary @/tmp/body \
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=00000000-0000-4000-8000-0000000000${suffix}" | "$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=00000000-0000-4000-8000-0000000000${suffix}"
grep -F '"result":"succeeded"' )"
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 \ curl --fail --silent --show-error \
--cookie /tmp/cookies \ --cookie "$cookies" \
--header 'Accept: application/json' \ --header 'Accept: application/json' \
"$base/?root=$root" | "$base/?root=$root" |
grep -F "smoke-$suffix.txt" grep -F "smoke-$suffix.txt"
curl --fail --silent --show-error \ curl --fail --silent --show-error \
--cookie /tmp/cookies \ --cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download "$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
test "$(cat /tmp/download)" = "$first" test "$(cat /tmp/download)" = "$first"
@ -101,7 +399,7 @@ spec:
size="$(wc -c </tmp/body | tr -d ' ')" size="$(wc -c </tmp/body | tr -d ' ')"
digest="$(sha256sum /tmp/body | cut -d' ' -f1)" digest="$(sha256sum /tmp/body | cut -d' ' -f1)"
curl --fail --silent --show-error \ curl --fail --silent --show-error \
--cookie /tmp/cookies \ --cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \ --header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $size" \ --header "X-Declared-Size: $size" \
--header "X-Content-SHA256: $digest" \ --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}" | "$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=10000000-0000-4000-8000-0000000000${suffix}" |
grep -F '"result":"succeeded"' grep -F '"result":"succeeded"'
curl --fail --silent --show-error \ curl --fail --silent --show-error \
--cookie /tmp/cookies \ --cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download "$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
test "$(cat /tmp/download)" = "$second" test "$(cat /tmp/download)" = "$second"
curl --fail --silent --show-error \ curl --fail --silent --show-error \
--cookie /tmp/cookies \ --cookie "$cookies" \
--header 'Content-Type: application/json' \ --header 'Content-Type: application/json' \
--data "{\"root\":\"$root\",\"path\":\"smoke-$suffix.txt\",\"operation_id\":\"20000000-0000-4000-8000-0000000000${suffix}\",\"csrf\":\"$csrf\"}" \ --data "{\"root\":\"$root\",\"path\":\"smoke-$suffix.txt\",\"operation_id\":\"20000000-0000-4000-8000-0000000000${suffix}\",\"csrf\":\"$csrf\"}" \
"$base/delete" | "$base/delete" |
grep -F '"result":"succeeded"' grep -F '"result":"succeeded"'
if curl --fail --silent \ if curl --fail --silent \
--cookie /tmp/cookies \ --cookie "$cookies" \
"$base/download?root=$root&path=smoke-$suffix.txt" >/dev/null; then "$base/download?root=$root&path=smoke-$suffix.txt" >/dev/null; then
exit 1 exit 1
fi fi
@ -129,7 +427,152 @@ spec:
exercise_root personal 01 exercise_root personal 01
exercise_root shared 02 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: resources:
requests: {cpu: 50m, memory: 32Mi} requests: {cpu: 50m, memory: 32Mi}
limits: {cpu: "1", memory: 128Mi} limits: {cpu: "1", memory: 128Mi}

View file

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