nuthatch/test/Nuthatch/Probe/Oidc.hs

434 lines
14 KiB
Haskell
Raw Normal View History

module Nuthatch.Probe.Oidc
( ProbeContext (..)
, Session (..)
, login
, newProbeContext
, verifyPkceBoundary
) where
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, try)
import Data.Aeson
( FromJSON (..)
, eitherDecodeStrict'
, withObject
, (.:)
)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as B8
import Data.Char (toLower)
import Data.List (find)
import Data.Maybe (fromMaybe)
import Nuthatch.Probe.Assert
import Nuthatch.Probe.Http
import System.Environment (lookupEnv)
data ProbeContext = ProbeContext
{ probeClient :: ProbeClient
, probeBase :: String
, probeOidcIssuer :: String
, probeOidcProvider :: String
, probeOidcPassword :: String
, probeOidcLoginPat :: Maybe String
, probeOidcClientId :: String
, probeOidcClientSecret :: String
, probeOidcRedirectUri :: String
}
data Session = Session
{ sessionCookies :: CookieSession
, sessionCsrf :: BS.ByteString
}
data ZitadelSession = ZitadelSession
{ zitadelSessionId :: String
, zitadelSessionToken :: String
}
instance FromJSON ZitadelSession where
parseJSON = withObject "ZitadelSession" $ \object ->
ZitadelSession
<$> object .: "sessionId"
<*> object .: "sessionToken"
newtype ZitadelCallback = ZitadelCallback String
instance FromJSON ZitadelCallback where
parseJSON = withObject "ZitadelCallback" $ \object ->
ZitadelCallback <$> object .: "callbackUrl"
newtype OidcDiscovery = OidcDiscovery String
instance FromJSON OidcDiscovery where
parseJSON = withObject "OidcDiscovery" $ \object ->
OidcDiscovery <$> object .: "token_endpoint"
newProbeContext :: IO ProbeContext
newProbeContext = do
client <- newProbeClient
lane <- requireEnv "NUTHATCH_FIXTURE_LANE"
issuer <- requireEnv "NUTHATCH_OIDC_ISSUER"
provider <- requireEnv "NUTHATCH_OIDC_PROVIDER"
password <- requireEnv "NUTHATCH_OIDC_USER_PASSWORD"
loginPat <- optionalEnv "NUTHATCH_OIDC_LOGIN_PAT"
clientId <- requireEnv "NUTHATCH_OIDC_CLIENT_ID"
clientSecret <- requireEnv "NUTHATCH_OIDC_CLIENT_SECRET"
redirectUri <- requireEnv "NUTHATCH_OIDC_REDIRECT_URI"
pure ProbeContext
{ probeClient = client
, probeBase = "https://nuthatch-" <> lane <> ":8080"
, probeOidcIssuer = issuer
, probeOidcProvider = provider
, probeOidcPassword = password
, probeOidcLoginPat = loginPat
, probeOidcClientId = clientId
, probeOidcClientSecret = clientSecret
, probeOidcRedirectUri = redirectUri
}
login :: ProbeContext -> Bool -> String -> String -> IO Session
login context verifyBoundary user suffix = do
cookies <- newCookieSession
idpCookies <- newCookieSession
started <-
retryTransport 20 $
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
requireCookieAttributes
"OIDC transaction"
["secure", "httponly", "samesite=lax", "path=/oidc/callback"]
(findCookie "nuthatch_oidc" started)
state <- queryValue "state" authorizationUrl
if verifyBoundary
then verifyTransactionBoundary context cookies authorizationUrl state
else pure ()
callback <- completeAuthorization context idpCookies user suffix authorizationUrl
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)
requireCookieAttributes
"session"
["secure", "httponly", "samesite=lax", "path=/;"]
(findCookie "nuthatch_session" completed)
requireCookieAttributes
"CSRF"
["secure", "samesite=lax", "path=/;"]
(findCookie "nuthatch_csrf" completed)
assert "CSRF cookie must be readable by the browser" $
not ("httponly" `containsLower` findCookie "nuthatch_csrf" completed)
requireCookieAttributes
"expired OIDC transaction"
["max-age=0", "path=/oidc/callback;"]
(findCookie "nuthatch_oidc" completed)
let responseCookies = headerValues "set-cookie" completed
assert "application cookies must be host-only" $
all (not . containsLower "domain=") responseCookies
csrf <- cookieValue cookies "nuthatch_csrf"
pure Session
{ sessionCookies = cookies
, sessionCsrf = csrf
}
verifyPkceBoundary :: ProbeContext -> String -> IO ()
verifyPkceBoundary context user = do
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
callback <- completeAuthorization
context
idpCookies
user
"pkce"
authorizationUrl
code <- queryValue "code" callback
discoveryResponse <- get
(probeClient context)
(probeOidcIssuer context <> "/.well-known/openid-configuration")
assertEqual "OIDC discovery status" 200 (probeStatus discoveryResponse)
OidcDiscovery tokenEndpoint <-
decodeBody "OIDC discovery" discoveryResponse
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" $
probeStatus rejected >= 400
verifyTransactionBoundary
:: ProbeContext
-> CookieSession
-> String
-> String
-> IO ()
verifyTransactionBoundary context cookies authorizationUrl state = do
wrongRedirect <- get (probeClient context) (replaceRedirectUri authorizationUrl)
assert "provider accepted an unregistered redirect URI" $
probeStatus wrongRedirect >= 400
invalidState <- getWith
(probeClient context)
defaultRequestOptions { optionCookies = Just cookies }
(probeBase context
<> "/oidc/callback?code=unused&state=invalid-"
<> state)
assert "application accepted an invalid OIDC state" $
probeStatus invalidState >= 400
transaction <- cookieValue cookies "nuthatch_oidc"
malformed <- getWith
(probeClient context)
defaultRequestOptions
{ optionHeaders = [("Cookie", "nuthatch_oidc=malformed")] }
(probeBase context
<> "/oidc/callback?code=unused&state="
<> state)
assertEqual "malformed transaction cookie" 400 (probeStatus malformed)
let tampered = BS.cons 120 (BS.drop 1 transaction)
tamperedResponse <- getWith
(probeClient context)
defaultRequestOptions
{ optionHeaders = [("Cookie", "nuthatch_oidc=" <> tampered)] }
(probeBase context
<> "/oidc/callback?code=unused&state="
<> state)
assertEqual "tampered transaction cookie" 400 (probeStatus tamperedResponse)
completeAuthorization
:: ProbeContext
-> CookieSession
-> String
-> String
-> String
-> IO String
completeAuthorization context idpCookies user suffix authorizationUrl =
case probeOidcProvider context of
"keycloak" ->
completeKeycloak context idpCookies user authorizationUrl
"zitadel" ->
completeZitadel context user suffix authorizationUrl
provider ->
fail ("unsupported OIDC provider: " <> provider)
completeKeycloak
:: ProbeContext
-> CookieSession
-> String
-> String
-> IO String
completeKeycloak context idpCookies user authorizationUrl = do
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)
(headerValue "location" submitted)
completeZitadel
:: ProbeContext
-> String
-> String
-> String
-> IO String
completeZitadel context user _ authorizationUrl = do
pat <- maybe (fail "ZITADEL login PAT is missing") pure $
probeOidcLoginPat context
started <- get (probeClient context) authorizationUrl
location <- maybe
(fail "ZITADEL login omitted Location")
(pure . B8.unpack)
(headerValue "location" started)
authRequest <- queryValue "authRequest" location
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")
assert "ZITADEL session creation failed" $
probeStatus created >= 200 && probeStatus created < 300
ZitadelSession {..} <- decodeBody "ZITADEL session" created
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
<> "/v2/oidc/auth_requests/"
<> authRequest)
assertEqual "ZITADEL authorization finalization" 200 (probeStatus finalized)
ZitadelCallback callback <- decodeBody "ZITADEL callback" finalized
pure callback
decodeBody :: FromJSON value => String -> ProbeResponse -> IO value
decodeBody label response =
either (fail . ((label <> ": ") <>)) pure $
eitherDecodeStrict' (probeBody response)
findCookie :: BS.ByteString -> ProbeResponse -> BS.ByteString
findCookie name response =
fromMaybe BS.empty $
find (containsLower (lower name <> "=")) (headerValues "set-cookie" response)
requireCookieAttributes
:: String
-> [BS.ByteString]
-> BS.ByteString
-> IO ()
requireCookieAttributes label attributes cookie = do
assert (label <> " cookie is missing") (not (BS.null cookie))
mapM_
(\attribute ->
assert (label <> " cookie omitted " <> B8.unpack attribute) $
attribute `containsLower` cookie)
attributes
containsLower :: BS.ByteString -> BS.ByteString -> Bool
containsLower needle haystack =
lower needle `BS.isInfixOf` lower haystack
lower :: BS.ByteString -> BS.ByteString
lower = B8.map toLower
queryValue :: String -> String -> IO String
queryValue name url =
case drop 1 (dropWhile (/= '?') url) of
"" -> fail ("URL has no query: " <> url)
query ->
case find ((== name) . fst) (map splitPair (splitOn '&' query)) of
Just (_, value) | not (null value) -> pure value
_ -> fail ("URL query omitted " <> name)
splitPair :: String -> (String, String)
splitPair value =
case break (== '=') value of
(name, '=' : rest) -> (name, rest)
(name, _) -> (name, "")
splitOn :: Char -> String -> [String]
splitOn delimiter value =
case break (== delimiter) value of
(prefix, []) -> [prefix]
(prefix, _ : suffix) -> prefix : splitOn delimiter suffix
replaceRedirectUri :: String -> String
replaceRedirectUri url =
let marker = "redirect_uri="
(prefix, rest) = breakOn marker url
in case rest of
"" -> url
_ ->
let afterName = drop (length marker) rest
suffix = dropWhile (/= '&') afterName
in prefix
<> marker
<> "https%3A%2F%2Finvalid.example%2Fcallback"
<> suffix
htmlAttribute :: String -> String -> IO String
htmlAttribute name html =
let marker = name <> "=\""
(_, rest) = breakOn marker html
in case rest of
"" -> fail ("HTML omitted " <> name)
_ ->
pure
( replaceAll "&amp;" "&"
$ takeWhile (/= '"')
$ drop (length marker) rest
)
breakOn :: String -> String -> (String, String)
breakOn needle = go []
where
go prefix remaining
| needle `isPrefixOf` remaining = (reverse prefix, remaining)
go prefix [] = (reverse prefix, "")
go prefix (next : remaining) = go (next : prefix) remaining
isPrefixOf :: String -> String -> Bool
isPrefixOf prefix value =
take (length prefix) value == prefix
replaceAll :: String -> String -> String -> String
replaceAll needle replacement value =
case breakOn needle value of
(prefix, "") -> prefix
(prefix, rest) ->
prefix
<> replacement
<> replaceAll needle replacement (drop (length needle) rest)
optionalEnv :: String -> IO (Maybe String)
optionalEnv = lookupEnv
retryTransport :: Int -> IO value -> IO value
retryTransport attempts action = do
result <- try action
case result of
Right value -> pure value
Left exception
| attempts > 1 -> do
threadDelay 100000
retryTransport (attempts - 1) action
| otherwise -> fail (show (exception :: SomeException))