packages feed

shomei-jwt-0.2.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 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,
    SignedJWT,
    StringOrURI,
    addClaim,
    claimAud,
    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 Shomei.Authorization.Claims.Domain (AuthClaims (..), mkExtraClaims)
import Shomei.Error (TokenError (..))
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)
import Shomei.SigningKey.TestSupport (mkClaims, mkClaimsWith, publicJwks, testConfig)
import Shomei.SigningKey.Verify.Jwt (verifyToken)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Verify"
    [ 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 "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)