Type ordinary integration probe HTTP traffic

This commit is contained in:
ms2k 2026-07-20 23:06:01 +09:00
commit cc5c47f2ce

View file

@ -20,13 +20,12 @@ import Data.Char (toLower)
import Data.List (find)
import Data.Maybe (fromMaybe)
import Nuthatch.Probe.Assert
import Nuthatch.Probe.Curl
import Nuthatch.Probe.Http
import System.Environment (lookupEnv)
import System.FilePath ((</>))
data ProbeContext = ProbeContext
{ probeBase :: String
, probeDirectory :: FilePath
{ probeClient :: ProbeClient
, probeBase :: String
, probeOidcIssuer :: String
, probeOidcProvider :: String
, probeOidcPassword :: String
@ -37,7 +36,7 @@ data ProbeContext = ProbeContext
}
data Session = Session
{ sessionCookies :: FilePath
{ sessionCookies :: CookieSession
, sessionCsrf :: BS.ByteString
}
@ -64,8 +63,9 @@ instance FromJSON OidcDiscovery where
parseJSON = withObject "OidcDiscovery" $ \object ->
OidcDiscovery <$> object .: "token_endpoint"
newProbeContext :: FilePath -> IO ProbeContext
newProbeContext directory = do
newProbeContext :: IO ProbeContext
newProbeContext = do
client <- newProbeClient
lane <- requireEnv "NUTHATCH_FIXTURE_LANE"
issuer <- requireEnv "NUTHATCH_OIDC_ISSUER"
provider <- requireEnv "NUTHATCH_OIDC_PROVIDER"
@ -75,8 +75,8 @@ newProbeContext directory = do
clientSecret <- requireEnv "NUTHATCH_OIDC_CLIENT_SECRET"
redirectUri <- requireEnv "NUTHATCH_OIDC_REDIRECT_URI"
pure ProbeContext
{ probeBase = "https://nuthatch-" <> lane <> ":8080"
, probeDirectory = directory
{ probeClient = client
, probeBase = "https://nuthatch-" <> lane <> ":8080"
, probeOidcIssuer = issuer
, probeOidcProvider = provider
, probeOidcPassword = password
@ -88,15 +88,15 @@ newProbeContext directory = do
login :: ProbeContext -> Bool -> String -> String -> IO Session
login context verifyBoundary user suffix = do
let cookies = probeDirectory context </> "cookies-" <> suffix
idpCookies = probeDirectory context </> "idp-" <> suffix
cookies <- newCookieSession
idpCookies <- newCookieSession
started <-
retryTransport 20 $
curl
[ "--cookie-jar", cookies
, probeBase context <> "/login"
]
assertEqual "login redirect status" 303 (curlStatus started)
getWith
(probeClient context)
defaultRequestOptions { optionCookies = Just cookies }
(probeBase context <> "/login")
assertEqual "login redirect status" 303 (probeStatus started)
authorizationUrl <-
maybe (fail "login response omitted Location") (pure . B8.unpack) $
headerValue "location" started
@ -109,12 +109,11 @@ login context verifyBoundary user suffix = do
then verifyTransactionBoundary context cookies authorizationUrl state
else pure ()
callback <- completeAuthorization context idpCookies user suffix authorizationUrl
completed <- curl
[ "--cookie", cookies
, "--cookie-jar", cookies
, callback
]
assertEqual "OIDC callback status" 303 (curlStatus completed)
completed <- getWith
(probeClient context)
defaultRequestOptions { optionCookies = Just cookies }
callback
assertEqual "OIDC callback status" 303 (probeStatus completed)
assertEqual "OIDC callback location"
(Just "/app")
(B8.unpack <$> headerValue "location" completed)
@ -143,12 +142,12 @@ login context verifyBoundary user suffix = do
verifyPkceBoundary :: ProbeContext -> String -> IO ()
verifyPkceBoundary context user = do
let cookies = probeDirectory context </> "cookies-pkce"
idpCookies = probeDirectory context </> "idp-pkce"
started <- curl
[ "--cookie-jar", cookies
, probeBase context <> "/login"
]
cookies <- newCookieSession
idpCookies <- newCookieSession
started <- getWith
(probeClient context)
defaultRequestOptions { optionCookies = Just cookies }
(probeBase context <> "/login")
authorizationUrl <-
maybe (fail "PKCE login omitted Location") (pure . B8.unpack) $
headerValue "location" started
@ -159,62 +158,72 @@ verifyPkceBoundary context user = do
"pkce"
authorizationUrl
code <- queryValue "code" callback
discoveryResponse <- curl
[probeOidcIssuer context <> "/.well-known/openid-configuration"]
assertEqual "OIDC discovery status" 200 (curlStatus discoveryResponse)
discoveryResponse <- get
(probeClient context)
(probeOidcIssuer context <> "/.well-known/openid-configuration")
assertEqual "OIDC discovery status" 200 (probeStatus discoveryResponse)
OidcDiscovery tokenEndpoint <-
decodeBody "OIDC discovery" discoveryResponse
rejected <- curl
[ "--user"
, probeOidcClientId context <> ":" <> probeOidcClientSecret context
, "--data-urlencode", "grant_type=authorization_code"
, "--data-urlencode", "code=" <> code
, "--data-urlencode", "redirect_uri=" <> probeOidcRedirectUri context
, "--data-urlencode"
, "code_verifier=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
, tokenEndpoint
]
rejected <- postWith
(probeClient context)
defaultRequestOptions
{ optionBasicAuth = Just
( B8.pack (probeOidcClientId context)
, B8.pack (probeOidcClientSecret context)
)
, optionBody = FormBody
[ ("grant_type", "authorization_code")
, ("code", B8.pack code)
, ("redirect_uri", B8.pack (probeOidcRedirectUri context))
, ( "code_verifier"
, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
]
}
tokenEndpoint
assert "provider accepted an invalid PKCE verifier" $
curlStatus rejected >= 400
probeStatus rejected >= 400
verifyTransactionBoundary
:: ProbeContext
-> FilePath
-> CookieSession
-> String
-> String
-> IO ()
verifyTransactionBoundary context cookies authorizationUrl state = do
wrongRedirect <- curl [replaceRedirectUri authorizationUrl]
wrongRedirect <- get (probeClient context) (replaceRedirectUri authorizationUrl)
assert "provider accepted an unregistered redirect URI" $
curlStatus wrongRedirect >= 400
invalidState <- curl
[ "--cookie", cookies
, probeBase context
probeStatus wrongRedirect >= 400
invalidState <- getWith
(probeClient context)
defaultRequestOptions { optionCookies = Just cookies }
(probeBase context
<> "/oidc/callback?code=unused&state=invalid-"
<> state
]
<> state)
assert "application accepted an invalid OIDC state" $
curlStatus invalidState >= 400
probeStatus invalidState >= 400
transaction <- cookieValue cookies "nuthatch_oidc"
malformed <- curl
[ "--header", "Cookie: nuthatch_oidc=malformed"
, probeBase context
malformed <- getWith
(probeClient context)
defaultRequestOptions
{ optionHeaders = [("Cookie", "nuthatch_oidc=malformed")] }
(probeBase context
<> "/oidc/callback?code=unused&state="
<> state
]
assertEqual "malformed transaction cookie" 400 (curlStatus malformed)
<> state)
assertEqual "malformed transaction cookie" 400 (probeStatus malformed)
let tampered = BS.cons 120 (BS.drop 1 transaction)
tamperedResponse <- curl
[ "--header", "Cookie: nuthatch_oidc=" <> B8.unpack tampered
, probeBase context
tamperedResponse <- getWith
(probeClient context)
defaultRequestOptions
{ optionHeaders = [("Cookie", "nuthatch_oidc=" <> tampered)] }
(probeBase context
<> "/oidc/callback?code=unused&state="
<> state
]
assertEqual "tampered transaction cookie" 400 (curlStatus tamperedResponse)
<> state)
assertEqual "tampered transaction cookie" 400 (probeStatus tamperedResponse)
completeAuthorization
:: ProbeContext
-> FilePath
-> CookieSession
-> String
-> String
-> String
@ -230,25 +239,28 @@ completeAuthorization context idpCookies user suffix authorizationUrl =
completeKeycloak
:: ProbeContext
-> FilePath
-> CookieSession
-> String
-> String
-> IO String
completeKeycloak context idpCookies user authorizationUrl = do
form <- curl
[ "--cookie-jar", idpCookies
, authorizationUrl
]
assertEqual "Keycloak authorization form" 200 (curlStatus form)
action <- htmlAttribute "action" (B8.unpack (curlBody form))
submitted <- curl
[ "--cookie", idpCookies
, "--cookie-jar", idpCookies
, "--data-urlencode", "username=" <> user
, "--data-urlencode", "password=" <> probeOidcPassword context
, "--data-urlencode", "credentialId="
, action
]
form <- getWith
(probeClient context)
defaultRequestOptions { optionCookies = Just idpCookies }
authorizationUrl
assertEqual "Keycloak authorization form" 200 (probeStatus form)
action <- htmlAttribute "action" (B8.unpack (probeBody form))
submitted <- postWith
(probeClient context)
defaultRequestOptions
{ optionCookies = Just idpCookies
, optionBody = FormBody
[ ("username", B8.pack user)
, ("password", B8.pack (probeOidcPassword context))
, ("credentialId", "")
]
}
action
maybe
(fail "Keycloak login omitted callback Location")
(pure . B8.unpack)
@ -263,49 +275,57 @@ completeZitadel
completeZitadel context user _ authorizationUrl = do
pat <- maybe (fail "ZITADEL login PAT is missing") pure $
probeOidcLoginPat context
started <- curl [authorizationUrl]
started <- get (probeClient context) authorizationUrl
location <- maybe
(fail "ZITADEL login omitted Location")
(pure . B8.unpack)
(headerValue "location" started)
authRequest <- queryValue "authRequest" location
created <- curl
[ "--header", "Authorization: Bearer " <> pat
, "--header", "Content-Type: application/json"
, "--data"
, "{\"checks\":{\"user\":{\"userId\":\""
created <- postWith
(probeClient context)
defaultRequestOptions
{ optionHeaders =
[ ("Authorization", "Bearer " <> B8.pack pat)
, ("Content-Type", "application/json")
]
, optionBody = ByteBody . B8.pack $
"{\"checks\":{\"user\":{\"userId\":\""
<> user
<> "\"},\"password\":{\"password\":\""
<> probeOidcPassword context
<> "\"}},\"lifetime\":\"300s\"}"
, probeOidcIssuer context <> "/v2/sessions"
]
}
(probeOidcIssuer context <> "/v2/sessions")
assert "ZITADEL session creation failed" $
curlStatus created >= 200 && curlStatus created < 300
probeStatus created >= 200 && probeStatus created < 300
ZitadelSession {..} <- decodeBody "ZITADEL session" created
finalized <- curl
[ "--header", "Authorization: Bearer " <> pat
, "--header", "Content-Type: application/json"
, "--data"
, "{\"session\":{\"sessionId\":\""
finalized <- postWith
(probeClient context)
defaultRequestOptions
{ optionHeaders =
[ ("Authorization", "Bearer " <> B8.pack pat)
, ("Content-Type", "application/json")
]
, optionBody = ByteBody . B8.pack $
"{\"session\":{\"sessionId\":\""
<> zitadelSessionId
<> "\",\"sessionToken\":\""
<> zitadelSessionToken
<> "\"}}"
, probeOidcIssuer context
}
(probeOidcIssuer context
<> "/v2/oidc/auth_requests/"
<> authRequest
]
assertEqual "ZITADEL authorization finalization" 200 (curlStatus finalized)
<> authRequest)
assertEqual "ZITADEL authorization finalization" 200 (probeStatus finalized)
ZitadelCallback callback <- decodeBody "ZITADEL callback" finalized
pure callback
decodeBody :: FromJSON value => String -> CurlResponse -> IO value
decodeBody :: FromJSON value => String -> ProbeResponse -> IO value
decodeBody label response =
either (fail . ((label <> ": ") <>)) pure $
eitherDecodeStrict' (curlBody response)
eitherDecodeStrict' (probeBody response)
findCookie :: BS.ByteString -> CurlResponse -> BS.ByteString
findCookie :: BS.ByteString -> ProbeResponse -> BS.ByteString
findCookie name response =
fromMaybe BS.empty $
find (containsLower (lower name <> "=")) (headerValues "set-cookie" response)