Implement confidential OIDC authentication boundary
This commit is contained in:
parent
0f888986d7
commit
a4e615096c
17 changed files with 1896 additions and 329 deletions
|
|
@ -31,6 +31,7 @@ RUN apt-get update \
|
|||
libpq5 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 10001 nuthatch \
|
||||
&& useradd --system --uid 10001 --gid 10001 --no-create-home nuthatch
|
||||
|
|
|
|||
5
Makefile
5
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-app app-up app-down check-frontend build-frontend
|
||||
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app app-up app-down check-frontend build-frontend
|
||||
|
||||
test:
|
||||
cabal test --offline nuthatch-unit
|
||||
|
|
@ -15,6 +15,9 @@ check-kind-matrix:
|
|||
check-storage-foundation:
|
||||
./tests/kind/wp04-run.sh
|
||||
|
||||
check-auth:
|
||||
./tests/kind/wp05-run.sh
|
||||
|
||||
check-app:
|
||||
./tests/kind/wp05-run.sh
|
||||
|
||||
|
|
|
|||
10
app/Main.hs
10
app/Main.hs
|
|
@ -7,10 +7,10 @@ import Database.PostgreSQL.Simple (Only (..))
|
|||
import Network.Wai.Handler.Warp
|
||||
( HostPreference
|
||||
, defaultSettings
|
||||
, runSettings
|
||||
, setHost
|
||||
, setPort
|
||||
)
|
||||
import Network.Wai.Handler.WarpTLS (runTLS, tlsSettings)
|
||||
import Nuthatch
|
||||
import Nuthatch.Db (migrate, query_, withDb)
|
||||
import Nuthatch.Redis (withRedis)
|
||||
|
|
@ -39,10 +39,10 @@ main = do
|
|||
<> cfgListenHost cfg
|
||||
<> ":"
|
||||
<> show (cfgListenPort cfg)
|
||||
<> " (auth="
|
||||
<> show (cfgAuthMode cfg)
|
||||
<> ")"
|
||||
runSettings settings (application env)
|
||||
runTLS
|
||||
(tlsSettings (cfgTlsCertPath cfg) (cfgTlsKeyPath cfg))
|
||||
settings
|
||||
(application env)
|
||||
|
||||
waitForPostgres :: Config -> IO ()
|
||||
waitForPostgres cfg = go (60 :: Int)
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ metadata:
|
|||
namespace: nuthatch-testbed
|
||||
data:
|
||||
NUTHATCH_LISTEN: 0.0.0.0:8080
|
||||
NUTHATCH_PUBLIC_BASE_URL: http://nuthatch-@@LANE@@:8080
|
||||
NUTHATCH_PUBLIC_BASE_URL: https://nuthatch-@@LANE@@:8080
|
||||
NUTHATCH_REDIS_URL: "@@REDIS_URL@@"
|
||||
NUTHATCH_OIDC_ISSUER: "@@OIDC_ISSUER@@"
|
||||
NUTHATCH_OIDC_CLIENT_ID: "@@OIDC_CLIENT_ID@@"
|
||||
NUTHATCH_OIDC_ADDITIONAL_AUDIENCES: "@@OIDC_ADDITIONAL_AUDIENCES@@"
|
||||
NUTHATCH_OIDC_REDIRECT_URI: https://nuthatch-@@LANE@@:8080/oidc/callback
|
||||
NUTHATCH_S3_ENDPOINT: "@@S3_ENDPOINT@@"
|
||||
NUTHATCH_S3_REGION: "@@S3_REGION@@"
|
||||
NUTHATCH_S3_BUCKET: "@@S3_BUCKET@@"
|
||||
NUTHATCH_S3_PATH_STYLE: "true"
|
||||
NUTHATCH_AUTH_MODE: dev
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
|
|
@ -20,7 +23,8 @@ metadata:
|
|||
namespace: nuthatch-testbed
|
||||
type: Opaque
|
||||
stringData:
|
||||
NUTHATCH_JWT_SECRET: synthetic-kind-jwt-secret-@@LANE@@
|
||||
NUTHATCH_JWT_SECRET: synthetic-kind-session-jwt-secret-@@LANE@@
|
||||
NUTHATCH_OIDC_CLIENT_SECRET: "@@OIDC_CLIENT_SECRET@@"
|
||||
NUTHATCH_PG_CONN: "@@PG_CONN@@"
|
||||
NUTHATCH_S3_ACCESS_KEY: "@@S3_ACCESS_KEY@@"
|
||||
NUTHATCH_S3_SECRET_KEY: "@@S3_SECRET_KEY@@"
|
||||
|
|
@ -40,11 +44,47 @@ spec:
|
|||
spec:
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers: [10.96.0.53]
|
||||
searches:
|
||||
- nuthatch-testbed.svc.cluster.local
|
||||
- svc.cluster.local
|
||||
- cluster.local
|
||||
options:
|
||||
- {name: ndots, value: "5"}
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
fsGroup: 10001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile: {type: RuntimeDefault}
|
||||
initContainers:
|
||||
- name: tls-certificate
|
||||
image: @@APP_IMAGE@@
|
||||
imagePullPolicy: Never
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
- |
|
||||
openssl req \
|
||||
-x509 \
|
||||
-newkey rsa:2048 \
|
||||
-nodes \
|
||||
-days 1 \
|
||||
-subj "/CN=nuthatch-@@LANE@@" \
|
||||
-addext "subjectAltName=DNS:nuthatch-@@LANE@@,DNS:nuthatch-@@LANE@@.nuthatch-testbed.svc" \
|
||||
-keyout /tls/tls.key \
|
||||
-out /tls/tls.crt
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities: {drop: ["ALL"]}
|
||||
volumeMounts:
|
||||
- {name: tls, mountPath: /tls}
|
||||
resources:
|
||||
requests: {cpu: 25m, memory: 16Mi}
|
||||
limits: {cpu: 250m, memory: 32Mi}
|
||||
containers:
|
||||
- name: nuthatch
|
||||
image: @@APP_IMAGE@@
|
||||
|
|
@ -57,18 +97,23 @@ spec:
|
|||
- configMapRef: {name: nuthatch-@@LANE@@}
|
||||
- secretRef: {name: nuthatch-@@LANE@@}
|
||||
ports:
|
||||
- {name: http, containerPort: 8080}
|
||||
- {name: https, containerPort: 8080}
|
||||
livenessProbe:
|
||||
httpGet: {path: /healthz, port: http}
|
||||
httpGet: {path: /healthz, port: https, scheme: HTTPS}
|
||||
periodSeconds: 2
|
||||
failureThreshold: 15
|
||||
readinessProbe:
|
||||
httpGet: {path: /readyz, port: http}
|
||||
httpGet: {path: /readyz, port: https, scheme: HTTPS}
|
||||
periodSeconds: 2
|
||||
failureThreshold: 30
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 96Mi}
|
||||
limits: {cpu: "2", memory: 512Mi}
|
||||
volumeMounts:
|
||||
- {name: tls, mountPath: /tls, readOnly: true}
|
||||
volumes:
|
||||
- name: tls
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
|
@ -78,4 +123,4 @@ metadata:
|
|||
spec:
|
||||
selector: {app: nuthatch-@@LANE@@}
|
||||
ports:
|
||||
- {name: http, port: 8080, targetPort: http}
|
||||
- {name: https, port: 8080, targetPort: https}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ common shared
|
|||
, case-insensitive >= 1.2 && < 1.3
|
||||
, containers >= 0.6 && < 0.8
|
||||
, conduit >= 1.3 && < 1.4
|
||||
, cookie >= 0.5.1 && < 0.6
|
||||
, crypton >= 0.34 && < 1.1
|
||||
, directory >= 1.3 && < 1.4
|
||||
, entropy >= 0.4 && < 0.5
|
||||
|
|
@ -63,6 +64,8 @@ common shared
|
|||
, http-api-data >= 0.7 && < 0.8
|
||||
, http-media >= 0.8 && < 0.9
|
||||
, http-types >= 0.12 && < 0.13
|
||||
, jose >= 0.12 && < 0.13
|
||||
, lens >= 5.2 && < 5.4
|
||||
, memory >= 0.18 && < 0.19
|
||||
, mime-types >= 0.1 && < 0.2
|
||||
, mtl >= 2.3 && < 2.4
|
||||
|
|
@ -85,6 +88,7 @@ common shared
|
|||
, wai >= 3.2 && < 3.3
|
||||
, wai-extra >= 3.1 && < 3.2
|
||||
, warp >= 3.3 && < 3.5
|
||||
, warp-tls >= 3.4 && < 3.5
|
||||
, xml-conduit >= 1.9 && < 1.11
|
||||
|
||||
library
|
||||
|
|
@ -97,6 +101,7 @@ library
|
|||
Nuthatch.Audit
|
||||
Nuthatch.Config
|
||||
Nuthatch.Db
|
||||
Nuthatch.Oidc
|
||||
Nuthatch.Paths
|
||||
Nuthatch.Protocol
|
||||
Nuthatch.Redis
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ module Nuthatch.Api
|
|||
( Api
|
||||
, HealthEndpoint
|
||||
, ReadyEndpoint
|
||||
, LoginPageEndpoint
|
||||
, BrowserScriptEndpoint
|
||||
, LoginEndpoint
|
||||
, LoginStart
|
||||
, OidcCallbackEndpoint
|
||||
, LoginSuccess
|
||||
, LogoutEndpoint
|
||||
, LogoutSuccess
|
||||
|
|
@ -25,10 +26,6 @@ module Nuthatch.Api
|
|||
, PaginationToken (..)
|
||||
, SearchQuery (..)
|
||||
, DeclaredSize (..)
|
||||
, DevUsername (..)
|
||||
, LoginRequest (..)
|
||||
, LoginResponse (..)
|
||||
, LoginView (..)
|
||||
, HealthResponse (..)
|
||||
, ReadyResponse (..)
|
||||
, BrowseResponse (..)
|
||||
|
|
@ -73,7 +70,6 @@ import Nuthatch.Types
|
|||
import Servant.API
|
||||
import Servant.Server (HasServer (..))
|
||||
import Servant.Server.Experimental.Auth (AuthServerData)
|
||||
import Web.FormUrlEncoded (FromForm (..), lookupUnique)
|
||||
|
||||
data HTML
|
||||
data JavaScript
|
||||
|
|
@ -121,44 +117,6 @@ newtype DeclaredSize = DeclaredSize { unDeclaredSize :: Integer }
|
|||
deriving stock (Eq, Ord, Show)
|
||||
deriving newtype (ToJSON, FromJSON)
|
||||
|
||||
newtype DevUsername = DevUsername { unDevUsername :: Text }
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
data LoginRequest = LoginRequest
|
||||
{ loginUsername :: DevUsername
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
instance FromJSON LoginRequest where
|
||||
parseJSON = withObject "LoginRequest" $ \o -> do
|
||||
raw <- o .: "username"
|
||||
LoginRequest <$> either (fail . T.unpack) pure (validateDevUsername raw)
|
||||
|
||||
instance FromForm LoginRequest where
|
||||
fromForm form = do
|
||||
raw <- lookupUnique "username" form
|
||||
LoginRequest <$> validateDevUsername raw
|
||||
|
||||
data LoginResponse = LoginResponse
|
||||
{ loginUserId :: UserId
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
instance ToJSON LoginResponse where
|
||||
toJSON response = object ["user_id" .= loginUserId response]
|
||||
|
||||
data LoginView = LoginView
|
||||
{ loginViewHtml :: HtmlPage
|
||||
, loginViewJson :: LoginResponse
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
instance MimeRender HTML LoginView where
|
||||
mimeRender proxy = mimeRender proxy . loginViewHtml
|
||||
|
||||
instance ToJSON LoginView where
|
||||
toJSON = toJSON . loginViewJson
|
||||
|
||||
newtype HealthResponse = HealthResponse { healthOk :: Bool }
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
|
|
@ -302,13 +260,16 @@ instance ToJSON ReadyError where
|
|||
errorValue "dependency_unavailable" "PostgreSQL is unavailable" Nothing
|
||||
|
||||
data LoginError
|
||||
= InvalidUsername
|
||||
= OidcAuthorizationFailed
|
||||
| OidcCallbackRejected
|
||||
| SessionCreationFailed Text
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
instance ToJSON LoginError where
|
||||
toJSON InvalidUsername =
|
||||
errorValue "invalid_username" "username must be one token" Nothing
|
||||
toJSON OidcAuthorizationFailed =
|
||||
errorValue "oidc_authorization_failed" "OIDC authorization could not start" Nothing
|
||||
toJSON OidcCallbackRejected =
|
||||
errorValue "oidc_callback_rejected" "OIDC callback validation failed" Nothing
|
||||
toJSON (SessionCreationFailed message) =
|
||||
errorValue "session_error" message Nothing
|
||||
|
||||
|
|
@ -407,29 +368,43 @@ type HealthEndpoint =
|
|||
type ReadyEndpoint =
|
||||
"readyz" :> Throws ReadyError :> Get '[JSON] ReadyResponse
|
||||
|
||||
type LoginPageEndpoint =
|
||||
"login" :> Get '[HTML] HtmlPage
|
||||
|
||||
type BrowserScriptEndpoint =
|
||||
"nuthatch.js" :> Get '[JavaScript] BrowserScript
|
||||
|
||||
type LoginStart =
|
||||
Headers
|
||||
'[ Header "Set-Cookie" Text
|
||||
, Header "Location" Text
|
||||
]
|
||||
NoContent
|
||||
|
||||
type LoginEndpoint =
|
||||
"login"
|
||||
:> Throws LoginError
|
||||
:> Verb 'GET 303 '[JSON] LoginStart
|
||||
|
||||
type LoginSuccess =
|
||||
Headers
|
||||
'[ Header "Set-Cookie" Text
|
||||
, Header "Set-Cookie" Text
|
||||
, Header "X-CSRF-Token" CsrfToken
|
||||
, Header "Set-Cookie" Text
|
||||
, Header "Location" Text
|
||||
]
|
||||
LoginView
|
||||
NoContent
|
||||
|
||||
type LoginEndpoint =
|
||||
"login"
|
||||
type OidcCallbackEndpoint =
|
||||
"oidc"
|
||||
:> "callback"
|
||||
:> Throws LoginError
|
||||
:> ReqBody '[JSON, FormUrlEncoded] LoginRequest
|
||||
:> Post '[HTML, JSON] LoginSuccess
|
||||
:> QueryParam' '[Required, Strict] "code" Text
|
||||
:> QueryParam' '[Required, Strict] "state" Text
|
||||
:> Header' '[Required, Strict] "Cookie" Text
|
||||
:> Verb 'GET 303 '[JSON] LoginSuccess
|
||||
|
||||
type LogoutSuccess =
|
||||
Headers
|
||||
'[ Header "Set-Cookie" Text
|
||||
, Header "Set-Cookie" Text
|
||||
, Header "Set-Cookie" Text
|
||||
]
|
||||
NoContent
|
||||
|
|
@ -501,9 +476,9 @@ type DeletePrefixEndpoint =
|
|||
type Api =
|
||||
HealthEndpoint
|
||||
:<|> ReadyEndpoint
|
||||
:<|> LoginPageEndpoint
|
||||
:<|> BrowserScriptEndpoint
|
||||
:<|> LoginEndpoint
|
||||
:<|> OidcCallbackEndpoint
|
||||
:<|> LogoutEndpoint
|
||||
:<|> BrowseEndpoint
|
||||
:<|> DownloadEndpoint
|
||||
|
|
@ -554,16 +529,6 @@ validatePaginationToken raw
|
|||
| T.any isControl raw = Left "pagination token contains a control character"
|
||||
| otherwise = Right (PaginationToken raw)
|
||||
|
||||
validateDevUsername :: Text -> Either Text DevUsername
|
||||
validateDevUsername raw
|
||||
| T.null raw = Left "username must not be empty"
|
||||
| T.length raw > 128 = Left "username is too long"
|
||||
| T.any invalidCharacter raw = Left "username must be one printable token"
|
||||
| otherwise = Right (DevUsername raw)
|
||||
where
|
||||
invalidCharacter character =
|
||||
isControl character || character `elem` (" \t\r\n" :: String)
|
||||
|
||||
mutationResultText :: MutationResult -> Text
|
||||
mutationResultText MutationPending = "pending"
|
||||
mutationResultText MutationSucceeded = "succeeded"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import qualified Data.Aeson as A
|
|||
import qualified Data.ByteArray as BA
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Base16 as B16
|
||||
import qualified Data.ByteString.Char8 as BS8
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Either (isRight)
|
||||
import Data.Maybe (fromMaybe, listToMaybe)
|
||||
|
|
@ -30,17 +29,18 @@ import Data.UUID.V4 (nextRandom)
|
|||
import Database.PostgreSQL.Simple (Only (..))
|
||||
import Network.HTTP.Types
|
||||
( hContentType
|
||||
, hCookie
|
||||
, hLocation
|
||||
, status400
|
||||
, urlDecode
|
||||
)
|
||||
import qualified Network.HTTP.Types as HTTP
|
||||
import Network.Wai (Request (..), responseLBS)
|
||||
import Nuthatch.Api
|
||||
import Nuthatch.Audit (audit)
|
||||
import Nuthatch.Config (Config (..))
|
||||
import Nuthatch.Db (Db, ensurePersonalPool, withTransaction)
|
||||
import qualified Nuthatch.Db as Db
|
||||
import Nuthatch.Oidc
|
||||
import Nuthatch.Paths
|
||||
import Nuthatch.Protocol
|
||||
import Nuthatch.Redis (Redis, cacheGet, cacheInvalidatePrefixes, cacheSet, throttleTake)
|
||||
|
|
@ -57,13 +57,26 @@ import Nuthatch.S3
|
|||
)
|
||||
import Nuthatch.Session
|
||||
import Nuthatch.Types
|
||||
import Nuthatch.Ui (browsePage, browserScript, loginPage)
|
||||
import Nuthatch.Ui (browsePage, browserScript)
|
||||
import Servant
|
||||
import Servant.Server.Experimental.Auth
|
||||
( AuthHandler
|
||||
, mkAuthHandler
|
||||
)
|
||||
import Servant.Types.SourceT (runSourceT, source)
|
||||
import Web.Cookie
|
||||
( defaultSetCookie
|
||||
, parseCookies
|
||||
, renderSetCookieBS
|
||||
, sameSiteLax
|
||||
, setCookieHttpOnly
|
||||
, setCookieMaxAge
|
||||
, setCookieName
|
||||
, setCookiePath
|
||||
, setCookieSameSite
|
||||
, setCookieSecure
|
||||
, setCookieValue
|
||||
)
|
||||
|
||||
data Env = Env
|
||||
{ envConfig :: Config
|
||||
|
|
@ -105,9 +118,9 @@ server :: Env -> Server Api
|
|||
server env =
|
||||
pure (HealthResponse True)
|
||||
:<|> handleReady env
|
||||
:<|> pure (HtmlPage (loginPage ""))
|
||||
:<|> pure (BrowserScript browserScript)
|
||||
:<|> handleLoginRequest env
|
||||
:<|> handleOidcCallback env
|
||||
:<|> handleLogoutRequest env
|
||||
:<|> handleBrowseRequest env
|
||||
:<|> handleDownloadRequest env
|
||||
|
|
@ -117,7 +130,7 @@ server env =
|
|||
|
||||
authenticationHandler :: Config -> AuthHandler Request SessionContext
|
||||
authenticationHandler cfg = mkAuthHandler $ \request -> do
|
||||
let cookies = parseCookies (requestHeaders request)
|
||||
let cookies = requestCookies request
|
||||
sessionName = TE.encodeUtf8 (cfgCookieName cfg)
|
||||
csrfName = TE.encodeUtf8 (cfgCsrfCookieName cfg)
|
||||
token <- maybe (throwError loginRequired) pure (lookup sessionName cookies)
|
||||
|
|
@ -155,36 +168,68 @@ handleReady env = do
|
|||
|
||||
handleLoginRequest
|
||||
:: Env
|
||||
-> LoginRequest
|
||||
-> Handler LoginStart
|
||||
handleLoginRequest env = do
|
||||
let cfg = envConfig env
|
||||
authorizationResult <- liftIO (beginAuthorization cfg)
|
||||
authorization <- either
|
||||
(const (throwOperationError err502 OidcAuthorizationFailed))
|
||||
pure
|
||||
authorizationResult
|
||||
pure $
|
||||
addHeader
|
||||
(cookieValue
|
||||
(cfgOidcTransactionCookieName cfg)
|
||||
(authorizationTransaction authorization)
|
||||
"/oidc/callback"
|
||||
True) $
|
||||
addHeader (authorizationLocation authorization) NoContent
|
||||
|
||||
handleOidcCallback
|
||||
:: Env
|
||||
-> Text
|
||||
-> Text
|
||||
-> Text
|
||||
-> Handler LoginSuccess
|
||||
handleLoginRequest env request = do
|
||||
let username = unDevUsername (loginUsername request)
|
||||
let uid = encodeUserId "nuthatch-dev" username
|
||||
cfg = envConfig env
|
||||
handleOidcCallback env code state cookieHeader = do
|
||||
let cfg = envConfig env
|
||||
cookies = parseCookies (TE.encodeUtf8 cookieHeader)
|
||||
transactionName = TE.encodeUtf8 (cfgOidcTransactionCookieName cfg)
|
||||
transaction <- maybe
|
||||
(throwOperationError err400 OidcCallbackRejected)
|
||||
(parseCookieValueText OidcCallbackRejected)
|
||||
(lookup transactionName cookies)
|
||||
identityResult <- liftIO (redeemAuthorizationCode cfg transaction state code)
|
||||
identity <- case identityResult of
|
||||
Left reason -> do
|
||||
liftIO $ audit "login" "rejected" Nothing [("reason", A.String reason)]
|
||||
throwOperationError err400 OidcCallbackRejected
|
||||
Right accepted -> pure accepted
|
||||
let uid = encodeUserId (identityIssuer identity) (identitySubject identity)
|
||||
liftIO (ensurePersonalPool (envDb env) cfg uid)
|
||||
tokenResult <- liftIO (mintSession cfg "nuthatch-dev" username (Just username))
|
||||
tokenResult <- liftIO $
|
||||
mintSession
|
||||
cfg
|
||||
(identityIssuer identity)
|
||||
(identitySubject identity)
|
||||
(identityDisplay identity)
|
||||
token <- either
|
||||
(throwOperationError err500 . SessionCreationFailed)
|
||||
(const (throwOperationError err500 (SessionCreationFailed "session creation failed")))
|
||||
pure
|
||||
tokenResult
|
||||
csrf <- liftIO newCsrfToken
|
||||
liftIO $ audit "login" "ok" (Just (unUserId uid)) []
|
||||
pure (loginResponse cfg uid token csrf)
|
||||
pure (loginResponse cfg token csrf)
|
||||
|
||||
loginResponse
|
||||
:: Config
|
||||
-> UserId
|
||||
-> SessionToken
|
||||
-> CsrfToken
|
||||
-> LoginSuccess
|
||||
loginResponse cfg uid token csrf =
|
||||
addHeader (cookieValue cfg (cfgCookieName cfg) (unSessionToken token) True) $
|
||||
addHeader (cookieValue cfg (cfgCsrfCookieName cfg) (unCsrfToken csrf) False) $
|
||||
addHeader csrf (LoginView
|
||||
{ loginViewHtml = HtmlPage
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"refresh\" content=\"0;url=/\"></head><body><a href=\"/\">Continue to Nuthatch</a></body></html>"
|
||||
, loginViewJson = LoginResponse uid
|
||||
})
|
||||
loginResponse :: Config -> SessionToken -> CsrfToken -> LoginSuccess
|
||||
loginResponse cfg token csrf =
|
||||
addHeader (cookieValue (cfgCookieName cfg) (unSessionToken token) "/" True) $
|
||||
addHeader (cookieValue (cfgCsrfCookieName cfg) (unCsrfToken csrf) "/" False) $
|
||||
addHeader
|
||||
(expiredCookieValue
|
||||
(cfgOidcTransactionCookieName cfg)
|
||||
"/oidc/callback") $
|
||||
addHeader "/" NoContent
|
||||
|
||||
handleLogoutRequest
|
||||
:: Env
|
||||
|
|
@ -195,8 +240,13 @@ handleLogoutRequest env session providedCsrf = do
|
|||
requireCsrf LogoutCsrfRejected session providedCsrf
|
||||
let cfg = envConfig env
|
||||
pure $
|
||||
addHeader (expiredCookieValue (cfgCookieName cfg)) $
|
||||
addHeader (expiredCookieValue (cfgCsrfCookieName cfg)) NoContent
|
||||
addHeader (expiredCookieValue (cfgCookieName cfg) "/") $
|
||||
addHeader (expiredCookieValue (cfgCsrfCookieName cfg) "/") $
|
||||
addHeader
|
||||
(expiredCookieValue
|
||||
(cfgOidcTransactionCookieName cfg)
|
||||
"/oidc/callback")
|
||||
NoContent
|
||||
|
||||
handleBrowseRequest
|
||||
:: Env
|
||||
|
|
@ -396,15 +446,32 @@ browsePrefixText :: BrowsePrefix -> Text
|
|||
browsePrefixText (BrowsePrefix Nothing) = ""
|
||||
browsePrefixText (BrowsePrefix (Just path)) = unRelativePath path
|
||||
|
||||
cookieValue :: Config -> Text -> Text -> Bool -> Text
|
||||
cookieValue _cfg name value httpOnly =
|
||||
name <> "=" <> value
|
||||
<> "; Path=/; SameSite=Lax"
|
||||
<> if httpOnly then "; HttpOnly" else ""
|
||||
cookieValue :: Text -> Text -> BS.ByteString -> Bool -> Text
|
||||
cookieValue name value path httpOnly =
|
||||
TE.decodeUtf8 $
|
||||
renderSetCookieBS $
|
||||
defaultSetCookie
|
||||
{ setCookieName = TE.encodeUtf8 name
|
||||
, setCookieValue = TE.encodeUtf8 value
|
||||
, setCookiePath = Just path
|
||||
, setCookieHttpOnly = httpOnly
|
||||
, setCookieSecure = True
|
||||
, setCookieSameSite = Just sameSiteLax
|
||||
}
|
||||
|
||||
expiredCookieValue :: Text -> Text
|
||||
expiredCookieValue name =
|
||||
name <> "=; Path=/; Max-Age=0; SameSite=Lax"
|
||||
expiredCookieValue :: Text -> BS.ByteString -> Text
|
||||
expiredCookieValue name path =
|
||||
TE.decodeUtf8 $
|
||||
renderSetCookieBS $
|
||||
defaultSetCookie
|
||||
{ setCookieName = TE.encodeUtf8 name
|
||||
, setCookieValue = ""
|
||||
, setCookiePath = Just path
|
||||
, setCookieMaxAge = Just 0
|
||||
, setCookieHttpOnly = True
|
||||
, setCookieSecure = True
|
||||
, setCookieSameSite = Just sameSiteLax
|
||||
}
|
||||
|
||||
mutationAuditResult :: MutationResult -> Text
|
||||
mutationAuditResult MutationSucceeded = "ok"
|
||||
|
|
@ -725,18 +792,16 @@ lastSeg t =
|
|||
(x : _) -> x
|
||||
[] -> t
|
||||
|
||||
parseCookies :: [HTTP.Header] -> [(BS.ByteString, BS.ByteString)]
|
||||
parseCookies headers =
|
||||
case lookup "Cookie" headers of
|
||||
Nothing -> []
|
||||
Just raw ->
|
||||
[ (trimBS k, trimBS (BS8.drop 1 v))
|
||||
| part <- BS8.split ';' raw
|
||||
, let (k, v) = BS8.break (== '=') part
|
||||
, not (BS.null v)
|
||||
]
|
||||
requestCookies :: Request -> [(BS.ByteString, BS.ByteString)]
|
||||
requestCookies request =
|
||||
maybe [] parseCookies (lookup hCookie (requestHeaders request))
|
||||
|
||||
trimBS :: BS.ByteString -> BS.ByteString
|
||||
trimBS = BS8.dropWhile isSpace . BS8.dropWhileEnd isSpace
|
||||
where
|
||||
isSpace c = c == ' ' || c == '\t'
|
||||
parseCookieValueText
|
||||
:: A.ToJSON error
|
||||
=> error
|
||||
-> BS.ByteString
|
||||
-> Handler Text
|
||||
parseCookieValueText operationError bytes =
|
||||
case TE.decodeUtf8' bytes of
|
||||
Right value | not (T.null value) && T.length value <= 8192 -> pure value
|
||||
_ -> throwOperationError err400 operationError
|
||||
|
|
|
|||
|
|
@ -1,27 +1,36 @@
|
|||
module Nuthatch.Config
|
||||
( Config (..)
|
||||
, AuthMode (..)
|
||||
, loadConfig
|
||||
) where
|
||||
|
||||
import Control.Monad (when)
|
||||
import qualified Data.ByteString as BS
|
||||
import Data.List (nub)
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import System.Environment (lookupEnv)
|
||||
import Text.Read (readMaybe)
|
||||
|
||||
data AuthMode = AuthDev
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
data Config = Config
|
||||
{ cfgListenHost :: String
|
||||
, cfgListenPort :: Int
|
||||
, cfgTlsCertPath :: FilePath
|
||||
, cfgTlsKeyPath :: FilePath
|
||||
, cfgPublicBaseUrl :: Text
|
||||
, cfgJwtSecret :: Text
|
||||
, cfgSessionHours :: Integer
|
||||
, cfgCookieName :: Text
|
||||
, cfgCsrfCookieName :: Text
|
||||
, cfgOidcTransactionCookieName :: Text
|
||||
, cfgOidcIssuer :: Text
|
||||
, cfgOidcClientId :: Text
|
||||
, cfgOidcAdditionalAudiences :: [Text]
|
||||
, cfgOidcClientSecret :: Text
|
||||
, cfgOidcRedirectUri :: Text
|
||||
, cfgOidcTransactionMinutes :: Integer
|
||||
, cfgOidcMaxIdTokenSeconds :: Integer
|
||||
, cfgPgConn :: String
|
||||
, cfgRedisHost :: String
|
||||
, cfgRedisPort :: Int
|
||||
|
|
@ -40,22 +49,45 @@ data Config = Config
|
|||
, cfgListCacheTtlSec :: Integer
|
||||
, cfgSearchMaxKeys :: Int
|
||||
, cfgRecoveryGraceSec :: Integer
|
||||
, cfgAuthMode :: AuthMode
|
||||
}
|
||||
deriving stock (Show)
|
||||
|
||||
loadConfig :: IO Config
|
||||
loadConfig = do
|
||||
listen <- fromMaybe "127.0.0.1:8080" <$> lookupEnv "NUTHATCH_LISTEN"
|
||||
let (host, portText) = splitHostPort listen
|
||||
port <- requireRead "NUTHATCH_LISTEN port" portText
|
||||
publicBase <- T.pack . fromMaybe ("http://" <> listen) <$> lookupEnv "NUTHATCH_PUBLIC_BASE_URL"
|
||||
tlsCertPath <- fromMaybe "/tls/tls.crt" <$> lookupEnv "NUTHATCH_TLS_CERT_PATH"
|
||||
tlsKeyPath <- fromMaybe "/tls/tls.key" <$> lookupEnv "NUTHATCH_TLS_KEY_PATH"
|
||||
publicBase <- T.pack <$> requireEnv "NUTHATCH_PUBLIC_BASE_URL"
|
||||
when (not ("https://" `T.isPrefixOf` publicBase)) $
|
||||
fail "NUTHATCH_PUBLIC_BASE_URL must use https"
|
||||
jwtSecret <- T.pack <$> requireEnv "NUTHATCH_JWT_SECRET"
|
||||
when (T.length jwtSecret < 16) $
|
||||
fail "NUTHATCH_JWT_SECRET must be at least 16 characters"
|
||||
when (BS.length (TE.encodeUtf8 jwtSecret) < 32) $
|
||||
fail "NUTHATCH_JWT_SECRET must be at least 32 bytes"
|
||||
sessionHours <- envInteger "NUTHATCH_SESSION_HOURS" 12
|
||||
cookieName <- T.pack . fromMaybe "nuthatch_session" <$> lookupEnv "NUTHATCH_COOKIE_NAME"
|
||||
csrfCookie <- T.pack . fromMaybe "nuthatch_csrf" <$> lookupEnv "NUTHATCH_CSRF_COOKIE_NAME"
|
||||
transactionCookie <- T.pack . fromMaybe "nuthatch_oidc" <$> lookupEnv "NUTHATCH_OIDC_TRANSACTION_COOKIE_NAME"
|
||||
oidcIssuer <- T.dropWhileEnd (== '/') . T.pack <$> requireEnv "NUTHATCH_OIDC_ISSUER"
|
||||
oidcClientId <- T.pack <$> requireEnv "NUTHATCH_OIDC_CLIENT_ID"
|
||||
additionalAudienceValue <- lookupEnv "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES"
|
||||
let oidcAdditionalAudiences =
|
||||
case T.strip . T.pack <$> additionalAudienceValue of
|
||||
Nothing -> []
|
||||
Just "" -> []
|
||||
Just value -> map T.strip (T.splitOn "," value)
|
||||
when
|
||||
( any T.null oidcAdditionalAudiences
|
||||
|| oidcClientId `elem` oidcAdditionalAudiences
|
||||
|| length oidcAdditionalAudiences /= length (nub oidcAdditionalAudiences)
|
||||
) $
|
||||
fail "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES must contain unique non-client values"
|
||||
oidcClientSecret <- T.pack <$> requireEnv "NUTHATCH_OIDC_CLIENT_SECRET"
|
||||
oidcRedirectUri <- T.pack <$> requireEnv "NUTHATCH_OIDC_REDIRECT_URI"
|
||||
when (oidcRedirectUri /= publicBase <> "/oidc/callback") $
|
||||
fail "NUTHATCH_OIDC_REDIRECT_URI must equal the public callback URL"
|
||||
oidcTransactionMinutes <- envInteger "NUTHATCH_OIDC_TRANSACTION_MINUTES" 5
|
||||
oidcMaxIdTokenSeconds <- envInteger "NUTHATCH_OIDC_MAX_ID_TOKEN_SECONDS" 3600
|
||||
pgConn <- requireEnv "NUTHATCH_PG_CONN"
|
||||
redisUrl <- requireEnv "NUTHATCH_REDIS_URL"
|
||||
(redisHost, redisPort, redisDb) <- parseRedisUrl redisUrl
|
||||
|
|
@ -73,18 +105,24 @@ loadConfig = do
|
|||
listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30
|
||||
searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500
|
||||
recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90
|
||||
authModeText <- fromMaybe "dev" <$> lookupEnv "NUTHATCH_AUTH_MODE"
|
||||
authMode <- case authModeText of
|
||||
"dev" -> pure AuthDev
|
||||
other -> fail $ "unsupported NUTHATCH_AUTH_MODE: " <> other
|
||||
pure Config
|
||||
{ cfgListenHost = host
|
||||
, cfgListenPort = port
|
||||
, cfgTlsCertPath = tlsCertPath
|
||||
, cfgTlsKeyPath = tlsKeyPath
|
||||
, cfgPublicBaseUrl = publicBase
|
||||
, cfgJwtSecret = jwtSecret
|
||||
, cfgSessionHours = sessionHours
|
||||
, cfgCookieName = cookieName
|
||||
, cfgCsrfCookieName = csrfCookie
|
||||
, cfgOidcTransactionCookieName = transactionCookie
|
||||
, cfgOidcIssuer = oidcIssuer
|
||||
, cfgOidcClientId = oidcClientId
|
||||
, cfgOidcAdditionalAudiences = oidcAdditionalAudiences
|
||||
, cfgOidcClientSecret = oidcClientSecret
|
||||
, cfgOidcRedirectUri = oidcRedirectUri
|
||||
, cfgOidcTransactionMinutes = oidcTransactionMinutes
|
||||
, cfgOidcMaxIdTokenSeconds = oidcMaxIdTokenSeconds
|
||||
, cfgPgConn = pgConn
|
||||
, cfgRedisHost = redisHost
|
||||
, cfgRedisPort = redisPort
|
||||
|
|
@ -103,7 +141,6 @@ loadConfig = do
|
|||
, cfgListCacheTtlSec = listTtl
|
||||
, cfgSearchMaxKeys = searchMax
|
||||
, cfgRecoveryGraceSec = recoveryGrace
|
||||
, cfgAuthMode = authMode
|
||||
}
|
||||
|
||||
splitHostPort :: String -> (String, String)
|
||||
|
|
|
|||
469
src/Nuthatch/Oidc.hs
Normal file
469
src/Nuthatch/Oidc.hs
Normal 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)
|
||||
|
|
@ -5,121 +5,147 @@ module Nuthatch.Session
|
|||
, constantTimeEq
|
||||
) where
|
||||
|
||||
import Control.Lens
|
||||
( (&)
|
||||
, (?~)
|
||||
, (^.)
|
||||
, (.~)
|
||||
)
|
||||
import Crypto.Hash (Digest, SHA256, hash)
|
||||
import Crypto.MAC.HMAC (HMAC, hmac)
|
||||
import Crypto.JOSE (runJOSE)
|
||||
import Crypto.JOSE.Compact (decodeCompact, encodeCompact)
|
||||
import Crypto.JOSE.JWA.JWS (Alg (HS256))
|
||||
import Crypto.JOSE.JWK (fromOctets)
|
||||
import Crypto.JOSE.JWS
|
||||
( algorithms
|
||||
, newJWSHeaderProtected
|
||||
)
|
||||
import Crypto.JWT
|
||||
( Audience (..)
|
||||
, ClaimsSet
|
||||
, HasClaimsSet (..)
|
||||
, JWTError
|
||||
, NumericDate (..)
|
||||
, SignedJWT
|
||||
, defaultJWTValidationSettings
|
||||
, emptyClaimsSet
|
||||
, issuerPredicate
|
||||
, signJWT
|
||||
, verifyJWT
|
||||
)
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.Key as Key
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import Data.ByteArray (convert)
|
||||
import qualified Data.ByteArray as BA
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Base64.URL as B64
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Set (singleton)
|
||||
import Data.String (fromString)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||
import Data.Time.Clock (addUTCTime, getCurrentTime)
|
||||
import Nuthatch.Config (Config (..))
|
||||
import Nuthatch.Paths (encodeUserId)
|
||||
import Nuthatch.Types
|
||||
import System.Entropy (getEntropy)
|
||||
|
||||
data SessionClaims = SessionClaims
|
||||
{ registeredClaims :: ClaimsSet
|
||||
, oidcIssuer :: Text
|
||||
, oidcSubject :: Text
|
||||
, displayName :: Maybe Text
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
instance HasClaimsSet SessionClaims where
|
||||
claimsSet update claims =
|
||||
fmap
|
||||
(\registered -> claims { registeredClaims = registered })
|
||||
(update (registeredClaims claims))
|
||||
|
||||
instance A.FromJSON SessionClaims where
|
||||
parseJSON value = A.withObject "SessionClaims" parse value
|
||||
where
|
||||
parse object =
|
||||
SessionClaims
|
||||
<$> A.parseJSON value
|
||||
<*> object A..: "oidc_issuer"
|
||||
<*> object A..: "oidc_subject"
|
||||
<*> object A..:? "display"
|
||||
|
||||
instance A.ToJSON SessionClaims where
|
||||
toJSON claims =
|
||||
case A.toJSON (registeredClaims claims) of
|
||||
A.Object object ->
|
||||
A.Object $
|
||||
KM.insert "oidc_issuer" (A.String (oidcIssuer claims)) $
|
||||
KM.insert "oidc_subject" (A.String (oidcSubject claims)) $
|
||||
maybe
|
||||
object
|
||||
(\display -> KM.insert "display" (A.String display) object)
|
||||
(displayName claims)
|
||||
value -> value
|
||||
|
||||
mintSession :: Config -> Text -> Text -> Maybe Text -> IO (Either Text SessionToken)
|
||||
mintSession cfg iss sub display = do
|
||||
now <- floor <$> getPOSIXTime
|
||||
let expAt = now + cfgSessionHours cfg * 3600
|
||||
uid = encodeUserId iss sub
|
||||
header = A.object
|
||||
[ "alg" A..= ("HS256" :: Text)
|
||||
, "typ" A..= ("JWT" :: Text)
|
||||
]
|
||||
payload = A.object $
|
||||
[ "iss" A..= ("nuthatch" :: Text)
|
||||
, "aud" A..= ("nuthatch" :: Text)
|
||||
, "iat" A..= now
|
||||
, "exp" A..= expAt
|
||||
, "sub" A..= unUserId uid
|
||||
, "oidc_issuer" A..= iss
|
||||
, "oidc_subject" A..= sub
|
||||
] <> maybe [] (\d -> ["display" A..= d]) display
|
||||
token = signJwt (TE.encodeUtf8 (cfgJwtSecret cfg)) header payload
|
||||
pure (validateSessionToken token)
|
||||
mintSession cfg issuer subject display = do
|
||||
now <- getCurrentTime
|
||||
let expires = addUTCTime (fromInteger (cfgSessionHours cfg * 3600)) now
|
||||
userId = encodeUserId issuer subject
|
||||
applicationIssuer = "nuthatch"
|
||||
applicationAudience = "nuthatch"
|
||||
userSubject = fromString (T.unpack (unUserId userId))
|
||||
result <- runJOSE $ do
|
||||
let claims = SessionClaims
|
||||
{ registeredClaims =
|
||||
emptyClaimsSet
|
||||
& claimIss ?~ applicationIssuer
|
||||
& claimSub ?~ userSubject
|
||||
& claimAud ?~ Audience [applicationAudience]
|
||||
& claimIat ?~ NumericDate now
|
||||
& claimExp ?~ NumericDate expires
|
||||
, oidcIssuer = issuer
|
||||
, oidcSubject = subject
|
||||
, displayName = display
|
||||
}
|
||||
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
|
||||
header = newJWSHeaderProtected HS256
|
||||
encodeCompact <$> signJWT key header claims
|
||||
pure $ case result of
|
||||
Left (_ :: JWTError) -> Left "session token creation failed"
|
||||
Right compact ->
|
||||
validateSessionToken (TE.decodeUtf8 (BL.toStrict compact))
|
||||
|
||||
verifySession :: Config -> SessionToken -> IO (Either Text SessionUser)
|
||||
verifySession cfg (SessionToken token) = do
|
||||
now <- floor <$> getPOSIXTime
|
||||
pure $ do
|
||||
claims <- verifyJwt (TE.encodeUtf8 (cfgJwtSecret cfg)) token
|
||||
expAt <- claimInt claims "exp"
|
||||
if expAt < now
|
||||
then Left "session expired"
|
||||
else do
|
||||
iss <- claimText claims "oidc_issuer"
|
||||
sub <- claimText claims "oidc_subject"
|
||||
let display = either (const Nothing) Just (claimText claims "display")
|
||||
uid = encodeUserId iss sub
|
||||
pure SessionUser
|
||||
{ suIssuer = iss
|
||||
, suSubject = sub
|
||||
, suUserId = uid
|
||||
, suDisplay = display
|
||||
let applicationIssuer = "nuthatch"
|
||||
applicationAudience = "nuthatch"
|
||||
result <- runJOSE $ do
|
||||
jwt <- decodeCompact (BL.fromStrict (TE.encodeUtf8 token))
|
||||
let settings =
|
||||
defaultJWTValidationSettings (== applicationAudience)
|
||||
& issuerPredicate .~ (== applicationIssuer)
|
||||
& algorithms .~ singleton HS256
|
||||
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
|
||||
verifyJWT settings key (jwt :: SignedJWT)
|
||||
pure $ case result of
|
||||
Left (_ :: JWTError) -> Left "session token validation failed"
|
||||
Right claims ->
|
||||
let userId = encodeUserId (oidcIssuer claims) (oidcSubject claims)
|
||||
expectedSubject = fromString (T.unpack (unUserId userId))
|
||||
registered = registeredClaims claims
|
||||
in if registered ^. claimSub /= Just expectedSubject
|
||||
|| registered ^. claimAud /= Just (Audience ["nuthatch"])
|
||||
|| registered ^. claimIat == Nothing
|
||||
|| registered ^. claimExp == Nothing
|
||||
then Left "session token claims are invalid"
|
||||
else Right SessionUser
|
||||
{ suIssuer = oidcIssuer claims
|
||||
, suSubject = oidcSubject claims
|
||||
, suUserId = userId
|
||||
, suDisplay = displayName claims
|
||||
}
|
||||
|
||||
signJwt :: BS.ByteString -> A.Value -> A.Value -> Text
|
||||
signJwt secret header payload =
|
||||
let h = b64Json header
|
||||
p = b64Json payload
|
||||
signingInput = h <> "." <> p
|
||||
sig = b64Raw (hmacSha256 secret signingInput)
|
||||
in TE.decodeUtf8 (signingInput <> "." <> sig)
|
||||
|
||||
verifyJwt :: BS.ByteString -> Text -> Either Text A.Object
|
||||
verifyJwt secret token =
|
||||
case T.splitOn "." token of
|
||||
[h, p, s] -> do
|
||||
let signingInput = TE.encodeUtf8 (h <> "." <> p)
|
||||
expected = b64Raw (hmacSha256 secret signingInput)
|
||||
if not (BA.constEq expected (TE.encodeUtf8 s))
|
||||
then Left "invalid session signature"
|
||||
else do
|
||||
payloadBytes <- b64Decode (TE.encodeUtf8 p)
|
||||
case A.eitherDecodeStrict payloadBytes of
|
||||
Left err -> Left (T.pack err)
|
||||
Right (A.Object obj) -> Right obj
|
||||
Right _ -> Left "jwt payload must be an object"
|
||||
_ -> Left "malformed session token"
|
||||
|
||||
hmacSha256 :: BS.ByteString -> BS.ByteString -> BS.ByteString
|
||||
hmacSha256 secret msg =
|
||||
convert (hmac secret msg :: HMAC SHA256)
|
||||
|
||||
b64Json :: A.Value -> BS.ByteString
|
||||
b64Json = B64.encodeUnpadded . BL.toStrict . A.encode
|
||||
|
||||
b64Raw :: BS.ByteString -> BS.ByteString
|
||||
b64Raw = B64.encodeUnpadded
|
||||
|
||||
b64Decode :: BS.ByteString -> Either Text BS.ByteString
|
||||
b64Decode bs =
|
||||
case B64.decode bs of
|
||||
Left err -> Left (T.pack err)
|
||||
Right x -> Right x
|
||||
|
||||
claimText :: A.Object -> Text -> Either Text Text
|
||||
claimText obj key =
|
||||
case KM.lookup (Key.fromText key) obj of
|
||||
Just (A.String t) -> Right t
|
||||
_ -> Left ("missing claim " <> key)
|
||||
|
||||
claimInt :: A.Object -> Text -> Either Text Integer
|
||||
claimInt obj key =
|
||||
case KM.lookup (Key.fromText key) obj of
|
||||
Just v ->
|
||||
case A.fromJSON v of
|
||||
A.Success (i :: Integer) -> Right i
|
||||
_ -> Left ("invalid integer claim " <> key)
|
||||
_ -> Left ("missing claim " <> key)
|
||||
|
||||
newCsrfToken :: IO CsrfToken
|
||||
newCsrfToken = do
|
||||
bytes <- getEntropy 32
|
||||
|
|
@ -127,9 +153,9 @@ newCsrfToken = do
|
|||
|
||||
constantTimeEq :: CsrfToken -> CsrfToken -> Bool
|
||||
constantTimeEq (CsrfToken a) (CsrfToken b) =
|
||||
let ba = TE.encodeUtf8 a
|
||||
bb = TE.encodeUtf8 b
|
||||
in BS.length ba == BS.length bb
|
||||
let left = TE.encodeUtf8 a
|
||||
right = TE.encodeUtf8 b
|
||||
in BS.length left == BS.length right
|
||||
&& BA.constEq
|
||||
(hash ba :: Digest SHA256)
|
||||
(hash bb :: Digest SHA256)
|
||||
(hash left :: Digest SHA256)
|
||||
(hash right :: Digest SHA256)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
module Nuthatch.Ui
|
||||
( loginPage
|
||||
, browserScript
|
||||
( browserScript
|
||||
, browsePage
|
||||
, htmlResponse
|
||||
, escapeHtml
|
||||
|
|
@ -30,19 +29,6 @@ escapeHtml =
|
|||
'\'' -> "'"
|
||||
c -> T.singleton c
|
||||
|
||||
loginPage :: Text -> Text
|
||||
loginPage err =
|
||||
page "Nuthatch login" $
|
||||
T.unlines
|
||||
[ "<h1>Nuthatch</h1>"
|
||||
, "<p>Development login (OIDC is not wired in the MVP).</p>"
|
||||
, if T.null err then "" else "<p class=\"err\">" <> escapeHtml err <> "</p>"
|
||||
, "<form method=\"post\" action=\"/login\">"
|
||||
, " <label>Username <input name=\"username\" required autofocus></label>"
|
||||
, " <button type=\"submit\">Sign in</button>"
|
||||
, "</form>"
|
||||
]
|
||||
|
||||
browsePage
|
||||
:: SessionUser
|
||||
-> Root
|
||||
|
|
|
|||
350
test/Spec.hs
350
test/Spec.hs
|
|
@ -1,14 +1,37 @@
|
|||
module Main (main) where
|
||||
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Control.Lens ((&), (?~))
|
||||
import Crypto.JOSE (runJOSE)
|
||||
import Crypto.JOSE.Compact (encodeCompact)
|
||||
import Crypto.JOSE.JWA.JWS (Alg (RS256, RS384))
|
||||
import Crypto.JOSE.JWK
|
||||
( JWK
|
||||
, JWKSet (..)
|
||||
, KeyMaterialGenParam (RSAGenParam)
|
||||
, genJWK
|
||||
)
|
||||
import Crypto.JOSE.JWS (newJWSHeaderProtected)
|
||||
import Crypto.JWT
|
||||
( Audience (..)
|
||||
, ClaimsSet
|
||||
, HasClaimsSet (..)
|
||||
, JWTError
|
||||
, NumericDate (..)
|
||||
, emptyClaimsSet
|
||||
, signJWT
|
||||
)
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Either (isLeft)
|
||||
import Data.Either (isLeft, isRight)
|
||||
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
|
||||
import Data.String (fromString)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
|
||||
import Hedgehog
|
||||
( Gen
|
||||
, Property
|
||||
|
|
@ -24,6 +47,7 @@ import Network.HTTP.Types
|
|||
, methodGet
|
||||
, methodPost
|
||||
, status200
|
||||
, status303
|
||||
, status400
|
||||
, status404
|
||||
, status405
|
||||
|
|
@ -43,7 +67,8 @@ import Nuthatch.App
|
|||
, loginResponse
|
||||
, utf8Boundary
|
||||
)
|
||||
import Nuthatch.Config (AuthMode (..), Config (..))
|
||||
import Nuthatch.Config (Config (..))
|
||||
import Nuthatch.Oidc (OidcIdentity (..), verifyIdToken)
|
||||
import Nuthatch.Paths
|
||||
( buildObjectKey
|
||||
, encodeUserId
|
||||
|
|
@ -62,7 +87,7 @@ import Nuthatch.Protocol
|
|||
)
|
||||
import Nuthatch.Types
|
||||
import Nuthatch.Session (mintSession)
|
||||
import Nuthatch.Ui (browsePage, browserScript, loginPage)
|
||||
import Nuthatch.Ui (browsePage, browserScript)
|
||||
import Servant
|
||||
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
|
||||
import Servant.Types.SourceT (source)
|
||||
|
|
@ -78,6 +103,7 @@ tests = testGroup "nuthatch-unit"
|
|||
[ pathTests
|
||||
, identityTests
|
||||
, protocolTests
|
||||
, oidcTests
|
||||
, apiTests
|
||||
]
|
||||
|
||||
|
|
@ -127,6 +153,222 @@ protocolTests = testGroup "mutation protocol decisions"
|
|||
, testProperty "only stale active records are recovery eligible" propRecoveryTiming
|
||||
]
|
||||
|
||||
oidcTests :: TestTree
|
||||
oidcTests = testGroup "OIDC validation"
|
||||
[ testCase "accepts an exact RS256 ID token" testValidIdToken
|
||||
, testCase "accepts an exact configured audience set" testConfiguredAudiences
|
||||
, testCase "rejects invalid nonce, algorithm, issuer, signature, and audience"
|
||||
testIdTokenCryptographicRejections
|
||||
, testCase "rejects malformed, missing-time, future, expired, and overlong tokens"
|
||||
testIdTokenTimeAndSerializationRejections
|
||||
]
|
||||
|
||||
data TestIdTokenClaims = TestIdTokenClaims
|
||||
{ testIdTokenRegistered :: ClaimsSet
|
||||
, testIdTokenNonce :: Text
|
||||
, testIdTokenAuthorizedParty :: Maybe Text
|
||||
, testIdTokenName :: Maybe Text
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
instance HasClaimsSet TestIdTokenClaims where
|
||||
claimsSet update claims =
|
||||
fmap
|
||||
(\registered -> claims { testIdTokenRegistered = registered })
|
||||
(update (testIdTokenRegistered claims))
|
||||
|
||||
instance A.FromJSON TestIdTokenClaims where
|
||||
parseJSON value = A.withObject "test ID token" (\object ->
|
||||
TestIdTokenClaims
|
||||
<$> A.parseJSON value
|
||||
<*> object A..: "nonce"
|
||||
<*> object A..:? "azp"
|
||||
<*> object A..:? "name") value
|
||||
|
||||
instance A.ToJSON TestIdTokenClaims where
|
||||
toJSON claims =
|
||||
case A.toJSON (testIdTokenRegistered claims) of
|
||||
A.Object object ->
|
||||
let withName =
|
||||
maybe
|
||||
object
|
||||
(\name -> KM.insert "name" (A.String name) object)
|
||||
(testIdTokenName claims)
|
||||
withAuthorizedParty =
|
||||
maybe
|
||||
withName
|
||||
(\authorizedParty ->
|
||||
KM.insert "azp" (A.String authorizedParty) withName)
|
||||
(testIdTokenAuthorizedParty claims)
|
||||
in A.Object $
|
||||
KM.insert
|
||||
"nonce"
|
||||
(A.String (testIdTokenNonce claims))
|
||||
withAuthorizedParty
|
||||
value -> value
|
||||
|
||||
testValidIdToken :: IO ()
|
||||
testValidIdToken = do
|
||||
key <- genJWK (RSAGenParam 256)
|
||||
now <- getCurrentTime
|
||||
compact <- signTestIdToken key RS256
|
||||
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||||
result <- verifyIdToken
|
||||
testConfig
|
||||
(JWKSet [key])
|
||||
now
|
||||
"nonce"
|
||||
compact
|
||||
result @?= Right OidcIdentity
|
||||
{ identityIssuer = "https://issuer.example"
|
||||
, identitySubject = "upstream-subject"
|
||||
, identityDisplay = Just "Example User"
|
||||
}
|
||||
|
||||
testConfiguredAudiences :: IO ()
|
||||
testConfiguredAudiences = do
|
||||
key <- genJWK (RSAGenParam 256)
|
||||
now <- getCurrentTime
|
||||
compact <- signTestIdToken key RS256
|
||||
((testIdClaims now "https://issuer.example" ["project", "nuthatch"] "nonce")
|
||||
{ testIdTokenAuthorizedParty = Just "nuthatch" })
|
||||
missingAuthorizedParty <- signTestIdToken key RS256
|
||||
(testIdClaims now "https://issuer.example" ["project", "nuthatch"] "nonce")
|
||||
result <- verifyIdToken
|
||||
testConfig { cfgOidcAdditionalAudiences = ["project"] }
|
||||
(JWKSet [key])
|
||||
now
|
||||
"nonce"
|
||||
compact
|
||||
rejected <- verifyIdToken
|
||||
testConfig { cfgOidcAdditionalAudiences = ["project"] }
|
||||
(JWKSet [key])
|
||||
now
|
||||
"nonce"
|
||||
missingAuthorizedParty
|
||||
assertBool "the configured audience set is accepted" (isRight result)
|
||||
assertBool "a multi-audience token without azp is rejected" (isLeft rejected)
|
||||
|
||||
testIdTokenCryptographicRejections :: IO ()
|
||||
testIdTokenCryptographicRejections = do
|
||||
key <- genJWK (RSAGenParam 256)
|
||||
otherKey <- genJWK (RSAGenParam 256)
|
||||
now <- getCurrentTime
|
||||
valid <- signTestIdToken key RS256
|
||||
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||||
wrongAlgorithm <- signTestIdToken key RS384
|
||||
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||||
wrongIssuer <- signTestIdToken key RS256
|
||||
(testIdClaims now "https://other.example" ["nuthatch"] "nonce")
|
||||
wrongSignature <- signTestIdToken otherKey RS256
|
||||
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||||
wrongAudience <- signTestIdToken key RS256
|
||||
(testIdClaims now "https://issuer.example" ["other"] "nonce")
|
||||
multipleAudience <- signTestIdToken key RS256
|
||||
(testIdClaims now "https://issuer.example" ["nuthatch", "other"] "nonce")
|
||||
let rejected =
|
||||
[ verifyIdToken testConfig (JWKSet [key]) now "other-nonce" valid
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongAlgorithm
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongIssuer
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongSignature
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongAudience
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" multipleAudience
|
||||
]
|
||||
results <- sequence rejected
|
||||
assertBool "every invalid cryptographic or binding case is rejected"
|
||||
(all isLeft results)
|
||||
|
||||
testIdTokenTimeAndSerializationRejections :: IO ()
|
||||
testIdTokenTimeAndSerializationRejections = do
|
||||
key <- genJWK (RSAGenParam 256)
|
||||
now <- getCurrentTime
|
||||
missingIssuedAt <- signTestIdToken key RS256
|
||||
((testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
|
||||
{ testIdTokenRegistered =
|
||||
emptyClaimsSet
|
||||
& claimIss ?~ "https://issuer.example"
|
||||
& claimSub ?~ "upstream-subject"
|
||||
& claimAud ?~ Audience ["nuthatch"]
|
||||
& claimExp ?~ NumericDate (addUTCTime 300 now)
|
||||
})
|
||||
future <- signTestIdToken key RS256
|
||||
(testIdClaimsAt
|
||||
(addUTCTime 60 now)
|
||||
(addUTCTime 300 now)
|
||||
"https://issuer.example"
|
||||
["nuthatch"]
|
||||
"nonce")
|
||||
expired <- signTestIdToken key RS256
|
||||
(testIdClaimsAt
|
||||
(addUTCTime (-600) now)
|
||||
(addUTCTime (-300) now)
|
||||
"https://issuer.example"
|
||||
["nuthatch"]
|
||||
"nonce")
|
||||
overlong <- signTestIdToken key RS256
|
||||
(testIdClaimsAt
|
||||
now
|
||||
(addUTCTime 7200 now)
|
||||
"https://issuer.example"
|
||||
["nuthatch"]
|
||||
"nonce")
|
||||
results <- sequence
|
||||
[ verifyIdToken testConfig (JWKSet [key]) now "nonce" "not.compact"
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" missingIssuedAt
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" future
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" expired
|
||||
, verifyIdToken testConfig (JWKSet [key]) now "nonce" overlong
|
||||
]
|
||||
assertBool "every invalid serialization or time case is rejected"
|
||||
(all isLeft results)
|
||||
|
||||
testIdClaims
|
||||
:: UTCTime
|
||||
-> Text
|
||||
-> [Text]
|
||||
-> Text
|
||||
-> TestIdTokenClaims
|
||||
testIdClaims now issuer audiences nonce =
|
||||
testIdClaimsAt
|
||||
now
|
||||
(addUTCTime 300 now)
|
||||
issuer
|
||||
audiences
|
||||
nonce
|
||||
|
||||
testIdClaimsAt
|
||||
:: UTCTime
|
||||
-> UTCTime
|
||||
-> Text
|
||||
-> [Text]
|
||||
-> Text
|
||||
-> TestIdTokenClaims
|
||||
testIdClaimsAt issuedAt expiresAt issuer audiences nonce =
|
||||
TestIdTokenClaims
|
||||
{ testIdTokenRegistered =
|
||||
emptyClaimsSet
|
||||
& claimIss ?~ fromString (T.unpack issuer)
|
||||
& claimSub ?~ "upstream-subject"
|
||||
& claimAud ?~ Audience (map (fromString . T.unpack) audiences)
|
||||
& claimIat ?~ NumericDate issuedAt
|
||||
& claimExp ?~ NumericDate expiresAt
|
||||
, testIdTokenNonce = nonce
|
||||
, testIdTokenAuthorizedParty = Nothing
|
||||
, testIdTokenName = Just "Example User"
|
||||
}
|
||||
|
||||
signTestIdToken
|
||||
:: JWK
|
||||
-> Alg
|
||||
-> TestIdTokenClaims
|
||||
-> IO Text
|
||||
signTestIdToken key algorithm claims = do
|
||||
result <- runJOSE $
|
||||
encodeCompact <$> signJWT key (newJWSHeaderProtected algorithm) claims
|
||||
case result of
|
||||
Left (err :: JWTError) -> fail (show err)
|
||||
Right compact -> pure (TE.decodeUtf8 (BL.toStrict compact))
|
||||
|
||||
apiTests :: TestTree
|
||||
apiTests = testGroup "typed HTTP boundary"
|
||||
[ testCase "Servant selects routes and methods" testRouteSelection
|
||||
|
|
@ -140,18 +382,18 @@ apiTests = testGroup "typed HTTP boundary"
|
|||
, testCase "operation-specific JSON requests and responses remain distinct"
|
||||
testOperationRepresentations
|
||||
, testCase "operation-specific errors have closed distinct representations" $ do
|
||||
let loginError = A.encode InvalidUsername
|
||||
let loginError = A.encode OidcCallbackRejected
|
||||
deleteError = A.encode (DeleteMutationRejected "locked")
|
||||
prefixError = A.encode (DeletePrefixMutationRejected "partial")
|
||||
assertBool "login errors carry the login-specific code"
|
||||
("invalid_username" `BS.isInfixOf` BL.toStrict loginError)
|
||||
("oidc_callback_rejected" `BS.isInfixOf` BL.toStrict loginError)
|
||||
assertBool "delete errors carry the mutation-specific code"
|
||||
("mutation_rejected" `BS.isInfixOf` BL.toStrict deleteError)
|
||||
assertBool "operation errors must not share one representation"
|
||||
(loginError /= deleteError && deleteError /= prefixError)
|
||||
assertBool "prefix errors carry their operation-specific code"
|
||||
("prefix_mutation_rejected" `BS.isInfixOf` BL.toStrict prefixError)
|
||||
, testCase "production authentication validates cookies and login emits both cookies"
|
||||
, testCase "production authentication validates cookies and callback emits secure cookies"
|
||||
testAuthenticationBoundary
|
||||
, testCase "retained browser forms are wired to the typed API bridge"
|
||||
testBrowserBridge
|
||||
|
|
@ -254,27 +496,16 @@ testStreamingEndpoints = do
|
|||
testOperationRepresentations :: IO ()
|
||||
testOperationRepresentations = do
|
||||
calls <- newIORef 0
|
||||
login <- apiRequest calls methodPost
|
||||
"/login"
|
||||
[ ("Content-Type", "application/json")
|
||||
, ("Accept", "application/json")
|
||||
]
|
||||
"{\"username\":\"alice\"}"
|
||||
simpleStatus login @?= status200
|
||||
assertBool "login response must carry a user identity"
|
||||
("user_id" `BS.isInfixOf` BL.toStrict (simpleBody login))
|
||||
formLogin <- apiRequest calls methodPost
|
||||
"/login"
|
||||
[("Content-Type", "application/x-www-form-urlencoded")]
|
||||
"username=bob"
|
||||
simpleStatus formLogin @?= status200
|
||||
assertBool "browser login response navigates into the UI"
|
||||
("continue" `BS.isInfixOf` BL.toStrict (simpleBody formLogin))
|
||||
invalidLogin <- apiRequest calls methodPost
|
||||
"/login"
|
||||
[("Content-Type", "application/json")]
|
||||
"{\"username\":\"two words\"}"
|
||||
simpleStatus invalidLogin @?= status400
|
||||
login <- apiRequest calls methodGet "/login" [] ""
|
||||
simpleStatus login @?= status303
|
||||
lookup "Location" (simpleHeaders login) @?= Just "https://issuer.example/authorize"
|
||||
callback <- apiRequest calls methodGet
|
||||
"/oidc/callback?code=code&state=state"
|
||||
[("Cookie", "nuthatch_oidc=transaction")]
|
||||
""
|
||||
simpleStatus callback @?= status303
|
||||
legacyLogin <- apiRequest calls methodPost "/login" [] ""
|
||||
simpleStatus legacyLogin @?= status405
|
||||
deletion <- apiRequest calls methodPost
|
||||
"/delete"
|
||||
[("Content-Type", "application/json")]
|
||||
|
|
@ -350,18 +581,19 @@ typedTestServer :: IORef Int -> Server Api
|
|||
typedTestServer calls =
|
||||
counted (HealthResponse True)
|
||||
:<|> counted (ReadyResponse True)
|
||||
:<|> counted (HtmlPage "login")
|
||||
:<|> counted (BrowserScript "script")
|
||||
:<|> (\_ -> counted
|
||||
:<|> counted
|
||||
(addHeader "transaction=cookie"
|
||||
(addHeader "https://issuer.example/authorize" NoContent))
|
||||
:<|> (\_ _ _ -> counted
|
||||
(addHeader "session=cookie"
|
||||
(addHeader "csrf=cookie"
|
||||
(addHeader (CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
(LoginView
|
||||
(HtmlPage "continue")
|
||||
(LoginResponse (UserId "user")))))))
|
||||
(addHeader "transaction=expired"
|
||||
(addHeader "/" NoContent)))))
|
||||
:<|> (\_ _ -> counted
|
||||
(addHeader "session=expired"
|
||||
(addHeader "csrf=expired" NoContent)))
|
||||
(addHeader "csrf=expired"
|
||||
(addHeader "transaction=expired" NoContent))))
|
||||
:<|> (\_ _ _ _ _ -> counted
|
||||
(BrowseView
|
||||
(HtmlPage "browse")
|
||||
|
|
@ -392,17 +624,28 @@ testAuthenticationBoundary = do
|
|||
token <- either (fail . T.unpack) pure tokenResult
|
||||
let csrf = CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
responseHeaders = getHeaders
|
||||
(loginResponse testConfig (UserId "user") token csrf)
|
||||
(loginResponse testConfig token csrf)
|
||||
cookieHeaders =
|
||||
[ value
|
||||
| (name, value) <- responseHeaders
|
||||
, name == "Set-Cookie"
|
||||
]
|
||||
length cookieHeaders @?= 2
|
||||
length cookieHeaders @?= 3
|
||||
assertBool "login sets the session cookie"
|
||||
(any ("nuthatch_session=" `BS.isPrefixOf`) cookieHeaders)
|
||||
assertBool "login sets the CSRF cookie"
|
||||
(any ("nuthatch_csrf=" `BS.isPrefixOf`) cookieHeaders)
|
||||
assertBool "cookies are secure and same-site"
|
||||
(all ("Secure" `BS.isInfixOf`) cookieHeaders
|
||||
&& all ("SameSite=Lax" `BS.isInfixOf`) cookieHeaders)
|
||||
assertBool "cookies are host-only"
|
||||
(all (not . ("Domain=" `BS.isInfixOf`)) cookieHeaders)
|
||||
assertBool "session and CSRF cookies use the root path"
|
||||
(length (filter ("Path=/;" `BS.isInfixOf`) cookieHeaders) == 2)
|
||||
assertBool "the transaction cookie uses the callback path"
|
||||
(any ("Path=/oidc/callback;" `BS.isInfixOf`) cookieHeaders)
|
||||
assertBool "session and transaction cookies are HTTP-only"
|
||||
(length (filter ("HttpOnly" `BS.isInfixOf`) cookieHeaders) == 2)
|
||||
|
||||
calls <- newIORef 0
|
||||
let app = productionAuthTestApplication testConfig calls
|
||||
|
|
@ -425,6 +668,26 @@ testAuthenticationBoundary = do
|
|||
simpleStatus malformed @?= status400
|
||||
readIORef calls >>= (@?= 0)
|
||||
|
||||
malformedGrammar <- apiRequestWith app methodGet
|
||||
"/?root=personal"
|
||||
[("Cookie", "nuthatch_session")]
|
||||
""
|
||||
simpleStatus malformedGrammar @?= status400
|
||||
readIORef calls >>= (@?= 0)
|
||||
|
||||
let tamperedToken =
|
||||
case T.unsnoc (unSessionToken token) of
|
||||
Just (prefix, final)
|
||||
| final == 'a' -> prefix <> "b"
|
||||
| otherwise -> prefix <> "a"
|
||||
Nothing -> "invalid"
|
||||
tampered <- apiRequestWith app methodGet
|
||||
"/?root=personal"
|
||||
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 tamperedToken)]
|
||||
""
|
||||
simpleStatus tampered @?= status303
|
||||
readIORef calls >>= (@?= 0)
|
||||
|
||||
missingCsrf <- apiRequestWith app methodGet
|
||||
"/?root=personal"
|
||||
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token))]
|
||||
|
|
@ -457,8 +720,6 @@ testBrowserBridge = do
|
|||
[entry]
|
||||
Nothing
|
||||
""
|
||||
assertBool "login page loads the external bridge"
|
||||
("/nuthatch.js" `T.isInfixOf` loginPage "")
|
||||
mapM_
|
||||
(\marker -> assertBool ("browse page includes " <> T.unpack marker)
|
||||
(marker `T.isInfixOf` browse))
|
||||
|
|
@ -486,11 +747,21 @@ testConfig :: Config
|
|||
testConfig = Config
|
||||
{ cfgListenHost = "127.0.0.1"
|
||||
, cfgListenPort = 8080
|
||||
, cfgTlsCertPath = "/tls/tls.crt"
|
||||
, cfgTlsKeyPath = "/tls/tls.key"
|
||||
, cfgPublicBaseUrl = "https://nuthatch.example"
|
||||
, cfgJwtSecret = "unit-test-secret-material"
|
||||
, cfgJwtSecret = "unit-test-session-secret-material-32"
|
||||
, cfgSessionHours = 1
|
||||
, cfgCookieName = "nuthatch_session"
|
||||
, cfgCsrfCookieName = "nuthatch_csrf"
|
||||
, cfgOidcTransactionCookieName = "nuthatch_oidc"
|
||||
, cfgOidcIssuer = "https://issuer.example"
|
||||
, cfgOidcClientId = "nuthatch"
|
||||
, cfgOidcAdditionalAudiences = []
|
||||
, cfgOidcClientSecret = "unit-test-client-secret"
|
||||
, cfgOidcRedirectUri = "https://nuthatch.example/oidc/callback"
|
||||
, cfgOidcTransactionMinutes = 5
|
||||
, cfgOidcMaxIdTokenSeconds = 3600
|
||||
, cfgPgConn = ""
|
||||
, cfgRedisHost = ""
|
||||
, cfgRedisPort = 6379
|
||||
|
|
@ -509,7 +780,6 @@ testConfig = Config
|
|||
, cfgListCacheTtlSec = 30
|
||||
, cfgSearchMaxKeys = 100
|
||||
, cfgRecoveryGraceSec = 30
|
||||
, cfgAuthMode = AuthDev
|
||||
}
|
||||
|
||||
newtype ValidPath = ValidPath { getValidPath :: Text }
|
||||
|
|
|
|||
|
|
@ -207,6 +207,9 @@ spec:
|
|||
spec:
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
volumes:
|
||||
- name: bootstrap
|
||||
emptyDir: {}
|
||||
containers:
|
||||
- name: zitadel
|
||||
image: @@ZITADEL_IMAGE@@
|
||||
|
|
@ -220,6 +223,21 @@ spec:
|
|||
- {name: ZITADEL_EXTERNALPORT, value: "8080"}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME, value: nuthatch-admin}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD, value: Synthetic-Zitadel-1!}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED, value: "false"}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_PATPATH, value: /bootstrap/admin.pat}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME, value: nuthatch-provisioner}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME, value: Nuthatch Provisioner}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE, value: "2036-01-01T00:00:00Z"}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH, value: /bootstrap/login-client.pat}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME, value: nuthatch-login-client}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME, value: Nuthatch Login Client}
|
||||
- {name: ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE, value: "2036-01-01T00:00:00Z"}
|
||||
- {name: ZITADEL_DEFAULTINSTANCE_OIDCSETTINGS_ACCESSTOKENLIFETIME, value: 5m}
|
||||
- {name: ZITADEL_DEFAULTINSTANCE_OIDCSETTINGS_IDTOKENLIFETIME, value: 5m}
|
||||
- {name: ZITADEL_DEFAULTINSTANCE_OIDCSETTINGS_REFRESHTOKENIDLEEXPIRATION, value: 1h}
|
||||
- {name: ZITADEL_DEFAULTINSTANCE_OIDCSETTINGS_REFRESHTOKENEXPIRATION, value: 2h}
|
||||
volumeMounts:
|
||||
- {name: bootstrap, mountPath: /bootstrap}
|
||||
ports:
|
||||
- {name: http, containerPort: 8080}
|
||||
readinessProbe:
|
||||
|
|
@ -233,6 +251,25 @@ spec:
|
|||
resources:
|
||||
requests: {cpu: 200m, memory: 512Mi}
|
||||
limits: {cpu: "2", memory: 1Gi}
|
||||
- name: oidc-provisioner
|
||||
image: @@CURL_IMAGE@@
|
||||
imagePullPolicy: Never
|
||||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
- |
|
||||
while [ ! -s /bootstrap/admin.pat ] || [ ! -s /bootstrap/login-client.pat ]; do
|
||||
sleep 1
|
||||
done
|
||||
exec sleep 2147483647
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: {drop: ["ALL"]}
|
||||
runAsUser: 0
|
||||
volumeMounts:
|
||||
- {name: bootstrap, mountPath: /bootstrap, readOnly: true}
|
||||
resources:
|
||||
requests: {cpu: 10m, memory: 16Mi}
|
||||
limits: {cpu: 100m, memory: 32Mi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ render_manifests() {
|
|||
-e "s|@@GARAGE_IMAGE@@|$garage|g" \
|
||||
-e "s|@@VERSITYGW_IMAGE@@|$versitygw|g" \
|
||||
-e "s|@@COREDNS_IMAGE@@|$coredns|g" \
|
||||
-e "s|@@CURL_IMAGE@@|$curl|g" \
|
||||
-e "s|@@AWS_CLI_IMAGE@@|$aws_cli|g" \
|
||||
-e "s|@@POSTGRES_REPLICAS@@|$postgres_replicas|g" \
|
||||
-e "s|@@REDIS_REPLICAS@@|$redis_replicas|g" \
|
||||
|
|
@ -528,6 +529,153 @@ provision_lane_target_principal() {
|
|||
printf -v "$secret_key_var" '%s' "$secret_key"
|
||||
}
|
||||
|
||||
keycloak_admin_token() {
|
||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||
sh -ec '
|
||||
/opt/keycloak/bin/kcadm.sh config credentials \
|
||||
--server http://127.0.0.1:8080 \
|
||||
--realm master \
|
||||
--user nuthatch-admin \
|
||||
--password synthetic-keycloak >/dev/null
|
||||
/opt/keycloak/bin/kcadm.sh get serverinfo >/dev/null
|
||||
' >/dev/null
|
||||
}
|
||||
|
||||
provision_lane_keycloak_registration() {
|
||||
local lane="$1"
|
||||
local redirect_uri="$2"
|
||||
local client_id_var="$3"
|
||||
local client_secret_var="$4"
|
||||
local additional_audiences_var="$5"
|
||||
local user_a_var="$6"
|
||||
local user_b_var="$7"
|
||||
local client_id="nuthatch-$lane"
|
||||
local client_secret="synthetic-keycloak-client-secret-$lane"
|
||||
local username
|
||||
keycloak_admin_token || return
|
||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||
/opt/keycloak/bin/kcadm.sh create clients \
|
||||
--realm master \
|
||||
-s "clientId=$client_id" \
|
||||
-s enabled=true \
|
||||
-s protocol=openid-connect \
|
||||
-s publicClient=false \
|
||||
-s clientAuthenticatorType=client-secret \
|
||||
-s standardFlowEnabled=true \
|
||||
-s directAccessGrantsEnabled=false \
|
||||
-s "secret=$client_secret" \
|
||||
-s "redirectUris=[\"$redirect_uri\"]" \
|
||||
-s 'attributes={"pkce.code.challenge.method":"S256"}' >/dev/null || return
|
||||
for username in "nuthatch-$lane-a" "nuthatch-$lane-b"; do
|
||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||
/opt/keycloak/bin/kcadm.sh create users \
|
||||
--realm master \
|
||||
-s "username=$username" \
|
||||
-s enabled=true \
|
||||
-s "firstName=$username" >/dev/null || return
|
||||
kube --namespace "$FIXTURE_NAMESPACE" exec deployment/keycloak -- \
|
||||
/opt/keycloak/bin/kcadm.sh set-password \
|
||||
--realm master \
|
||||
--username "$username" \
|
||||
--new-password Synthetic-OIDC-1! >/dev/null || return
|
||||
done
|
||||
printf -v "$client_id_var" '%s' "$client_id"
|
||||
printf -v "$client_secret_var" '%s' "$client_secret"
|
||||
printf -v "$additional_audiences_var" ''
|
||||
printf -v "$user_a_var" '%s' "nuthatch-$lane-a"
|
||||
printf -v "$user_b_var" '%s' "nuthatch-$lane-b"
|
||||
}
|
||||
|
||||
zitadel_api() {
|
||||
local token_path="$1"
|
||||
local method="$2"
|
||||
local path="$3"
|
||||
local body="$4"
|
||||
kube --namespace "$FIXTURE_NAMESPACE" exec \
|
||||
deployment/zitadel \
|
||||
--container oidc-provisioner \
|
||||
-- sh -ec '
|
||||
token="$(tr -d "\r\n" < "$1")"
|
||||
curl --fail --silent --show-error \
|
||||
--request "$2" \
|
||||
--header "Host: zitadel.nuthatch.test" \
|
||||
--header "Authorization: Bearer $token" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "$4" \
|
||||
"http://127.0.0.1:8080$3"
|
||||
' sh "$token_path" "$method" "$path" "$body"
|
||||
}
|
||||
|
||||
provision_lane_zitadel_registration() {
|
||||
local lane="$1"
|
||||
local redirect_uri="$2"
|
||||
local client_id_var="$3"
|
||||
local client_secret_var="$4"
|
||||
local additional_audiences_var="$5"
|
||||
local user_a_var="$6"
|
||||
local user_b_var="$7"
|
||||
local project_response
|
||||
local project_id
|
||||
local app_response
|
||||
local client_id
|
||||
local client_secret
|
||||
local username
|
||||
local user_response
|
||||
local user_ids=()
|
||||
project_response="$(
|
||||
zitadel_api \
|
||||
/bootstrap/admin.pat \
|
||||
POST \
|
||||
/management/v1/projects \
|
||||
"{\"name\":\"Nuthatch lane $lane\"}"
|
||||
)" || return
|
||||
project_id="$(jq -er .id <<< "$project_response")" || return
|
||||
app_response="$(
|
||||
zitadel_api \
|
||||
/bootstrap/admin.pat \
|
||||
POST \
|
||||
"/management/v1/projects/$project_id/apps/oidc" \
|
||||
"{\"projectId\":\"$project_id\",\"name\":\"Nuthatch lane $lane\",\"redirectUris\":[\"$redirect_uri\"],\"responseTypes\":[\"OIDC_RESPONSE_TYPE_CODE\"],\"grantTypes\":[\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\"],\"appType\":\"OIDC_APP_TYPE_WEB\",\"authMethodType\":\"OIDC_AUTH_METHOD_TYPE_BASIC\",\"version\":\"OIDC_VERSION_1_0\",\"devMode\":false}"
|
||||
)" || return
|
||||
client_id="$(jq -er .clientId <<< "$app_response")" || return
|
||||
client_secret="$(jq -er .clientSecret <<< "$app_response")" || return
|
||||
for username in "nuthatch-$lane-a" "nuthatch-$lane-b"; do
|
||||
user_response="$(
|
||||
zitadel_api \
|
||||
/bootstrap/admin.pat \
|
||||
POST \
|
||||
/management/v1/users/human \
|
||||
"{\"userName\":\"$username\",\"profile\":{\"firstName\":\"Nuthatch\",\"lastName\":\"$username\",\"displayName\":\"$username\",\"preferredLanguage\":\"en\"},\"email\":{\"email\":\"$username@example.test\",\"isEmailVerified\":true},\"initialPassword\":\"Synthetic-OIDC-1!\"}"
|
||||
)" || return
|
||||
user_ids+=("$(jq -er .userId <<< "$user_response")") || return
|
||||
done
|
||||
printf -v "$client_id_var" '%s' "$client_id"
|
||||
printf -v "$client_secret_var" '%s' "$client_secret"
|
||||
printf -v "$additional_audiences_var" '%s' "$project_id"
|
||||
printf -v "$user_a_var" '%s' "${user_ids[0]}"
|
||||
printf -v "$user_b_var" '%s' "${user_ids[1]}"
|
||||
}
|
||||
|
||||
create_oidc_automation_secret() {
|
||||
local lane="$1"
|
||||
local provider="$2"
|
||||
local login_pat=unused
|
||||
if [[ "$provider" = zitadel ]]; then
|
||||
login_pat="$(
|
||||
kube --namespace "$FIXTURE_NAMESPACE" exec \
|
||||
deployment/zitadel \
|
||||
--container oidc-provisioner \
|
||||
-- sh -ec 'tr -d "\r\n" < /bootstrap/login-client.pat'
|
||||
)" || return
|
||||
fi
|
||||
kube create secret generic "nuthatch-oidc-automation-$lane" \
|
||||
--namespace "$FIXTURE_NAMESPACE" \
|
||||
--from-literal="NUTHATCH_OIDC_LOGIN_PAT=$login_pat" \
|
||||
--dry-run=client \
|
||||
--output yaml |
|
||||
kube apply --filename - >/dev/null
|
||||
}
|
||||
|
||||
write_lane_profile() {
|
||||
local lane="$1"
|
||||
local coordination="$2"
|
||||
|
|
@ -538,6 +686,12 @@ write_lane_profile() {
|
|||
local database=
|
||||
local coordination_database=
|
||||
local oidc_issuer=
|
||||
local oidc_client_id=
|
||||
local oidc_client_secret=
|
||||
local oidc_additional_audiences=
|
||||
local oidc_redirect_uri=
|
||||
local oidc_user_a=
|
||||
local oidc_user_b=
|
||||
local target_url=
|
||||
local target_bucket=
|
||||
local target_region=
|
||||
|
|
@ -550,11 +704,36 @@ write_lane_profile() {
|
|||
coordination_database="$(reset_lane_coordination_store "$coordination" "$lane")" || return
|
||||
fi
|
||||
if fixture_has_family oidc; then
|
||||
oidc_redirect_uri="https://nuthatch-$lane:8080/oidc/callback"
|
||||
case "$oidc" in
|
||||
keycloak) oidc_issuer=http://keycloak.nuthatch.test:8080/realms/master ;;
|
||||
zitadel) oidc_issuer=http://zitadel.nuthatch.test:8080 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "$oidc" in
|
||||
keycloak)
|
||||
provision_lane_keycloak_registration \
|
||||
"$lane" \
|
||||
"$oidc_redirect_uri" \
|
||||
oidc_client_id \
|
||||
oidc_client_secret \
|
||||
oidc_additional_audiences \
|
||||
oidc_user_a \
|
||||
oidc_user_b || return
|
||||
;;
|
||||
zitadel)
|
||||
provision_lane_zitadel_registration \
|
||||
"$lane" \
|
||||
"$oidc_redirect_uri" \
|
||||
oidc_client_id \
|
||||
oidc_client_secret \
|
||||
oidc_additional_audiences \
|
||||
oidc_user_a \
|
||||
oidc_user_b || return
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
create_oidc_automation_secret "$lane" "$oidc" || return
|
||||
fi
|
||||
if fixture_has_family target; then
|
||||
target_bucket="nuthatch-lane-$lane"
|
||||
|
|
@ -581,6 +760,12 @@ write_lane_profile() {
|
|||
printf 'NUTHATCH_PG_CONN=host=postgres port=5432 user=nuthatch password=synthetic-postgres dbname=%s\n' "$database"
|
||||
printf 'NUTHATCH_REDIS_URL=redis://%s:6379/%s\n' "$coordination" "$coordination_database"
|
||||
printf 'NUTHATCH_OIDC_ISSUER=%s\n' "$oidc_issuer"
|
||||
printf 'NUTHATCH_OIDC_CLIENT_ID=%s\n' "$oidc_client_id"
|
||||
printf 'NUTHATCH_OIDC_ADDITIONAL_AUDIENCES=%s\n' "$oidc_additional_audiences"
|
||||
printf 'NUTHATCH_OIDC_REDIRECT_URI=%s\n' "$oidc_redirect_uri"
|
||||
printf 'NUTHATCH_OIDC_USER_A=%s\n' "$oidc_user_a"
|
||||
printf 'NUTHATCH_OIDC_USER_B=%s\n' "$oidc_user_b"
|
||||
printf 'NUTHATCH_OIDC_USER_PASSWORD=Synthetic-OIDC-1!\n'
|
||||
printf 'NUTHATCH_S3_ENDPOINT=%s\n' "$target_url"
|
||||
printf 'NUTHATCH_S3_BUCKET=%s\n' "$target_bucket"
|
||||
printf 'NUTHATCH_S3_REGION=%s\n' "$target_region"
|
||||
|
|
@ -588,6 +773,10 @@ write_lane_profile() {
|
|||
printf 'NUTHATCH_S3_SECRET_KEY=%s\n' "$target_secret_key"
|
||||
printf 'NUTHATCH_S3_PATH_STYLE=true\n'
|
||||
} > "$profile_file" || return
|
||||
if fixture_has_family oidc; then
|
||||
printf '%s' "$oidc_client_secret" > "$profile_file.oidc-secret" || return
|
||||
chmod 0600 "$profile_file.oidc-secret" || return
|
||||
fi
|
||||
kube create configmap "$profile" \
|
||||
--namespace "$FIXTURE_NAMESPACE" \
|
||||
--from-env-file="$profile_file" \
|
||||
|
|
@ -682,8 +871,12 @@ cleanup_lane_resources() {
|
|||
"job/$job" \
|
||||
"configmap/$profile" \
|
||||
"configmap/$profile-file" \
|
||||
"secret/nuthatch-oidc-automation-${profile##*-}" \
|
||||
--ignore-not-found >/dev/null || return
|
||||
rm -f "$RUNTIME/$profile.env" "$RUNTIME/$job.yaml"
|
||||
rm -f \
|
||||
"$RUNTIME/$profile.env" \
|
||||
"$RUNTIME/$profile.env.oidc-secret" \
|
||||
"$RUNTIME/$job.yaml"
|
||||
}
|
||||
|
||||
run_job_matrix() {
|
||||
|
|
@ -722,6 +915,7 @@ destroy_fixture() {
|
|||
"$RUNTIME/probe.yaml" \
|
||||
"$RUNTIME/fixture.env" \
|
||||
"$RUNTIME"/nuthatch-fixture-lane-*.env \
|
||||
"$RUNTIME"/nuthatch-fixture-lane-*.env.oidc-secret \
|
||||
"$RUNTIME"/wp05-app-*.yaml || status=1
|
||||
return "$status"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,14 @@ spec:
|
|||
envFrom:
|
||||
- configMapRef: {name: @@PROFILE_NAME@@}
|
||||
env:
|
||||
- {name: NUTHATCH_JWT_SECRET, value: synthetic-wp04-jwt-secret}
|
||||
- {name: NUTHATCH_JWT_SECRET, value: synthetic-wp04-session-jwt-secret}
|
||||
- {name: NUTHATCH_REDIS_URL, value: "redis://127.0.0.1:6379/0"}
|
||||
- {name: NUTHATCH_PUBLIC_BASE_URL, value: "https://wp04.invalid"}
|
||||
- {name: NUTHATCH_OIDC_ISSUER, value: "https://issuer.invalid"}
|
||||
- {name: NUTHATCH_OIDC_CLIENT_ID, value: wp04}
|
||||
- {name: NUTHATCH_OIDC_ADDITIONAL_AUDIENCES, value: ""}
|
||||
- {name: NUTHATCH_OIDC_CLIENT_SECRET, value: synthetic-wp04-oidc-client-secret}
|
||||
- {name: NUTHATCH_OIDC_REDIRECT_URI, value: "https://wp04.invalid/oidc/callback"}
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 32Mi}
|
||||
limits: {cpu: "2", memory: 96Mi}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ spec:
|
|||
spec:
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
dnsPolicy: None
|
||||
dnsConfig:
|
||||
nameservers: [10.96.0.53]
|
||||
searches:
|
||||
- nuthatch-testbed.svc.cluster.local
|
||||
- svc.cluster.local
|
||||
- cluster.local
|
||||
options:
|
||||
- {name: ndots, value: "5"}
|
||||
restartPolicy: Never
|
||||
initContainers:
|
||||
- name: credential-negative
|
||||
|
|
@ -57,18 +66,301 @@ spec:
|
|||
command: [/bin/sh, -ec]
|
||||
args:
|
||||
- |
|
||||
base="http://nuthatch-@@LANE@@:8080"
|
||||
curl --fail --silent --show-error "$base/healthz" | grep -F '"ok":true'
|
||||
curl --fail --silent --show-error "$base/readyz" | grep -F '"ready":true'
|
||||
curl --fail --silent --show-error \
|
||||
--cookie-jar /tmp/cookies \
|
||||
--dump-header /tmp/login-headers \
|
||||
--header 'Accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"username":"wp05-smoke"}' \
|
||||
"$base/login" >/tmp/login.json
|
||||
csrf="$(awk 'BEGIN{IGNORECASE=1} $1=="X-CSRF-Token:"{gsub("\r","",$2); print $2}' /tmp/login-headers)"
|
||||
base="https://nuthatch-@@LANE@@:8080"
|
||||
|
||||
curl() {
|
||||
command curl --insecure "$@"
|
||||
}
|
||||
|
||||
header_value() {
|
||||
awk -v expected="$2" '
|
||||
BEGIN { IGNORECASE=1 }
|
||||
$1 == expected ":" {
|
||||
sub(/^[^:]*:[[:space:]]*/, "")
|
||||
sub(/\r$/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
json_field() {
|
||||
tr -d '\n' < "$1" |
|
||||
sed -n 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
|
||||
}
|
||||
|
||||
oidc_callback_for() {
|
||||
user="$1"
|
||||
suffix="$2"
|
||||
authorization_url="$3"
|
||||
printf 'Completing %s authorization %s\n' \
|
||||
"$NUTHATCH_OIDC_PROVIDER" "$suffix" >&2
|
||||
case "$NUTHATCH_OIDC_PROVIDER" in
|
||||
keycloak)
|
||||
curl --fail --silent --show-error \
|
||||
--cookie-jar "/tmp/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/&/\&/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"
|
||||
cookies=/tmp/cookies-a
|
||||
printf 'Login a completed csrf-length=%d\n' "${#csrf}"
|
||||
authenticated_status="$(
|
||||
curl --insecure --silent --output /tmp/authenticated.body \
|
||||
--write-out '%{http_code}' \
|
||||
--cookie "$cookies" \
|
||||
"$base/"
|
||||
)"
|
||||
printf 'Authenticated browse status=%s\n' "$authenticated_status"
|
||||
test "$authenticated_status" = 200
|
||||
curl --insecure --fail --silent --show-error \
|
||||
--cookie "$cookies" \
|
||||
--header 'Accept: application/json' \
|
||||
"$base/" >/tmp/authenticated.json
|
||||
if grep -Ei \
|
||||
'NUTHATCH_S3|AWS_ACCESS_KEY|AWS_SECRET_ACCESS_KEY|client_secret' \
|
||||
/tmp/authenticated.body /tmp/authenticated.json; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exercise_root() {
|
||||
root="$1"
|
||||
|
|
@ -78,22 +370,28 @@ spec:
|
|||
printf '%s' "$first" >/tmp/body
|
||||
size="$(wc -c </tmp/body | tr -d ' ')"
|
||||
digest="$(sha256sum /tmp/body | cut -d' ' -f1)"
|
||||
curl --fail --silent --show-error \
|
||||
--cookie /tmp/cookies \
|
||||
upload_status="$(
|
||||
curl --silent --show-error \
|
||||
--output /tmp/upload.json \
|
||||
--write-out '%{http_code}' \
|
||||
--cookie "$cookies" \
|
||||
--header 'Content-Type: application/octet-stream' \
|
||||
--header "X-Declared-Size: $size" \
|
||||
--header "X-Content-SHA256: $digest" \
|
||||
--header "X-CSRF-Token: $csrf" \
|
||||
--data-binary @/tmp/body \
|
||||
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=00000000-0000-4000-8000-0000000000${suffix}" |
|
||||
grep -F '"result":"succeeded"'
|
||||
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=00000000-0000-4000-8000-0000000000${suffix}"
|
||||
)"
|
||||
printf 'Upload %s status=%s\n' "$root" "$upload_status"
|
||||
test "$upload_status" = 200
|
||||
grep -F '"result":"succeeded"' /tmp/upload.json
|
||||
curl --fail --silent --show-error \
|
||||
--cookie /tmp/cookies \
|
||||
--cookie "$cookies" \
|
||||
--header 'Accept: application/json' \
|
||||
"$base/?root=$root" |
|
||||
grep -F "smoke-$suffix.txt"
|
||||
curl --fail --silent --show-error \
|
||||
--cookie /tmp/cookies \
|
||||
--cookie "$cookies" \
|
||||
"$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
|
||||
test "$(cat /tmp/download)" = "$first"
|
||||
|
||||
|
|
@ -101,7 +399,7 @@ spec:
|
|||
size="$(wc -c </tmp/body | tr -d ' ')"
|
||||
digest="$(sha256sum /tmp/body | cut -d' ' -f1)"
|
||||
curl --fail --silent --show-error \
|
||||
--cookie /tmp/cookies \
|
||||
--cookie "$cookies" \
|
||||
--header 'Content-Type: application/octet-stream' \
|
||||
--header "X-Declared-Size: $size" \
|
||||
--header "X-Content-SHA256: $digest" \
|
||||
|
|
@ -110,18 +408,18 @@ spec:
|
|||
"$base/upload?root=$root&path=smoke-$suffix.txt&operation_id=10000000-0000-4000-8000-0000000000${suffix}" |
|
||||
grep -F '"result":"succeeded"'
|
||||
curl --fail --silent --show-error \
|
||||
--cookie /tmp/cookies \
|
||||
--cookie "$cookies" \
|
||||
"$base/download?root=$root&path=smoke-$suffix.txt" >/tmp/download
|
||||
test "$(cat /tmp/download)" = "$second"
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
--cookie /tmp/cookies \
|
||||
--cookie "$cookies" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "{\"root\":\"$root\",\"path\":\"smoke-$suffix.txt\",\"operation_id\":\"20000000-0000-4000-8000-0000000000${suffix}\",\"csrf\":\"$csrf\"}" \
|
||||
"$base/delete" |
|
||||
grep -F '"result":"succeeded"'
|
||||
if curl --fail --silent \
|
||||
--cookie /tmp/cookies \
|
||||
--cookie "$cookies" \
|
||||
"$base/download?root=$root&path=smoke-$suffix.txt" >/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -129,7 +427,152 @@ spec:
|
|||
|
||||
exercise_root personal 01
|
||||
exercise_root shared 02
|
||||
printf 'WP-05 app smoke passed\n'
|
||||
printf private-a >/tmp/body
|
||||
size="$(wc -c </tmp/body | tr -d ' ')"
|
||||
digest="$(sha256sum /tmp/body | cut -d' ' -f1)"
|
||||
curl --insecure --fail --silent --show-error \
|
||||
--cookie "$cookies" \
|
||||
--header 'Content-Type: application/octet-stream' \
|
||||
--header "X-Declared-Size: $size" \
|
||||
--header "X-Content-SHA256: $digest" \
|
||||
--header "X-CSRF-Token: $csrf" \
|
||||
--data-binary @/tmp/body \
|
||||
"$base/upload?root=personal&path=cross-user.txt&operation_id=30000000-0000-4000-8000-000000000001" |
|
||||
grep -F '"result":"succeeded"'
|
||||
curl --insecure --fail --silent --show-error \
|
||||
--cookie "$cookies" \
|
||||
--header 'Content-Type: application/octet-stream' \
|
||||
--header "X-Declared-Size: $size" \
|
||||
--header "X-Content-SHA256: $digest" \
|
||||
--header "X-CSRF-Token: $csrf" \
|
||||
--data-binary @/tmp/body \
|
||||
"$base/upload?root=shared&path=cross-user.txt&operation_id=30000000-0000-4000-8000-000000000002" |
|
||||
grep -F '"result":"succeeded"'
|
||||
|
||||
csrf_b="$(login "$NUTHATCH_OIDC_USER_B" b)"
|
||||
test -n "$csrf_b"
|
||||
|
||||
curl --insecure --silent --show-error \
|
||||
--cookie-jar /tmp/cookies-pkce \
|
||||
--dump-header /tmp/login-pkce.headers \
|
||||
"$base/login" >/tmp/login-pkce.body
|
||||
pkce_authorization_url="$(
|
||||
header_value /tmp/login-pkce.headers Location
|
||||
)"
|
||||
pkce_callback="$(
|
||||
oidc_callback_for \
|
||||
"$NUTHATCH_OIDC_USER_A" \
|
||||
pkce \
|
||||
"$pkce_authorization_url"
|
||||
)"
|
||||
pkce_code="$(
|
||||
printf '%s' "$pkce_callback" |
|
||||
sed -n 's/.*[?&]code=\([^&]*\).*/\1/p'
|
||||
)"
|
||||
test -n "$pkce_code"
|
||||
curl --fail --silent --show-error \
|
||||
"$NUTHATCH_OIDC_ISSUER/.well-known/openid-configuration" \
|
||||
>/tmp/discovery.json
|
||||
token_endpoint="$(json_field /tmp/discovery.json token_endpoint)"
|
||||
test -n "$token_endpoint"
|
||||
pkce_status="$(
|
||||
curl --silent --output /tmp/pkce-error.json \
|
||||
--write-out '%{http_code}' \
|
||||
--user "$NUTHATCH_OIDC_CLIENT_ID:$NUTHATCH_OIDC_CLIENT_SECRET" \
|
||||
--data-urlencode grant_type=authorization_code \
|
||||
--data-urlencode "code=$pkce_code" \
|
||||
--data-urlencode "redirect_uri=$NUTHATCH_OIDC_REDIRECT_URI" \
|
||||
--data-urlencode code_verifier=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
|
||||
"$token_endpoint"
|
||||
)"
|
||||
test "$pkce_status" -ge 400
|
||||
if curl --insecure --fail --silent --show-error \
|
||||
--cookie /tmp/cookies-b \
|
||||
--header 'Accept: application/json' \
|
||||
"$base/?root=personal" |
|
||||
grep -F cross-user.txt; then
|
||||
exit 1
|
||||
fi
|
||||
curl --insecure --fail --silent --show-error \
|
||||
--cookie /tmp/cookies-b \
|
||||
--header 'Accept: application/json' \
|
||||
"$base/?root=shared" |
|
||||
grep -F cross-user.txt
|
||||
|
||||
if curl --insecure --fail --silent \
|
||||
--cookie "$cookies" \
|
||||
--header 'X-CSRF-Token: invalid' \
|
||||
--request POST \
|
||||
"$base/logout" >/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
curl --insecure --fail --silent --show-error \
|
||||
--cookie "$cookies" \
|
||||
--cookie-jar "$cookies" \
|
||||
--dump-header /tmp/logout.headers \
|
||||
--header "X-CSRF-Token: $csrf" \
|
||||
--request POST \
|
||||
"$base/logout" >/dev/null
|
||||
grep -i '^set-cookie: nuthatch_session=' /tmp/logout.headers |
|
||||
grep -i 'max-age=0' >/dev/null
|
||||
grep -i '^set-cookie: nuthatch_csrf=' /tmp/logout.headers |
|
||||
grep -i 'max-age=0' >/dev/null
|
||||
grep -i '^set-cookie: nuthatch_oidc=' /tmp/logout.headers |
|
||||
grep -i 'max-age=0' |
|
||||
grep -i 'path=/oidc/callback' >/dev/null
|
||||
test "$(
|
||||
curl --insecure --silent --output /dev/null \
|
||||
--write-out '%{http_code}' \
|
||||
--cookie "$cookies" \
|
||||
"$base/"
|
||||
)" = 303
|
||||
printf 'OIDC app smoke passed\n'
|
||||
env:
|
||||
- name: NUTHATCH_OIDC_PROVIDER
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: @@PROFILE_NAME@@
|
||||
key: NUTHATCH_OIDC_PROVIDER
|
||||
- name: NUTHATCH_OIDC_ISSUER
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: @@PROFILE_NAME@@
|
||||
key: NUTHATCH_OIDC_ISSUER
|
||||
- name: NUTHATCH_OIDC_USER_A
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: @@PROFILE_NAME@@
|
||||
key: NUTHATCH_OIDC_USER_A
|
||||
- name: NUTHATCH_OIDC_USER_B
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: @@PROFILE_NAME@@
|
||||
key: NUTHATCH_OIDC_USER_B
|
||||
- name: NUTHATCH_OIDC_USER_PASSWORD
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: @@PROFILE_NAME@@
|
||||
key: NUTHATCH_OIDC_USER_PASSWORD
|
||||
- name: NUTHATCH_OIDC_CLIENT_ID
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: @@PROFILE_NAME@@
|
||||
key: NUTHATCH_OIDC_CLIENT_ID
|
||||
- name: NUTHATCH_OIDC_REDIRECT_URI
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: @@PROFILE_NAME@@
|
||||
key: NUTHATCH_OIDC_REDIRECT_URI
|
||||
- name: NUTHATCH_OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: nuthatch-@@LANE@@
|
||||
key: NUTHATCH_OIDC_CLIENT_SECRET
|
||||
- name: NUTHATCH_OIDC_LOGIN_PAT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: nuthatch-oidc-automation-@@LANE@@
|
||||
key: NUTHATCH_OIDC_LOGIN_PAT
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 32Mi}
|
||||
limits: {cpu: "1", memory: 128Mi}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ render_application() {
|
|||
local profile_file="$2"
|
||||
local pg_conn
|
||||
local redis_url
|
||||
local oidc_issuer
|
||||
local oidc_client_id
|
||||
local oidc_additional_audiences
|
||||
local oidc_client_secret
|
||||
local s3_endpoint
|
||||
local s3_region
|
||||
local s3_bucket
|
||||
|
|
@ -43,6 +47,12 @@ render_application() {
|
|||
local s3_secret_key
|
||||
pg_conn="$(sed -n 's/^NUTHATCH_PG_CONN=//p' "$profile_file")"
|
||||
redis_url="$(sed -n 's/^NUTHATCH_REDIS_URL=//p' "$profile_file")"
|
||||
oidc_issuer="$(sed -n 's/^NUTHATCH_OIDC_ISSUER=//p' "$profile_file")"
|
||||
oidc_client_id="$(sed -n 's/^NUTHATCH_OIDC_CLIENT_ID=//p' "$profile_file")"
|
||||
oidc_additional_audiences="$(
|
||||
sed -n 's/^NUTHATCH_OIDC_ADDITIONAL_AUDIENCES=//p' "$profile_file"
|
||||
)"
|
||||
oidc_client_secret="$(<"$profile_file.oidc-secret")"
|
||||
s3_endpoint="$(sed -n 's/^NUTHATCH_S3_ENDPOINT=//p' "$profile_file")"
|
||||
s3_region="$(sed -n 's/^NUTHATCH_S3_REGION=//p' "$profile_file")"
|
||||
s3_bucket="$(sed -n 's/^NUTHATCH_S3_BUCKET=//p' "$profile_file")"
|
||||
|
|
@ -53,6 +63,10 @@ render_application() {
|
|||
-e "s|@@APP_IMAGE@@|$APP_IMAGE|g" \
|
||||
-e "s|@@PG_CONN@@|$pg_conn|g" \
|
||||
-e "s|@@REDIS_URL@@|$redis_url|g" \
|
||||
-e "s|@@OIDC_ISSUER@@|$oidc_issuer|g" \
|
||||
-e "s|@@OIDC_CLIENT_ID@@|$oidc_client_id|g" \
|
||||
-e "s|@@OIDC_ADDITIONAL_AUDIENCES@@|$oidc_additional_audiences|g" \
|
||||
-e "s|@@OIDC_CLIENT_SECRET@@|$oidc_client_secret|g" \
|
||||
-e "s|@@S3_ENDPOINT@@|$s3_endpoint|g" \
|
||||
-e "s|@@S3_REGION@@|$s3_region|g" \
|
||||
-e "s|@@S3_BUCKET@@|$s3_bucket|g" \
|
||||
|
|
@ -74,7 +88,7 @@ cleanup_application() {
|
|||
|
||||
acquire_fixture_lock
|
||||
OWNS_FIXTURE=true
|
||||
setup_fixture durable coordination target
|
||||
setup_fixture durable coordination oidc target
|
||||
source "$RUNTIME/fixture.env"
|
||||
"$NUTHATCH_REAL_NERDCTL" --namespace "$NERDCTL_NAMESPACE" build \
|
||||
--file "$ROOT/Dockerfile" \
|
||||
|
|
@ -104,12 +118,13 @@ while IFS=$'\t' read -r lane coordination oidc target; do
|
|||
"$CURL_LOCAL_IMAGE" \
|
||||
"$profile"
|
||||
sed -i "s|@@LANE@@|$lane|g" "$RUNTIME/$job.yaml"
|
||||
printf 'Running lane %s: coordination=%s target=%s\n' "$lane" "$coordination" "$target"
|
||||
printf 'Running lane %s: coordination=%s oidc=%s target=%s\n' \
|
||||
"$lane" "$coordination" "$oidc" "$target"
|
||||
run_job_manifest "$job" "$RUNTIME/$job.yaml"
|
||||
cleanup_application "$lane"
|
||||
cleanup_lane_state "$lane" "$coordination" "$oidc" "$target"
|
||||
cleanup_lane_resources "$job" "$profile"
|
||||
done < <(expand_fixture_matrix coordination target)
|
||||
done < <(expand_fixture_matrix coordination oidc target)
|
||||
|
||||
SUCCEEDED=true
|
||||
printf 'Nuthatch WP-05 app gate passed\n'
|
||||
printf 'Nuthatch OIDC app gate passed\n'
|
||||
|
|
|
|||
Loading…
Reference in a new issue