nuthatch/test/Nuthatch/Test/Oidc.hs

254 lines
8.4 KiB
Haskell
Raw Normal View History

module Nuthatch.Test.Oidc
( tests
) where
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.Lazy as BL
import Data.Either (isLeft, isRight)
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 Nuthatch.Config (Config (..))
import Nuthatch.Oidc (OidcIdentity (..), verifyIdToken)
import Nuthatch.Test.Config (testConfig)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit ((@?=), assertBool, testCase)
tests :: TestTree
tests = 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))