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