shomei-jwt 0.2.0.0 → 0.3.0.0
raw patch · 7 files changed
+218/−75 lines, 7 filesdep ~shomei-coredep ~shomei-jwtPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: shomei-core, shomei-jwt
API changes (from Hackage documentation)
+ Shomei.SigningKey.Sign.Jwt: signAccessTokenWith :: ShomeiConfig -> JWK -> AuthClaims -> IO (Either JWTError AccessToken)
+ Shomei.SigningKey.Verify.Jwt: [legacyActorDeadline] :: VerifierSettings -> !Maybe UTCTime
+ Shomei.SigningKey.Verify.Jwt: [maxTokenLifetime] :: VerifierSettings -> !NominalDiffTime
+ Shomei.SigningKey.Verify.Jwt: validateVerifierSettings :: VerifierSettings -> Either Text ()
- Shomei.SigningKey.Verify.Jwt: VerifierSettings :: !Issuer -> !Audience -> !NominalDiffTime -> !Bool -> VerifierSettings
+ Shomei.SigningKey.Verify.Jwt: VerifierSettings :: !Issuer -> !Audience -> !NominalDiffTime -> !Bool -> !NominalDiffTime -> !Maybe UTCTime -> VerifierSettings
Files
- CHANGELOG.md +12/−0
- shomei-jwt.cabal +57/−52
- src/Shomei/SigningKey/Sign/Jwt.hs +27/−8
- src/Shomei/SigningKey/Verify/Jwt.hs +33/−9
- test/Shomei/SigningKey/Sign/RsaCustomClaimSpec.hs +2/−2
- test/Shomei/SigningKey/TestSupport.hs +1/−1
- test/Shomei/SigningKey/Verify/JwtSpec.hs +86/−3
CHANGELOG.md view
@@ -4,6 +4,18 @@ [PVP](https://pvp.haskell.org/) and is versioned independently of the other Shōmei packages. +## 0.3.0.0 — 2026-11-05++- **Breaking:** internal dependency bounds follow the 0.3 API series.++- **Breaking:** `VerifierSettings` requires a finite positive `maxTokenLifetime` and an explicit+ optional legacy actor deadline. Verification rejects nonpositive/excess lifetimes independently+ of clock skew. The core-config adapter derives its ceiling from issuance TTLs.+- Actor claims default to single-subject objects. Finite legacy reading/emission settings support+ a coordinated rollout; a legacy mint's expiry plus skew must fit before the reader cutoff.+- A present malformed actor fails verification instead of losing its delegation identity.+ Direct extension bags cannot inject reserved claims. `sid` remains the correlation identifier.+ ## 0.2.0.0 — 2026-08-27 - **Breaking:** requires `shomei-core ^>=0.2.0.0`.
shomei-jwt.cabal view
@@ -1,7 +1,7 @@-cabal-version: 3.0-name: shomei-jwt-version: 0.2.0.0-synopsis: JWT access-token signing/verification and JWKS publishing+cabal-version: 3.0+name: shomei-jwt+version: 0.3.0.0+synopsis: JWT access-token signing/verification and JWKS publishing description: Interprets Shōmei's signing-key effects with jose. Signs and verifies ES256 (or, configurably, RS256) access tokens and OpenID Connect ID tokens,@@ -11,28 +11,33 @@ only on shomei-core, so it can be used without Shōmei's HTTP or PostgreSQL layers. -homepage: https://github.com/shinzui/shomei-bug-reports: https://github.com/shinzui/shomei/issues-license: MIT-license-file: LICENSE-author: Nadeem Bitar-maintainer: nadeem@gmail.com-copyright: 2026 Nadeem Bitar-category: Web, Security-tested-with: GHC ==9.12.4+homepage: https://github.com/shinzui/shomei+bug-reports: https://github.com/shinzui/shomei/issues+license: MIT+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: 2026 Nadeem Bitar+category: Web, Security+tested-with: ghc ==9.12.4 extra-doc-files: CHANGELOG.md source-repository head- type: git+ type: git location: https://github.com/shinzui/shomei.git common warnings ghc-options:- -Wall -Wcompat -Widentities -Wincomplete-record-updates- -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints+ -Wall+ -Wcompat+ -Widentities+ -Wincomplete-record-updates+ -Wincomplete-uni-patterns+ -Wpartial-fields+ -Wredundant-constraints common shared- default-language: GHC2024+ default-language: GHC2024 default-extensions: BlockArguments DeriveAnyClass@@ -45,8 +50,8 @@ TemplateHaskell library- import: warnings, shared- hs-source-dirs: src+ import: warnings, shared+ hs-source-dirs: src exposed-modules: Shomei.SigningKey.Jwks.Jwt Shomei.SigningKey.Key.Jwt@@ -56,28 +61,28 @@ Shomei.SigningKey.Verify.Jwt build-depends:- , aeson >=2.1 && <2.3- , base >=4.18 && <5- , base64-bytestring >=1.2 && <1.3- , bytestring >=0.11 && <0.13- , containers >=0.6 && <0.9- , crypton >=1.1.0 && <1.2- , effectful >=2.5 && <2.8- , effectful-core >=2.5 && <2.8- , jose >=0.13 && <0.14- , lens >=5.2 && <5.4- , monad-time >=0.4 && <0.5- , mtl >=2.3 && <2.4- , ram >=0.22 && <0.23- , shomei-core ^>=0.2.0.0- , text >=2.0 && <2.2- , time >=1.12 && <1.15+ aeson >=2.1 && <2.3,+ base >=4.18 && <5,+ base64-bytestring >=1.2 && <1.3,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.9,+ crypton >=1.1.0 && <1.2,+ effectful >=2.5 && <2.8,+ effectful-core >=2.5 && <2.8,+ jose >=0.13 && <0.14,+ lens >=5.2 && <5.4,+ monad-time >=0.4 && <0.5,+ mtl >=2.3 && <2.4,+ ram >=0.22 && <0.23,+ shomei-core ^>=0.3.0.0,+ text >=2.0 && <2.2,+ time >=1.12 && <1.15, test-suite shomei-jwt-test- import: warnings, shared- type: exitcode-stdio-1.0+ import: warnings, shared+ type: exitcode-stdio-1.0 hs-source-dirs: test- main-is: Main.hs+ main-is: Main.hs other-modules: Shomei.SigningKey.Interpreter.JwtSpec Shomei.SigningKey.Jwks.JwtSpec@@ -91,17 +96,17 @@ Shomei.SigningKey.Verify.JwtSpec build-depends:- , aeson >=2.1 && <2.3- , base >=4.18 && <5- , bytestring >=0.11 && <0.13- , containers >=0.6 && <0.9- , effectful >=2.5 && <2.8- , jose >=0.13 && <0.14- , lens >=5.2 && <5.4- , ram >=0.22 && <0.23- , shomei-core ^>=0.2.0.0- , shomei-jwt ^>=0.2.0.0- , tasty >=1.4 && <1.6- , tasty-hunit >=0.10 && <0.11- , text >=2.0 && <2.2- , time >=1.12 && <1.15+ aeson >=2.1 && <2.3,+ base >=4.18 && <5,+ bytestring >=0.11 && <0.13,+ containers >=0.6 && <0.9,+ effectful >=2.5 && <2.8,+ jose >=0.13 && <0.14,+ lens >=5.2 && <5.4,+ ram >=0.22 && <0.23,+ shomei-core ^>=0.3.0.0,+ shomei-jwt ^>=0.3.0.0,+ tasty >=1.4 && <1.6,+ tasty-hunit >=0.10 && <0.11,+ text >=2.0 && <2.2,+ time >=1.12 && <1.15,
src/Shomei/SigningKey/Sign/Jwt.hs view
@@ -16,6 +16,7 @@ claimsFromIdToken, wholeSeconds, signAccessToken,+ signAccessTokenWith, signIdToken, runTokenSignerJwt, )@@ -59,7 +60,7 @@ import Effectful.Dispatch.Dynamic (interpret_) import Shomei.Authorization.Claims.Domain (AuthClaims (..)) import Shomei.Authorization.Claims.Domain qualified as Domain-import Shomei.Config (ShomeiConfig)+import Shomei.Config (ShomeiConfig (..), SigningKeyConfig (..)) import Shomei.Id (idText) import Shomei.OAuth.IdToken.Domain (IdToken (IdToken), IdTokenClaims (..)) import Shomei.Prelude@@ -83,7 +84,10 @@ -- directly; session id, scopes, roles, and permissions travel as the custom claims -- @sid@, @scopes@, @roles@, @permissions@. claimsFromAuth :: AuthClaims -> ClaimsSet-claimsFromAuth ac =+claimsFromAuth = claimsFromAuthWithActorFormat False++claimsFromAuthWithActorFormat :: Bool -> AuthClaims -> ClaimsSet+claimsFromAuthWithActorFormat legacy ac = withActor $ -- 'addExtra' seeds the custom claims into the base FIRST, then every claim in -- 'Domain.reservedClaimKeys' is written through a registered slot or a managed@@ -108,11 +112,11 @@ & addClaim "permissions" (Aeson.toJSON (Set.toList ac.permissions)) & addClaim "auth_time" (Aeson.toJSON (NumericDate (wholeSeconds ac.authTime))) where- addExtra obj cs = KeyMap.foldrWithKey (\k v -> addClaim (Key.toText k) v) cs obj+ addExtra obj cs = KeyMap.foldrWithKey (\k v -> if Key.toText k `elem` Domain.reservedClaimKeys then id else addClaim (Key.toText k) v) cs obj -- Add the @act@ claim only for delegated tokens, leaving ordinary tokens -- byte-identical to before this field existed. withActor cs = case ac.actor of- Just uid -> cs & addClaim "act" (Aeson.String (idText uid))+ Just uid -> cs & addClaim "act" (if legacy then Aeson.String (idText uid) else Aeson.object ["sub" Aeson..= idText uid]) Nothing -> cs -- | The JWS algorithm to sign with for a given key, chosen directly from the key@@ -131,7 +135,22 @@ -- for EC) rather than negotiated by @makeJWSHeader@, and the key's @kid@ is copied -- into the header by hand so a verifier can select the right key. signAccessToken :: JWK -> AuthClaims -> IO (Either JWTError AccessToken)-signAccessToken jwk ac = do+signAccessToken = signAccessTokenWithActorFormat False++-- | During a rolling upgrade the explicit legacy emitter expires at the same finite+-- deadline as legacy reading. A legacy token's expiry plus skew must fit inside+-- that window; later mints automatically use objects so no fresh legacy token+-- outlives the reader's cutoff.+signAccessTokenWith :: ShomeiConfig -> JWK -> AuthClaims -> IO (Either JWTError AccessToken)+signAccessTokenWith cfg jwk ac = do+ now <- getCurrentTime+ let lastValid = utcTimeToPOSIXSeconds ac.expiresAt + fromIntegral cfg.signingKeyConfig.allowedClockSkewSeconds+ fits deadline = utcTimeToPOSIXSeconds now < deadline && lastValid < deadline+ legacy = cfg.signingKeyConfig.emitLegacyActor && maybe False fits (fromIntegral <$> cfg.signingKeyConfig.legacyActorUntilUnix)+ signAccessTokenWithActorFormat legacy jwk ac++signAccessTokenWithActorFormat :: Bool -> JWK -> AuthClaims -> IO (Either JWTError AccessToken)+signAccessTokenWithActorFormat legacy jwk ac = do let hdr = newJWSHeaderProtected (algForKey jwk) & JWS.kid@@ -139,7 +158,7 @@ & JWS.typ ?~ newHeaderParamProtected "at+jwt" result <- runJOSE @JWTError $ do- signed <- signClaims jwk hdr (claimsFromAuth ac)+ signed <- signClaims jwk hdr (claimsFromAuthWithActorFormat legacy ac) pure (encodeCompact (signed :: SignedJWT)) pure $ case result of Left e -> Left e@@ -203,9 +222,9 @@ ShomeiConfig -> Eff (TokenSigner : es) a -> Eff es a-runTokenSignerJwt jwk _cfg = interpret_ \case+runTokenSignerJwt jwk cfg = interpret_ \case SignAccessToken ac -> do- r <- liftIO (signAccessToken jwk ac)+ r <- liftIO (signAccessTokenWith cfg jwk ac) case r of Right tok -> pure tok Left e -> liftIO (throwIO (userError ("token signing failed: " <> show e)))
src/Shomei/SigningKey/Verify/Jwt.hs view
@@ -13,6 +13,7 @@ module Shomei.SigningKey.Verify.Jwt ( VerifierSettings (..), verifierSettingsFromConfig,+ validateVerifierSettings, KidSelectingKeys (..), checkStringOrUri, verifyTokenWith,@@ -58,12 +59,13 @@ import Data.Set qualified as Set import Data.Text qualified as Text import Data.Text.Encoding qualified as Text-import Data.Time (NominalDiffTime)+import Data.Time (NominalDiffTime, diffUTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Effectful (Eff, IOE, (:>)) import Effectful.Dispatch.Dynamic (interpret_) import Shomei.Authorization.Claims.Domain (AuthClaims (..)) import Shomei.Authorization.Claims.Domain qualified as Domain-import Shomei.Config (ShomeiConfig (..), SigningKeyConfig (..))+import Shomei.Config (ImpersonationConfig (..), MachineTokenConfig (..), ShomeiConfig (..), SigningKeyConfig (..)) import Shomei.Error (TokenError (..)) import Shomei.Id (parseId) import Shomei.Prelude@@ -82,7 +84,9 @@ { issuer :: !Domain.Issuer, audience :: !Domain.Audience, allowedClockSkew :: !NominalDiffTime,- requireTokenType :: !Bool+ requireTokenType :: !Bool,+ maxTokenLifetime :: !NominalDiffTime,+ legacyActorDeadline :: !(Maybe UTCTime) } deriving stock (Eq, Show) @@ -92,13 +96,23 @@ { issuer = cfg.issuer, audience = cfg.audience, allowedClockSkew = fromIntegral cfg.signingKeyConfig.allowedClockSkewSeconds,- requireTokenType = False+ requireTokenType = False,+ maxTokenLifetime = maximum [cfg.accessTokenTTL, cfg.machineTokenConfig.machineTokenTTL, cfg.impersonationConfig.impersonationSessionTTL],+ legacyActorDeadline = posixSecondsToUTCTime . fromIntegral <$> cfg.signingKeyConfig.legacyActorUntilUnix } -- | A verification key store that returns only the key named by the protected -- @kid@ header. Missing and unknown identifiers deliberately return no keys. newtype KidSelectingKeys = KidSelectingKeys JWKSet +-- | Validate once before serving requests in standalone consumers.+validateVerifierSettings :: VerifierSettings -> Either Text ()+validateVerifierSettings settings = do+ checkStringOrUri (issuerText settings.issuer)+ checkStringOrUri (audienceText settings.audience)+ when (settings.maxTokenLifetime <= 0) (Left "maxTokenLifetime must be positive and finite")+ when (settings.allowedClockSkew < 0) (Left "allowedClockSkew must be nonnegative")+ instance (Applicative m, HasKid h) => VerificationKeyStore m (h p) payload KidSelectingKeys where getVerificationKeys hdr _payload (KidSelectingKeys (JWKSet keys)) = pure case preview (kid . _Just . param) hdr of@@ -115,7 +129,9 @@ -- | Verify with explicit policy. The protected @kid@ chooses exactly one public -- key and the accepted JWS algorithms are pinned to Shōmei's ES256/RS256 set. verifyTokenWith :: VerifierSettings -> JWKSet -> Text -> IO (Either TokenError AuthClaims)+verifyTokenWith settings _ _ | Left _ <- validateVerifierSettings settings = pure (Left TokenMalformed) verifyTokenWith verifierSettings jwks raw = do+ verificationTime <- getCurrentTime let bytes = BSL.fromStrict (Text.encodeUtf8 raw) matches wanted = maybe (const False) (==) (preview stringOrUri wanted) settings =@@ -138,7 +154,7 @@ pure case result of Left (JWSError NoUsableKeys) -> Left (TokenKeyNotFound headerKid) Left err -> Left (jwtErrorToTokenError err)- Right claims -> checkTokenType verifierSettings headerType *> claimsToAuth claims+ Right claims -> checkTokenType verifierSettings headerType *> claimsToAuth verifierSettings verificationTime claims -- | THE core/Servant contract. Existing callers receive the hardened verifier -- through the unchanged public function.@@ -190,8 +206,8 @@ | otherwise -> Left (TokenOtherError ("typ " <> tokenType <> " is not at+jwt")) -- | Decode a verified jose 'ClaimsSet' back into Shōmei's 'AuthClaims'.-claimsToAuth :: ClaimsSet -> Either TokenError AuthClaims-claimsToAuth cs = do+claimsToAuth :: VerifierSettings -> UTCTime -> ClaimsSet -> Either TokenError AuthClaims+claimsToAuth settings verificationTime cs = do subTxt <- note "missing sub" (cs ^. claimSub >>= soText) subj <- mapLeft (const TokenMalformed) (parseId subTxt) sidTxt <- note "missing sid" (lookupString "sid")@@ -200,6 +216,7 @@ audTxt <- exactAudience (cs ^. claimAud) issuedAt' <- note "missing iat" (dateOf (cs ^. claimIat)) expiresAt' <- note "missing exp" (dateOf (cs ^. claimExp))+ when (expiresAt' <= issuedAt' || diffUTCTime expiresAt' issuedAt' > settings.maxTokenLifetime) (Left TokenMalformed) authTime' <- case Map.lookup "auth_time" claims of Nothing -> Right issuedAt' Just value -> case parseEither Aeson.parseJSON value of@@ -223,9 +240,15 @@ ] -- The @act@ claim is present only on delegated (impersonation) tokens. Absent -- → 'Nothing'; present but unparseable → a malformed token.- actor' <- case lookupString "act" of+ actor' <- case Map.lookup "act" claims of Nothing -> Right Nothing- Just actTxt -> Just <$> mapLeft (const TokenMalformed) (parseId actTxt)+ Just (Aeson.String actTxt)+ | maybe False (verificationTime <) settings.legacyActorDeadline -> parseActor actTxt+ Just (Aeson.Object actorObject)+ | KeyMap.size actorObject == 1,+ Just (Aeson.String actTxt) <- KeyMap.lookup "sub" actorObject ->+ parseActor actTxt+ Just _ -> Left TokenMalformed pure AuthClaims { subject = subj,@@ -242,6 +265,7 @@ extraClaims = extra } where+ parseActor actTxt = Just <$> mapLeft (const TokenMalformed) (parseId actTxt) note msg = maybe (Left (TokenOtherError msg)) Right mapLeft f = either (Left . f) Right -- jose serialises a StringOrURI (whether arbitrary string or URI) as a JSON
test/Shomei/SigningKey/Sign/RsaCustomClaimSpec.hs view
@@ -72,8 +72,8 @@ Right ac' -> idText ac'.subject @?= idText base.subject Left e -> assertFailure ("verify failed: " <> show e), testCase "configSigningAlgorithm parses RS256 and rejects unknown text" $ do- let rs = testConfig {signingKeyConfig = SigningKeyConfig {algorithm = "RS256", refreshIntervalSeconds = 60, allowedClockSkewSeconds = 30}}- bad = testConfig {signingKeyConfig = SigningKeyConfig {algorithm = "nope", refreshIntervalSeconds = 60, allowedClockSkewSeconds = 30}}+ let rs = testConfig {signingKeyConfig = testConfig.signingKeyConfig {algorithm = "RS256"}}+ bad = testConfig {signingKeyConfig = testConfig.signingKeyConfig {algorithm = "nope"}} configSigningAlgorithm rs @?= Right RS256 assertBool "unknown signing algorithms must be a boot error" (isLeft (configSigningAlgorithm bad)) ]
test/Shomei/SigningKey/TestSupport.hs view
@@ -28,7 +28,7 @@ testAudience = Audience "shomei-clients" testConfig :: ShomeiConfig-testConfig = defaultShomeiConfig testIssuer testAudience+testConfig = (defaultShomeiConfig testIssuer testAudience) {accessTokenTTL = 3600} -- | Build claims valid from @t@ for one hour, with two scopes and one role. mkClaims :: ShomeiConfig -> UTCTime -> IO AuthClaims
test/Shomei/SigningKey/Verify/JwtSpec.hs view
@@ -4,6 +4,7 @@ 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)@@ -15,10 +16,12 @@ ( Audience (Audience), ClaimsSet, JWTError,+ NumericDate (..), SignedJWT, StringOrURI, addClaim, claimAud,+ claimNbf, signClaims, unregisteredClaims, )@@ -35,14 +38,17 @@ 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)+import Shomei.SigningKey.Sign.Jwt (claimsFromAuth, signAccessToken, signAccessTokenWith) import Shomei.SigningKey.TestSupport (mkClaims, mkClaimsWith, publicJwks, testConfig)-import Shomei.SigningKey.Verify.Jwt (verifyToken)+import Shomei.SigningKey.Verify.Jwt (VerifierSettings (..), verifierSettingsFromConfig, verifyToken, verifyTokenWith) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=)) @@ -50,9 +56,74 @@ tests = testGroup "Verify"- [ testCase "accepts an iat within the configured 30-second skew" $ do+ [ 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@@ -100,6 +171,18 @@ 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))