Implement confidential OIDC authentication boundary

This commit is contained in:
ms2k 2026-07-18 18:08:41 +09:00
commit a4e615096c

View file

@ -1,14 +1,37 @@
module Main (main) where
import Control.Monad.IO.Class (liftIO)
import Control.Lens ((&), (?~))
import Crypto.JOSE (runJOSE)
import Crypto.JOSE.Compact (encodeCompact)
import Crypto.JOSE.JWA.JWS (Alg (RS256, RS384))
import Crypto.JOSE.JWK
( JWK
, JWKSet (..)
, KeyMaterialGenParam (RSAGenParam)
, genJWK
)
import Crypto.JOSE.JWS (newJWSHeaderProtected)
import Crypto.JWT
( Audience (..)
, ClaimsSet
, HasClaimsSet (..)
, JWTError
, NumericDate (..)
, emptyClaimsSet
, signJWT
)
import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.Either (isLeft)
import Data.Either (isLeft, isRight)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
import Hedgehog
( Gen
, Property
@ -24,6 +47,7 @@ import Network.HTTP.Types
, methodGet
, methodPost
, status200
, status303
, status400
, status404
, status405
@ -43,7 +67,8 @@ import Nuthatch.App
, loginResponse
, utf8Boundary
)
import Nuthatch.Config (AuthMode (..), Config (..))
import Nuthatch.Config (Config (..))
import Nuthatch.Oidc (OidcIdentity (..), verifyIdToken)
import Nuthatch.Paths
( buildObjectKey
, encodeUserId
@ -62,7 +87,7 @@ import Nuthatch.Protocol
)
import Nuthatch.Types
import Nuthatch.Session (mintSession)
import Nuthatch.Ui (browsePage, browserScript, loginPage)
import Nuthatch.Ui (browsePage, browserScript)
import Servant
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
import Servant.Types.SourceT (source)
@ -78,6 +103,7 @@ tests = testGroup "nuthatch-unit"
[ pathTests
, identityTests
, protocolTests
, oidcTests
, apiTests
]
@ -127,6 +153,222 @@ protocolTests = testGroup "mutation protocol decisions"
, testProperty "only stale active records are recovery eligible" propRecoveryTiming
]
oidcTests :: TestTree
oidcTests = testGroup "OIDC validation"
[ testCase "accepts an exact RS256 ID token" testValidIdToken
, testCase "accepts an exact configured audience set" testConfiguredAudiences
, testCase "rejects invalid nonce, algorithm, issuer, signature, and audience"
testIdTokenCryptographicRejections
, testCase "rejects malformed, missing-time, future, expired, and overlong tokens"
testIdTokenTimeAndSerializationRejections
]
data TestIdTokenClaims = TestIdTokenClaims
{ testIdTokenRegistered :: ClaimsSet
, testIdTokenNonce :: Text
, testIdTokenAuthorizedParty :: Maybe Text
, testIdTokenName :: Maybe Text
}
deriving stock (Eq, Show)
instance HasClaimsSet TestIdTokenClaims where
claimsSet update claims =
fmap
(\registered -> claims { testIdTokenRegistered = registered })
(update (testIdTokenRegistered claims))
instance A.FromJSON TestIdTokenClaims where
parseJSON value = A.withObject "test ID token" (\object ->
TestIdTokenClaims
<$> A.parseJSON value
<*> object A..: "nonce"
<*> object A..:? "azp"
<*> object A..:? "name") value
instance A.ToJSON TestIdTokenClaims where
toJSON claims =
case A.toJSON (testIdTokenRegistered claims) of
A.Object object ->
let withName =
maybe
object
(\name -> KM.insert "name" (A.String name) object)
(testIdTokenName claims)
withAuthorizedParty =
maybe
withName
(\authorizedParty ->
KM.insert "azp" (A.String authorizedParty) withName)
(testIdTokenAuthorizedParty claims)
in A.Object $
KM.insert
"nonce"
(A.String (testIdTokenNonce claims))
withAuthorizedParty
value -> value
testValidIdToken :: IO ()
testValidIdToken = do
key <- genJWK (RSAGenParam 256)
now <- getCurrentTime
compact <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
result <- verifyIdToken
testConfig
(JWKSet [key])
now
"nonce"
compact
result @?= Right OidcIdentity
{ identityIssuer = "https://issuer.example"
, identitySubject = "upstream-subject"
, identityDisplay = Just "Example User"
}
testConfiguredAudiences :: IO ()
testConfiguredAudiences = do
key <- genJWK (RSAGenParam 256)
now <- getCurrentTime
compact <- signTestIdToken key RS256
((testIdClaims now "https://issuer.example" ["project", "nuthatch"] "nonce")
{ testIdTokenAuthorizedParty = Just "nuthatch" })
missingAuthorizedParty <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["project", "nuthatch"] "nonce")
result <- verifyIdToken
testConfig { cfgOidcAdditionalAudiences = ["project"] }
(JWKSet [key])
now
"nonce"
compact
rejected <- verifyIdToken
testConfig { cfgOidcAdditionalAudiences = ["project"] }
(JWKSet [key])
now
"nonce"
missingAuthorizedParty
assertBool "the configured audience set is accepted" (isRight result)
assertBool "a multi-audience token without azp is rejected" (isLeft rejected)
testIdTokenCryptographicRejections :: IO ()
testIdTokenCryptographicRejections = do
key <- genJWK (RSAGenParam 256)
otherKey <- genJWK (RSAGenParam 256)
now <- getCurrentTime
valid <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
wrongAlgorithm <- signTestIdToken key RS384
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
wrongIssuer <- signTestIdToken key RS256
(testIdClaims now "https://other.example" ["nuthatch"] "nonce")
wrongSignature <- signTestIdToken otherKey RS256
(testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
wrongAudience <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["other"] "nonce")
multipleAudience <- signTestIdToken key RS256
(testIdClaims now "https://issuer.example" ["nuthatch", "other"] "nonce")
let rejected =
[ verifyIdToken testConfig (JWKSet [key]) now "other-nonce" valid
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongAlgorithm
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongIssuer
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongSignature
, verifyIdToken testConfig (JWKSet [key]) now "nonce" wrongAudience
, verifyIdToken testConfig (JWKSet [key]) now "nonce" multipleAudience
]
results <- sequence rejected
assertBool "every invalid cryptographic or binding case is rejected"
(all isLeft results)
testIdTokenTimeAndSerializationRejections :: IO ()
testIdTokenTimeAndSerializationRejections = do
key <- genJWK (RSAGenParam 256)
now <- getCurrentTime
missingIssuedAt <- signTestIdToken key RS256
((testIdClaims now "https://issuer.example" ["nuthatch"] "nonce")
{ testIdTokenRegistered =
emptyClaimsSet
& claimIss ?~ "https://issuer.example"
& claimSub ?~ "upstream-subject"
& claimAud ?~ Audience ["nuthatch"]
& claimExp ?~ NumericDate (addUTCTime 300 now)
})
future <- signTestIdToken key RS256
(testIdClaimsAt
(addUTCTime 60 now)
(addUTCTime 300 now)
"https://issuer.example"
["nuthatch"]
"nonce")
expired <- signTestIdToken key RS256
(testIdClaimsAt
(addUTCTime (-600) now)
(addUTCTime (-300) now)
"https://issuer.example"
["nuthatch"]
"nonce")
overlong <- signTestIdToken key RS256
(testIdClaimsAt
now
(addUTCTime 7200 now)
"https://issuer.example"
["nuthatch"]
"nonce")
results <- sequence
[ verifyIdToken testConfig (JWKSet [key]) now "nonce" "not.compact"
, verifyIdToken testConfig (JWKSet [key]) now "nonce" missingIssuedAt
, verifyIdToken testConfig (JWKSet [key]) now "nonce" future
, verifyIdToken testConfig (JWKSet [key]) now "nonce" expired
, verifyIdToken testConfig (JWKSet [key]) now "nonce" overlong
]
assertBool "every invalid serialization or time case is rejected"
(all isLeft results)
testIdClaims
:: UTCTime
-> Text
-> [Text]
-> Text
-> TestIdTokenClaims
testIdClaims now issuer audiences nonce =
testIdClaimsAt
now
(addUTCTime 300 now)
issuer
audiences
nonce
testIdClaimsAt
:: UTCTime
-> UTCTime
-> Text
-> [Text]
-> Text
-> TestIdTokenClaims
testIdClaimsAt issuedAt expiresAt issuer audiences nonce =
TestIdTokenClaims
{ testIdTokenRegistered =
emptyClaimsSet
& claimIss ?~ fromString (T.unpack issuer)
& claimSub ?~ "upstream-subject"
& claimAud ?~ Audience (map (fromString . T.unpack) audiences)
& claimIat ?~ NumericDate issuedAt
& claimExp ?~ NumericDate expiresAt
, testIdTokenNonce = nonce
, testIdTokenAuthorizedParty = Nothing
, testIdTokenName = Just "Example User"
}
signTestIdToken
:: JWK
-> Alg
-> TestIdTokenClaims
-> IO Text
signTestIdToken key algorithm claims = do
result <- runJOSE $
encodeCompact <$> signJWT key (newJWSHeaderProtected algorithm) claims
case result of
Left (err :: JWTError) -> fail (show err)
Right compact -> pure (TE.decodeUtf8 (BL.toStrict compact))
apiTests :: TestTree
apiTests = testGroup "typed HTTP boundary"
[ testCase "Servant selects routes and methods" testRouteSelection
@ -140,18 +382,18 @@ apiTests = testGroup "typed HTTP boundary"
, testCase "operation-specific JSON requests and responses remain distinct"
testOperationRepresentations
, testCase "operation-specific errors have closed distinct representations" $ do
let loginError = A.encode InvalidUsername
let loginError = A.encode OidcCallbackRejected
deleteError = A.encode (DeleteMutationRejected "locked")
prefixError = A.encode (DeletePrefixMutationRejected "partial")
assertBool "login errors carry the login-specific code"
("invalid_username" `BS.isInfixOf` BL.toStrict loginError)
("oidc_callback_rejected" `BS.isInfixOf` BL.toStrict loginError)
assertBool "delete errors carry the mutation-specific code"
("mutation_rejected" `BS.isInfixOf` BL.toStrict deleteError)
assertBool "operation errors must not share one representation"
(loginError /= deleteError && deleteError /= prefixError)
assertBool "prefix errors carry their operation-specific code"
("prefix_mutation_rejected" `BS.isInfixOf` BL.toStrict prefixError)
, testCase "production authentication validates cookies and login emits both cookies"
, testCase "production authentication validates cookies and callback emits secure cookies"
testAuthenticationBoundary
, testCase "retained browser forms are wired to the typed API bridge"
testBrowserBridge
@ -254,27 +496,16 @@ testStreamingEndpoints = do
testOperationRepresentations :: IO ()
testOperationRepresentations = do
calls <- newIORef 0
login <- apiRequest calls methodPost
"/login"
[ ("Content-Type", "application/json")
, ("Accept", "application/json")
]
"{\"username\":\"alice\"}"
simpleStatus login @?= status200
assertBool "login response must carry a user identity"
("user_id" `BS.isInfixOf` BL.toStrict (simpleBody login))
formLogin <- apiRequest calls methodPost
"/login"
[("Content-Type", "application/x-www-form-urlencoded")]
"username=bob"
simpleStatus formLogin @?= status200
assertBool "browser login response navigates into the UI"
("continue" `BS.isInfixOf` BL.toStrict (simpleBody formLogin))
invalidLogin <- apiRequest calls methodPost
"/login"
[("Content-Type", "application/json")]
"{\"username\":\"two words\"}"
simpleStatus invalidLogin @?= status400
login <- apiRequest calls methodGet "/login" [] ""
simpleStatus login @?= status303
lookup "Location" (simpleHeaders login) @?= Just "https://issuer.example/authorize"
callback <- apiRequest calls methodGet
"/oidc/callback?code=code&state=state"
[("Cookie", "nuthatch_oidc=transaction")]
""
simpleStatus callback @?= status303
legacyLogin <- apiRequest calls methodPost "/login" [] ""
simpleStatus legacyLogin @?= status405
deletion <- apiRequest calls methodPost
"/delete"
[("Content-Type", "application/json")]
@ -350,18 +581,19 @@ typedTestServer :: IORef Int -> Server Api
typedTestServer calls =
counted (HealthResponse True)
:<|> counted (ReadyResponse True)
:<|> counted (HtmlPage "login")
:<|> counted (BrowserScript "script")
:<|> (\_ -> counted
:<|> counted
(addHeader "transaction=cookie"
(addHeader "https://issuer.example/authorize" NoContent))
:<|> (\_ _ _ -> counted
(addHeader "session=cookie"
(addHeader "csrf=cookie"
(addHeader (CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
(LoginView
(HtmlPage "continue")
(LoginResponse (UserId "user")))))))
(addHeader "transaction=expired"
(addHeader "/" NoContent)))))
:<|> (\_ _ -> counted
(addHeader "session=expired"
(addHeader "csrf=expired" NoContent)))
(addHeader "csrf=expired"
(addHeader "transaction=expired" NoContent))))
:<|> (\_ _ _ _ _ -> counted
(BrowseView
(HtmlPage "browse")
@ -392,17 +624,28 @@ testAuthenticationBoundary = do
token <- either (fail . T.unpack) pure tokenResult
let csrf = CsrfToken "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
responseHeaders = getHeaders
(loginResponse testConfig (UserId "user") token csrf)
(loginResponse testConfig token csrf)
cookieHeaders =
[ value
| (name, value) <- responseHeaders
, name == "Set-Cookie"
]
length cookieHeaders @?= 2
length cookieHeaders @?= 3
assertBool "login sets the session cookie"
(any ("nuthatch_session=" `BS.isPrefixOf`) cookieHeaders)
assertBool "login sets the CSRF cookie"
(any ("nuthatch_csrf=" `BS.isPrefixOf`) cookieHeaders)
assertBool "cookies are secure and same-site"
(all ("Secure" `BS.isInfixOf`) cookieHeaders
&& all ("SameSite=Lax" `BS.isInfixOf`) cookieHeaders)
assertBool "cookies are host-only"
(all (not . ("Domain=" `BS.isInfixOf`)) cookieHeaders)
assertBool "session and CSRF cookies use the root path"
(length (filter ("Path=/;" `BS.isInfixOf`) cookieHeaders) == 2)
assertBool "the transaction cookie uses the callback path"
(any ("Path=/oidc/callback;" `BS.isInfixOf`) cookieHeaders)
assertBool "session and transaction cookies are HTTP-only"
(length (filter ("HttpOnly" `BS.isInfixOf`) cookieHeaders) == 2)
calls <- newIORef 0
let app = productionAuthTestApplication testConfig calls
@ -425,6 +668,26 @@ testAuthenticationBoundary = do
simpleStatus malformed @?= status400
readIORef calls >>= (@?= 0)
malformedGrammar <- apiRequestWith app methodGet
"/?root=personal"
[("Cookie", "nuthatch_session")]
""
simpleStatus malformedGrammar @?= status400
readIORef calls >>= (@?= 0)
let tamperedToken =
case T.unsnoc (unSessionToken token) of
Just (prefix, final)
| final == 'a' -> prefix <> "b"
| otherwise -> prefix <> "a"
Nothing -> "invalid"
tampered <- apiRequestWith app methodGet
"/?root=personal"
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 tamperedToken)]
""
simpleStatus tampered @?= status303
readIORef calls >>= (@?= 0)
missingCsrf <- apiRequestWith app methodGet
"/?root=personal"
[("Cookie", "nuthatch_session=" <> TE.encodeUtf8 (unSessionToken token))]
@ -457,8 +720,6 @@ testBrowserBridge = do
[entry]
Nothing
""
assertBool "login page loads the external bridge"
("/nuthatch.js" `T.isInfixOf` loginPage "")
mapM_
(\marker -> assertBool ("browse page includes " <> T.unpack marker)
(marker `T.isInfixOf` browse))
@ -486,11 +747,21 @@ testConfig :: Config
testConfig = Config
{ cfgListenHost = "127.0.0.1"
, cfgListenPort = 8080
, cfgTlsCertPath = "/tls/tls.crt"
, cfgTlsKeyPath = "/tls/tls.key"
, cfgPublicBaseUrl = "https://nuthatch.example"
, cfgJwtSecret = "unit-test-secret-material"
, cfgJwtSecret = "unit-test-session-secret-material-32"
, cfgSessionHours = 1
, cfgCookieName = "nuthatch_session"
, cfgCsrfCookieName = "nuthatch_csrf"
, cfgOidcTransactionCookieName = "nuthatch_oidc"
, cfgOidcIssuer = "https://issuer.example"
, cfgOidcClientId = "nuthatch"
, cfgOidcAdditionalAudiences = []
, cfgOidcClientSecret = "unit-test-client-secret"
, cfgOidcRedirectUri = "https://nuthatch.example/oidc/callback"
, cfgOidcTransactionMinutes = 5
, cfgOidcMaxIdTokenSeconds = 3600
, cfgPgConn = ""
, cfgRedisHost = ""
, cfgRedisPort = 6379
@ -509,7 +780,6 @@ testConfig = Config
, cfgListCacheTtlSec = 30
, cfgSearchMaxKeys = 100
, cfgRecoveryGraceSec = 30
, cfgAuthMode = AuthDev
}
newtype ValidPath = ValidPath { getValidPath :: Text }