packages feed

shomei-jwt-0.3.0.0: test/Shomei/SigningKey/Verify/JwtSpec.hs

{-# OPTIONS_GHC -Wno-deprecations #-}

-- | Regression tests for the verifier's trust boundary.
module Shomei.SigningKey.Verify.JwtSpec (tests) where

import Control.Lens ((%~), (&), (.~), (?~), (^.))
import Control.Monad (forM_)
import Crypto.JOSE.Compact (encodeCompact)
import Crypto.JOSE.Error (runJOSE)
import Crypto.JOSE.Header (newHeaderParamProtected)
import Crypto.JOSE.JWA.JWS (Alg (ES256, HS256, RS256))
import Crypto.JOSE.JWK (JWK, asPublicKey, fromOctets)
import Crypto.JOSE.JWS (newJWSHeaderProtected)
import Crypto.JOSE.JWS qualified as JWS
import Crypto.JWT
  ( Audience (Audience),
    ClaimsSet,
    JWTError,
    NumericDate (..),
    SignedJWT,
    StringOrURI,
    addClaim,
    claimAud,
    claimNbf,
    signClaims,
    unregisteredClaims,
  )
import Data.Aeson (Object, Result (Error, Success), Value (Number, String))
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteArray.Encoding (Base (Base64URLUnpadded), convertFromBase, convertToBase)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as BSL
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.String (fromString)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Time (addUTCTime, getCurrentTime)
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
import Shomei.Authorization.Claims.Domain (AuthClaims (..), mkExtraClaims)
import Shomei.Config (ShomeiConfig (..), SigningKeyConfig (..))
import Shomei.Error (TokenError (..))
import Shomei.Id (genUserId, idText)
import Shomei.Session.Token.Domain (AccessToken (AccessToken))
import Shomei.SigningKey.Domain qualified as Domain
import Shomei.SigningKey.Key.Jwt (generateSigningKey, generateSigningKeyFor, keyKid)
import Shomei.SigningKey.Sign.Jwt (claimsFromAuth, signAccessToken, signAccessTokenWith)
import Shomei.SigningKey.TestSupport (mkClaims, mkClaimsWith, publicJwks, testConfig)
import Shomei.SigningKey.Verify.Jwt (VerifierSettings (..), verifierSettingsFromConfig, verifyToken, verifyTokenWith)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Verify"
    [ testCase "actor object emission and bounded legacy compatibility preserve delegation" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        base <- mkClaims testConfig now
        uid <- genUserId
        let claims = base {actor = Just uid}
            deadline = ceiling (utcTimeToPOSIXSeconds (addUTCTime 7200 now))
            legacyCfg = testConfig {signingKeyConfig = testConfig.signingKeyConfig {legacyActorUntilUnix = Just deadline, emitLegacyActor = True}}
            expiredCfg = legacyCfg {signingKeyConfig = legacyCfg.signingKeyConfig {legacyActorUntilUnix = Just (deadline - 7300)}}
            actorObject = Aeson.object ["sub" Aeson..= idText uid]
        objectWire <- signAccessOrFail jwk claims
        payload <- decodeSegment 1 objectWire
        KeyMap.lookup "act" payload @?= Just actorObject
        legacyToken <- either (assertFailure . show) pure =<< signAccessTokenWith legacyCfg jwk claims
        let AccessToken legacyWire = legacyToken
        legacyPayload <- decodeSegment 1 legacyWire
        KeyMap.lookup "act" legacyPayload @?= Just (String (idText uid))
        forM_ [objectWire, legacyWire] \wire -> do
          verified <- verifyToken (publicJwks jwk []) legacyCfg wire
          case verified of
            Right ac -> ac.actor @?= Just uid
            Left err -> assertFailure (show err)
        verifyToken (publicJwks jwk []) testConfig legacyWire >>= (@?= Left TokenMalformed)
        verifyToken (publicJwks jwk []) expiredCfg legacyWire >>= (@?= Left TokenMalformed)
        AccessToken automatic <- either (assertFailure . show) pure =<< signAccessTokenWith expiredCfg jwk claims
        automaticPayload <- decodeSegment 1 automatic
        KeyMap.lookup "act" automaticPayload @?= Just actorObject
        let tooShort = legacyCfg {signingKeyConfig = legacyCfg.signingKeyConfig {legacyActorUntilUnix = Just (deadline - 7140)}}
        AccessToken bounded <- either (assertFailure . show) pure =<< signAccessTokenWith tooShort jwk claims
        boundedPayload <- decodeSegment 1 bounded
        KeyMap.lookup "act" boundedPayload @?= Just actorObject,
      testCase "accepted lifetime is positive and finite independently of clock skew" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        let settings = (verifierSettingsFromConfig testConfig) {maxTokenLifetime = 60, allowedClockSkew = 3600}
        forM_ [0, -1, 61] \ttl -> do
          claims <- mkClaimsWith testConfig now (addUTCTime ttl now)
          wire <- signAccessOrFail jwk claims
          verifyTokenWith settings (publicJwks jwk []) wire >>= (@?= Left TokenMalformed)
        claims <- mkClaimsWith testConfig now (addUTCTime 60 now)
        wire <- signAccessOrFail jwk claims
        result <- verifyTokenWith settings (publicJwks jwk []) wire
        case result of
          Right _ -> pure ()
          Left err -> assertFailure (show err)
        verifyTokenWith settings {maxTokenLifetime = 0} (publicJwks jwk []) wire >>= (@?= Left TokenMalformed),
      testCase "a direct extension bag cannot inject actor or jti" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        base <- mkClaims testConfig now
        uid <- genUserId
        let claims = base {extraClaims = KeyMap.fromList [("act", Aeson.object ["sub" Aeson..= idText uid]), ("jti", String "forged")]}
        wire <- signAccessOrFail jwk claims
        payload <- decodeSegment 1 wire
        KeyMap.lookup "act" payload @?= Nothing
        KeyMap.lookup "jti" payload @?= Nothing
        assertBool "sid remains the correlation identifier" (KeyMap.member "sid" payload),
      testCase "a present malformed actor never verifies as an ordinary token" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        forM_ [Aeson.object ["sub" Aeson..= String "invalid"], Aeson.toJSON (["actor"] :: [Text]), Number 1, Aeson.Null, String ""] \actorValue -> do
          wire <- signClaimsOrFail jwk (addClaim "act" actorValue (claimsFromAuth claims))
          result <- verifyToken (publicJwks jwk []) testConfig wire
          result @?= Left TokenMalformed,
      testCase "accepts an iat within the configured 30-second skew" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaimsWith testConfig (addUTCTime 10 now) (addUTCTime 3600 now)
        wire <- signAccessOrFail jwk claims
        result <- verifyToken (publicJwks jwk []) testConfig wire
        case result of
          Right _ -> pure ()
          Left err -> assertFailure ("expected the token to verify, got " <> show err),
      testCase "rejects an iat beyond the configured skew" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaimsWith testConfig (addUTCTime 120 now) (addUTCTime 3600 now)
        wire <- signAccessOrFail jwk claims
        result <- verifyToken (publicJwks jwk []) testConfig wire
        result @?= Left (TokenOtherError "iat in the future"),
      testCase "rejects a string-valued roles claim as malformed" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        wire <- signClaimsOrFail jwk (claimsFromAuth claims & addClaim "roles" (String "admin"))
        result <- verifyToken (publicJwks jwk []) testConfig wire
        result @?= Left TokenMalformed,
      testCase "rejects a multi-element audience even when one value matches" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        let audienceValues = [sou "shomei-clients", sou "other"]
        wire <- signClaimsOrFail jwk (claimsFromAuth claims & claimAud ?~ Audience audienceValues)
        result <- verifyToken (publicJwks jwk []) testConfig wire
        result @?= Left TokenAudienceInvalid,
      testCase "mints integral iat and exp values" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        wire <- signAccessOrFail jwk claims
        payload <- decodeSegment 1 wire
        assertIntegralNumber "iat" payload
        assertIntegralNumber "exp" payload
        assertIntegralNumber "auth_time" payload,
      testCase "a legacy access token without auth_time falls back to iat" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        let legacyClaims = claimsFromAuth claims & unregisteredClaims %~ Map.delete "auth_time"
        wire <- signClaimsOrFail jwk legacyClaims
        result <- verifyToken (publicJwks jwk []) testConfig wire
        case result of
          Right verified -> verified.authTime @?= verified.issuedAt
          Left err -> assertFailure ("expected the legacy token to verify, got " <> show err),
      testCase "signed future nbf is rejected while the configured skew is honored" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        let wireAt offset = signClaimsWithHeaderOrFail jwk ES256 (Just (keyKid jwk)) (Just "at+jwt") (claimsFromAuth claims & claimNbf ?~ NumericDate (addUTCTime offset now))
        future <- wireAt 120
        verifyToken (publicJwks jwk []) testConfig future >>= (@?= Left (TokenOtherError "token not yet valid"))
        withinSkew <- wireAt 10
        accepted <- verifyToken (publicJwks jwk []) testConfig withinSkew
        case accepted of
          Right _ -> pure ()
          Left _ -> assertFailure "nbf inside configured skew was rejected",
      testCase "drops nbf and jti from the extension claim bag" $ do
        let extras = mkExtraClaims (KeyMap.fromList [("nbf", Number 1), ("jti", String "forged")])
        assertBool "nbf must be reserved" (not (KeyMap.member "nbf" extras))
        assertBool "jti must be reserved" (not (KeyMap.member "jti" extras)),
      testCase "reports an unknown kid without trying other keys" $ do
        signingKey <- generateSigningKey
        publishedKey <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        wire <- signAccessOrFail signingKey claims
        result <- verifyToken (publicJwks publishedKey []) testConfig wire
        result @?= Left (TokenKeyNotFound (Just (keyKid signingKey))),
      testCase "reports a missing kid even when the signature key is published" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        wire <- signClaimsWithHeaderOrFail jwk ES256 Nothing (Just "at+jwt") (claimsFromAuth claims)
        result <- verifyToken (publicJwks jwk []) testConfig wire
        result @?= Left (TokenKeyNotFound Nothing),
      testCase "rejects typ JWT on an access token" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        wire <- signClaimsWithHeaderOrFail jwk ES256 (Just (keyKid jwk)) (Just "JWT") (claimsFromAuth claims)
        result <- verifyToken (publicJwks jwk []) testConfig wire
        result @?= Left (TokenOtherError "typ JWT is not at+jwt"),
      testCase "temporarily accepts an access token with no typ" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        wire <- signClaimsWithHeaderOrFail jwk ES256 (Just (keyKid jwk)) Nothing (claimsFromAuth claims)
        result <- verifyToken (publicJwks jwk []) testConfig wire
        case result of
          Right _ -> pure ()
          Left err -> assertFailure ("expected the typ-less compatibility token to verify, got " <> show err),
      testCase "mints access-token typ at+jwt" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        wire <- signAccessOrFail jwk claims
        header <- decodeSegment 0 wire
        KeyMap.lookup "typ" header @?= Just (String "at+jwt"),
      testCase "rejects alg none" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        realWire <- signAccessOrFail jwk claims
        let payload = Text.splitOn "." realWire !! 1
            header = Aeson.object ["alg" Aeson..= String "none", "kid" Aeson..= String (keyKid jwk)]
            headerSegment = Text.decodeUtf8 (convertToBase Base64URLUnpadded (BSL.toStrict (Aeson.encode header)) :: ByteString)
            wire = Text.intercalate "." [headerSegment, payload, ""]
        result <- verifyToken (publicJwks jwk []) testConfig wire
        result @?= Left TokenSignatureInvalid,
      testCase "rejects HS256 signed with public-key bytes" $ do
        jwk <- generateSigningKey
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        let publicKey = fromMaybe jwk (jwk ^. asPublicKey)
            hmacKey = fromOctets (BSL.toStrict (Aeson.encode publicKey))
        wire <- signClaimsWithHeaderOrFail hmacKey HS256 (Just (keyKid jwk)) (Just "at+jwt") (claimsFromAuth claims)
        result <- verifyToken (publicJwks jwk []) testConfig wire
        result @?= Left TokenSignatureInvalid,
      testCase "rejects RS256 under an EC kid" $ do
        ecKey <- generateSigningKey
        rsaKey <- generateSigningKeyFor Domain.RS256
        now <- getCurrentTime
        claims <- mkClaims testConfig now
        wire <- signClaimsWithHeaderOrFail rsaKey RS256 (Just (keyKid ecKey)) (Just "at+jwt") (claimsFromAuth claims)
        result <- verifyToken (publicJwks ecKey []) testConfig wire
        result @?= Left TokenSignatureInvalid
    ]

sou :: Text -> StringOrURI
sou = fromString . Text.unpack

signAccessOrFail :: JWK -> AuthClaims -> IO Text
signAccessOrFail jwk claims = do
  result <- signAccessToken jwk claims
  case result of
    Left err -> assertFailure ("signing failed: " <> show err)
    Right (AccessToken wire) -> pure wire

signClaimsOrFail :: JWK -> ClaimsSet -> IO Text
signClaimsOrFail jwk = signClaimsWithHeaderOrFail jwk ES256 (Just (keyKid jwk)) Nothing

signClaimsWithHeaderOrFail :: JWK -> Alg -> Maybe Text -> Maybe Text -> ClaimsSet -> IO Text
signClaimsWithHeaderOrFail jwk algorithm headerKid headerType claims = do
  let header =
        newJWSHeaderProtected algorithm
          & JWS.kid
            .~ fmap newHeaderParamProtected headerKid
          & JWS.typ
            .~ fmap newHeaderParamProtected headerType
  result <- runJOSE @JWTError do
    signed <- signClaims jwk header claims
    pure (encodeCompact (signed :: SignedJWT))
  case result of
    Left err -> assertFailure ("signing claims failed: " <> show err)
    Right wire -> pure (Text.decodeUtf8 (BSL.toStrict wire))

decodeSegment :: Int -> Text -> IO Object
decodeSegment index wire = do
  let segment = Text.encodeUtf8 (Text.splitOn "." wire !! index)
  raw <-
    either (assertFailure . ("segment base64url decode failed: " <>)) pure $
      (convertFromBase Base64URLUnpadded segment :: Either String ByteString)
  maybe (assertFailure "segment is not a JSON object") pure (Aeson.decodeStrict raw)

assertIntegralNumber :: Text -> Object -> IO ()
assertIntegralNumber name payload = case KeyMap.lookup (fromString (Text.unpack name)) payload of
  Just value@(Number _) -> case Aeson.fromJSON value :: Result Integer of
    Success _ -> pure ()
    Error err -> assertFailure (Text.unpack name <> " is not integral: " <> err)
  other -> assertFailure (Text.unpack name <> " must be a JSON number, got " <> show other)