packages feed

shomei-jwt (empty) → 0.2.0.0

raw patch · 20 files changed

+2261/−0 lines, 20 filesdep +aesondep +basedep +base64-bytestring

Dependencies added: aeson, base, base64-bytestring, bytestring, containers, crypton, effectful, effectful-core, jose, lens, monad-time, mtl, ram, shomei-core, shomei-jwt, tasty, tasty-hunit, text, time

Files

+ CHANGELOG.md view
@@ -0,0 +1,33 @@+# Changelog for shomei-jwt++All notable changes to `shomei-jwt` are documented here. This package adheres to the+[PVP](https://pvp.haskell.org/) and is versioned independently of the other+Shōmei packages.++## 0.2.0.0 — 2026-08-27++- **Breaking:** requires `shomei-core ^>=0.2.0.0`.+- **Breaking:** `toStoredSigningKey` and `toStoredSigningKeyFor` now return `Either Text+  StoredSigningKey` and refuse keys without a public projection; generated and published JWKs+  explicitly carry their signing `alg`.+- Verification is pinned to ES256/RS256 and selects exactly by `kid`; missing and unknown key IDs,+  malformed list claims, multi-valued audiences, and access-token `typ` mismatches are rejected.+- JWT numeric dates are emitted at whole-second precision, access tokens carry `typ: at+jwt`, and+  configurable clock skew applies to `exp`, `nbf`, and `iat`.+- Key rotation uses the store's atomic active-key replacement operation and keeps retired overlap+  keys trusted while immediately excluding revoked keys.++## 0.1.0.0 — 2026-08-24++Initial release. Interprets Shōmei's signing-key effects with `jose`.++- ES256 and RS256 access-token signing and verification, and OpenID Connect+  ID tokens. RSA signing is pinned to RS256 rather than PSS.+- JWKS publishing over the full publishable key set, with hot reload, so+  relying services can verify tokens offline.+- Key rotation with an overlap window: a retired key keeps verifying+  outstanding tokens until they expire.+- Envelope encryption for signing keys at rest, with decrypt-at-load and+  rewrap.+- An extensible custom-claims bag on `AuthClaims`, plus the `act` actor+  claim for delegated tokens and the reserved `permissions` claim.
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2026 Nadeem Bitar++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ shomei-jwt.cabal view
@@ -0,0 +1,107 @@+cabal-version:   3.0+name:            shomei-jwt+version:         0.2.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,+  publishes the JWKS document that relying services fetch to verify those+  tokens offline, and implements key rotation with an overlap window so a+  retired key keeps verifying outstanding tokens until they expire. Depends+  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+extra-doc-files: CHANGELOG.md++source-repository head+  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++common shared+  default-language:   GHC2024+  default-extensions:+    BlockArguments+    DeriveAnyClass+    DuplicateRecordFields+    MultilineStrings+    OverloadedLabels+    OverloadedRecordDot+    OverloadedStrings+    QualifiedDo+    TemplateHaskell++library+  import:          warnings, shared+  hs-source-dirs:  src+  exposed-modules:+    Shomei.SigningKey.Jwks.Jwt+    Shomei.SigningKey.Key.Jwt+    Shomei.SigningKey.Protection.Jwt+    Shomei.SigningKey.Rotation.Jwt+    Shomei.SigningKey.Sign.Jwt+    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++test-suite shomei-jwt-test+  import:         warnings, shared+  type:           exitcode-stdio-1.0+  hs-source-dirs: test+  main-is:        Main.hs+  other-modules:+    Shomei.SigningKey.Interpreter.JwtSpec+    Shomei.SigningKey.Jwks.JwtSpec+    Shomei.SigningKey.Key.JwtSpec+    Shomei.SigningKey.Protection.JwtSpec+    Shomei.SigningKey.Rotation.JwtSpec+    Shomei.SigningKey.Sign.IdTokenSpec+    Shomei.SigningKey.Sign.JwtSpec+    Shomei.SigningKey.Sign.RsaCustomClaimSpec+    Shomei.SigningKey.TestSupport+    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
+ src/Shomei/SigningKey/Jwks/Jwt.hs view
@@ -0,0 +1,38 @@+-- | The published JWKS (JSON Web Key Set) document and the 'KeySet' abstraction.+--+-- A JWKS is the public document a downstream verifier fetches: @{"keys":[ ... ]}@+-- containing the *public* projection of each signing key (no private @"d"@). EP-6+-- serves 'jwksDocument' at @GET /.well-known/jwks.json@.+module Shomei.SigningKey.Jwks.Jwt+  ( jwksDocument,+    KeySet (..),+    keySetPublicJwks,+  )+where++import Crypto.JOSE.JWK (JWK, JWKSet (JWKSet), asPublicKey)+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as BSL+import Shomei.Prelude++-- | A live set of signing keys: the current active key plus any retired-but-valid keys.+data KeySet = KeySet+  { activeKey :: !JWK,+    previousKeys :: ![JWK]+  }++-- | All keys in a 'KeySet' (active first), as live JWKs.+keySetAll :: KeySet -> [JWK]+keySetAll ks = ks.activeKey : ks.previousKeys++-- | The public 'JWKSet' a verifier should use (private material stripped).+keySetPublicJwks :: KeySet -> JWKSet+keySetPublicJwks ks = JWKSet (mapMaybe publicOf (keySetAll ks))+  where+    publicOf k = k ^. asPublicKey++-- | Encode a list of keys as a published JWKS document (public material only).+jwksDocument :: [JWK] -> BSL.ByteString+jwksDocument keys = Aeson.encode (JWKSet (mapMaybe publicOf keys))+  where+    publicOf k = k ^. asPublicKey
+ src/Shomei/SigningKey/Key/Jwt.hs view
@@ -0,0 +1,118 @@+-- | Generating ES256 signing keys and converting them to/from the+-- storage-agnostic 'StoredSigningKey' record (MasterPlan IP-4).+--+-- A key's @kid@ is its RFC 7638 JWK thumbprint (the SHA-256 hash of the key's+-- canonical JSON, Base64URL-unpadded), so the same public key always yields the+-- same @kid@ and two distinct keys cannot collide. This module is the only place+-- in Shōmei that converts between the opaque JWK JSON stored in+-- 'StoredSigningKey' and a live @jose@ 'JWK'.+module Shomei.SigningKey.Key.Jwt+  ( generateSigningKey,+    generateSigningKeyFor,+    toStoredSigningKey,+    toStoredSigningKeyFor,+    fromStoredSigningKey,+    keyKid,+    joseAlg,+  )+where++import Crypto.Hash (Digest)+import Crypto.Hash.Algorithms (SHA256)+import Crypto.JOSE.JWA.JWK (Crv (P_256))+import Crypto.JOSE.JWA.JWS qualified as JWS+import Crypto.JOSE.JWK+  ( JWK,+    JWKAlg (JWSAlg),+    KeyMaterialGenParam (ECGenParam, RSAGenParam),+    KeyUse (Sig),+    asPublicKey,+    genJWK,+    jwkAlg,+    jwkKid,+    jwkUse,+    thumbprint,+  )+import Data.Aeson qualified as Aeson+import Data.ByteArray.Encoding (Base (Base64URLUnpadded), convertToBase)+import Data.ByteString (ByteString)+import Data.ByteString.Lazy qualified as BSL+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Shomei.Prelude+import Shomei.SigningKey.Domain+  ( SigningAlgorithm (ES256, RS256),+    SigningKeyStatus (KeyActive),+    StoredSigningKey (..),+    signingAlgorithmToText,+  )++-- | Generate a fresh signing key for the requested algorithm, marked for+-- signature use, with its @kid@ set to its RFC 7638 thumbprint (Base64URL,+-- unpadded). @ES256@ → a P-256 EC key; @RS256@ → a 2048-bit RSA key (256 bytes,+-- comfortably above jose's 2040-bit minimum).+generateSigningKeyFor :: SigningAlgorithm -> IO JWK+generateSigningKeyFor alg = do+  k0 <- genJWK (genParam alg)+  let tp = view thumbprint k0 :: Digest SHA256+      kid = Text.decodeUtf8 (convertToBase Base64URLUnpadded tp :: ByteString)+  pure (k0 & jwkUse ?~ Sig & jwkKid ?~ kid & jwkAlg ?~ JWSAlg (joseAlg alg))+  where+    genParam ES256 = ECGenParam P_256+    genParam RS256 = RSAGenParam 256 -- 256 bytes == 2048-bit modulus++-- | Generate a fresh ES256 (P-256) signing key. Back-compat alias defined in+-- terms of 'generateSigningKeyFor'.+generateSigningKey :: IO JWK+generateSigningKey = generateSigningKeyFor ES256++-- | The @kid@ stored on a key (empty if absent — 'generateSigningKey' always sets it).+keyKid :: JWK -> Text+keyKid k = fromMaybe "" (k ^. jwkKid)++joseAlg :: SigningAlgorithm -> JWS.Alg+joseAlg ES256 = JWS.ES256+joseAlg RS256 = JWS.RS256++-- | Convert a live 'JWK' to the storage-agnostic record. Serializes the full+-- key (with the private @"d"@) to 'privateKeyJwk' and the public-only projection+-- to 'publicKeyJwk'.+toStoredSigningKey :: UTCTime -> JWK -> Either Text StoredSigningKey+toStoredSigningKey t k = do+  pub <-+    maybe+      (Left ("key " <> keyKid k <> " has no public projection; refusing to store private material as public"))+      Right+      (k ^. asPublicKey)+  let enc = Text.decodeUtf8 . BSL.toStrict . Aeson.encode+  pure+    StoredSigningKey+      { keyId = keyKid k,+        algorithm = "ES256",+        publicKeyJwk = enc pub,+        privateKeyJwk = enc k,+        status = KeyActive,+        createdAt = t,+        activatedAt = Just t,+        retiredAt = Nothing,+        revokedAt = Nothing+      }++-- | Like 'toStoredSigningKey' but records the actual algorithm of the key. New+-- code that generates RS256 keys uses this; 'toStoredSigningKey' stays the ES256+-- convenience so existing callers are unaffected.+toStoredSigningKeyFor :: SigningAlgorithm -> UTCTime -> JWK -> Either Text StoredSigningKey+toStoredSigningKeyFor alg t k =+  (\stored -> stored {algorithm = signingAlgorithmToText alg}) <$> toStoredSigningKey t k++-- | Parse a stored key's full (private) JWK JSON back into a live 'JWK'.+--+-- __Does not decrypt.__ A row whose private material is encrypted at rest (see+-- "Shomei.SigningKey.Protection.Jwt") will fail to parse here. Production code loading a signer must+-- call 'Shomei.SigningKey.Protection.Jwt.decryptStoredSigningKey', which handles both forms; this+-- function remains for tests and for callers converting an in-memory, unprotected record.+fromStoredSigningKey :: StoredSigningKey -> Either Text JWK+fromStoredSigningKey sk =+  case Aeson.eitherDecodeStrict (Text.encodeUtf8 sk.privateKeyJwk) of+    Left err -> Left (Text.pack err)+    Right k -> Right k
+ src/Shomei/SigningKey/Protection/Jwt.hs view
@@ -0,0 +1,179 @@+-- | Envelope encryption of stored signing-key private material (at-rest protection).+--+-- A signing key's private JWK is the most powerful secret in the system: whoever holds it+-- can forge a valid token for any user of any downstream service that trusts Shōmei's JWKS.+-- Stored in plaintext, a database read — or a backup, a dump, a misconfigured replica —+-- hands that power over. Here it is encrypted under a __key-encryption key__ (KEK) that+-- lives outside the database, in the process environment, so forging tokens requires the+-- database /and/ the application environment.+--+-- Format v1, held in the existing @private_key_jwk text@ column (no schema change):+--+-- > "enc:v1:" <> base64url(nonce, 12 bytes) <> ":" <> base64url(ciphertext <> tag)+--+-- Cipher: ChaCha20-Poly1305 (AEAD). The associated data is the key's @kid@, which binds+-- each ciphertext to its row: an attacker with database /write/ access cannot relabel an+-- old compromised key as the active one, because decryption under the new @kid@ fails.+--+-- Operators wanting KMS/HSM-managed keys inject the KEK from their secret manager; that+-- integration sits above Shōmei and is out of scope here.+module Shomei.SigningKey.Protection.Jwt+  ( KeyEncryptionKey,+    keyEncryptionKeyFromBase64,+    KeyDecryptError (..),+    isEncryptedPrivateJwk,+    encryptPrivateJwk,+    decryptPrivateJwk,+    protectStoredSigningKey,+    decryptStoredSigningKey,+    publicJwkFromStored,+  )+where++import Crypto.Cipher.ChaChaPoly1305 qualified as AEAD+import Crypto.Error (CryptoFailable (..))+import Crypto.JOSE.JWK (JWK, JWKAlg (JWSAlg), jwkAlg)+import Crypto.MAC.Poly1305 qualified as Poly1305+import Crypto.Random (getRandomBytes)+import Data.Aeson qualified as Aeson+import Data.Bifunctor (first)+import Data.ByteArray qualified as BA+import Data.ByteArray.Encoding (Base (Base64, Base64URLUnpadded), convertFromBase, convertToBase)+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Shomei.Prelude+import Shomei.SigningKey.Domain (StoredSigningKey (..), signingAlgorithmFromText)+import Shomei.SigningKey.Key.Jwt (joseAlg)++-- | The 32-byte key that encrypts stored private keys. Abstract, with no 'Show' and no+-- JSON instances: printing it anywhere would defeat the entire scheme, so make that a type+-- error rather than a code-review question.+newtype KeyEncryptionKey = KeyEncryptionKey BA.ScrubbedBytes++-- | Parse a KEK from base64 text (the value of @SHOMEI_KEY_ENCRYPTION_KEY@). Requires+-- exactly 32 decoded bytes. The 'Left' explains what was wrong and how to make a valid one;+-- callers prefix it with the variable they read.+keyEncryptionKeyFromBase64 :: Text -> Either Text KeyEncryptionKey+keyEncryptionKeyFromBase64 raw =+  case convertFromBase Base64 (TE.encodeUtf8 (Text.strip raw)) :: Either String ByteString of+    Left err -> Left (badKek ("it is not valid base64 (" <> Text.pack err <> ")"))+    Right bs+      | BS.length bs == 32 -> Right (KeyEncryptionKey (BA.convert bs))+      | otherwise -> Left (badKek ("it decodes to " <> tshow (BS.length bs) <> " bytes, not 32"))+  where+    badKek reason =+      "is not a valid key-encryption key: "+        <> reason+        <> ". Generate one with: head -c 32 /dev/urandom | base64"+    tshow = Text.pack . show++-- | Why a stored private key could not be turned back into a live 'JWK'.+data KeyDecryptError+  = -- | The @enc:v1:@ envelope is structurally broken (missing prefix, bad base64, …).+    MalformedEncryptedKey Text+  | -- | Authentication failed. Deliberately one constructor for a wrong KEK, a tampered+    -- ciphertext, and a ciphertext moved to another row: the caller learns only that it+    -- did not authenticate.+    KeyDecryptFailed+  | -- | Decryption succeeded but the recovered bytes are not a JWK.+    KeyJsonInvalid Text+  deriving stock (Eq, Show)++envelopePrefix :: Text+envelopePrefix = "enc:v1:"++-- | Is this stored @private_key_jwk@ an envelope rather than plaintext JWK JSON? Plaintext+-- JWKs are JSON objects, so the prefix is an unambiguous discriminator.+isEncryptedPrivateJwk :: Text -> Bool+isEncryptedPrivateJwk = Text.isPrefixOf envelopePrefix++-- | Encrypt a private JWK JSON string under @kek@, binding it to @kid@.+encryptPrivateJwk :: KeyEncryptionKey -> Text -> Text -> IO Text+encryptPrivateJwk (KeyEncryptionKey kek) kid jwkJson = do+  nonceBytes <- getRandomBytes 12 :: IO ByteString+  case aeadState kek nonceBytes kid of+    CryptoFailed e -> ioError (userError ("shomei: cannot initialize key encryption: " <> show e))+    CryptoPassed st0 -> do+      let (ciphertext, st1) = AEAD.encrypt (TE.encodeUtf8 jwkJson) st0+          tag = BA.convert (AEAD.finalize st1) :: ByteString+      pure (envelopePrefix <> b64 nonceBytes <> ":" <> b64 (ciphertext <> tag))++-- | Recover private JWK JSON from the required encrypted envelope.+decryptPrivateJwk :: KeyEncryptionKey -> Text -> Text -> Either KeyDecryptError Text+decryptPrivateJwk (KeyEncryptionKey kek) kid stored+  | not (isEncryptedPrivateJwk stored) =+      Left (MalformedEncryptedKey "private key is not an enc:v1 envelope")+  | otherwise = do+      (nonceBytes, body) <- parseEnvelope stored+      when (BS.length body < 16) (Left (MalformedEncryptedKey "ciphertext is shorter than its authentication tag"))+      let (ciphertext, tagBytes) = BS.splitAt (BS.length body - 16) body+      st0 <- cryptoOr (MalformedEncryptedKey "bad nonce or key size") (aeadState kek nonceBytes kid)+      expectedTag <- cryptoOr (MalformedEncryptedKey "bad authentication tag") (Poly1305.authTag tagBytes)+      let (plaintext, st1) = AEAD.decrypt ciphertext st0+      -- 'Auth's Eq is constant-time (Data.ByteArray.constEq).+      unless (AEAD.finalize st1 == expectedTag) (Left KeyDecryptFailed)+      first (KeyJsonInvalid . Text.pack . show) (TE.decodeUtf8' plaintext)++-- | Split @enc:v1:\<nonce\>:\<body\>@ into its decoded parts.+parseEnvelope :: Text -> Either KeyDecryptError (ByteString, ByteString)+parseEnvelope stored =+  case Text.splitOn ":" (Text.drop (Text.length envelopePrefix) stored) of+    [nonceB64, bodyB64] -> do+      nonceBytes <- decodeField "nonce" nonceB64+      body <- decodeField "ciphertext" bodyB64+      when (BS.length nonceBytes /= 12) (Left (MalformedEncryptedKey "nonce is not 12 bytes"))+      pure (nonceBytes, body)+    parts ->+      Left (MalformedEncryptedKey ("expected enc:v1:<nonce>:<ciphertext>, found " <> Text.pack (show (length parts)) <> " parts"))+  where+    decodeField what t =+      first+        (\err -> MalformedEncryptedKey (what <> " is not valid base64url (" <> Text.pack err <> ")"))+        (convertFromBase Base64URLUnpadded (TE.encodeUtf8 t) :: Either String ByteString)++-- | The AEAD state for @(kek, nonce, kid-as-AAD)@, shared by encrypt and decrypt so the two+-- can never disagree about the associated data.+aeadState :: BA.ScrubbedBytes -> ByteString -> Text -> CryptoFailable AEAD.State+aeadState kek nonceBytes kid = do+  nonce <- AEAD.nonce12 nonceBytes+  st <- AEAD.initialize kek nonce+  pure (AEAD.finalizeAAD (AEAD.appendAAD (TE.encodeUtf8 kid) st))++cryptoOr :: KeyDecryptError -> CryptoFailable a -> Either KeyDecryptError a+cryptoOr err = \case+  CryptoFailed _ -> Left err+  CryptoPassed a -> Right a++b64 :: ByteString -> Text+b64 bs = TE.decodeUtf8 (convertToBase Base64URLUnpadded bs)++-- | Encrypt a key's private material before it is persisted. Idempotent for an envelope.+--+-- @publicKeyJwk@ is never encrypted — publication and verification must not depend on the+-- KEK.+protectStoredSigningKey :: KeyEncryptionKey -> StoredSigningKey -> IO StoredSigningKey+protectStoredSigningKey kek sk+  | isEncryptedPrivateJwk sk.privateKeyJwk = pure sk+  | otherwise = do+      enc <- encryptPrivateJwk kek sk.keyId sk.privateKeyJwk+      pure sk {privateKeyJwk = enc}++-- | The single stored→live conversion for __private__ key material: decrypt if needed, then+-- parse. Every path that needs a signing key goes through here; nothing else may parse+-- @private_key_jwk@.+decryptStoredSigningKey :: KeyEncryptionKey -> StoredSigningKey -> Either KeyDecryptError JWK+decryptStoredSigningKey kek sk = do+  jwkJson <- decryptPrivateJwk kek sk.keyId sk.privateKeyJwk+  first (KeyJsonInvalid . Text.pack) (Aeson.eitherDecodeStrict (TE.encodeUtf8 jwkJson))++-- | Parse a key's __public__ material. Needs no KEK, by construction: the published JWKS and+-- the verifier key set are built from this, so a missing or wrong KEK can never break+-- verification of outstanding tokens — only signing.+publicJwkFromStored :: StoredSigningKey -> Either Text JWK+publicJwkFromStored sk = do+  jwk <- first Text.pack (Aeson.eitherDecodeStrict (TE.encodeUtf8 sk.publicKeyJwk))+  pure case (jwk ^. jwkAlg, signingAlgorithmFromText sk.algorithm) of+    (Nothing, Right alg) -> jwk & jwkAlg ?~ JWSAlg (joseAlg alg)+    _ -> jwk
+ src/Shomei/SigningKey/Rotation/Jwt.hs view
@@ -0,0 +1,58 @@+-- | Key rotation and the live published JWKS, written against the+-- 'SigningKeyStore' and 'Clock' port effects only (no IO key storage of its own).+--+-- Rotation is intentionally simple: generate a new active key, insert it, and mark+-- the previously-active key 'KeyRetired'. The published JWKS includes every publishable+-- key ('KeyActive' and 'KeyRetired'), so tokens signed just before rotation keep+-- verifying until they expire (zero-downtime rotation).+module Shomei.SigningKey.Rotation.Jwt+  ( rotateSigningKey,+    currentJwks,+  )+where++import Crypto.JOSE.JWK (JWK)+import Data.ByteString.Lazy qualified as BSL+import Data.Either (rights)+import Data.Text qualified as Text+import Effectful (Eff, IOE, (:>))+import Shomei.Prelude+import Shomei.SigningKey.Domain (SigningAlgorithm)+import Shomei.SigningKey.Jwks.Jwt (jwksDocument)+import Shomei.SigningKey.Key.Jwt (generateSigningKeyFor, toStoredSigningKeyFor)+import Shomei.SigningKey.Protection.Jwt (KeyEncryptionKey, protectStoredSigningKey, publicJwkFromStored)+import Shomei.SigningKey.Store+  ( SigningKeyStore,+    listPublishableSigningKeys,+    replaceActiveSigningKey,+  )+import Shomei.Time.Store (Clock, now)++-- | Generate and encrypt a new active key, then retire whatever was active. Returns the live+-- 'JWK' so the caller can sign with it immediately.+rotateSigningKey ::+  (IOE :> es, SigningKeyStore :> es, Clock :> es) =>+  KeyEncryptionKey ->+  SigningAlgorithm ->+  Eff es JWK+rotateSigningKey kek alg = do+  t <- now+  newJwk <- liftIO (generateSigningKeyFor alg)+  stored <- liftIO (either (ioError . userError . Text.unpack) pure (toStoredSigningKeyFor alg t newJwk))+  protected <- liftIO (protectStoredSigningKey kek stored)+  replaceActiveSigningKey protected t+  pure newJwk++-- | Build the published JWKS from every publishable key: the active key(s) plus the+-- retired-but-still-trusted ones, so tokens signed just before a rotation keep verifying+-- until they expire. @pending@ and @revoked@ keys are excluded by the store's+-- 'listPublishableSigningKeys' contract.+--+-- Reads the __public__ column only, so it needs no key-encryption key and works unchanged+-- against a table whose private material is encrypted at rest.+currentJwks ::+  (SigningKeyStore :> es) =>+  Eff es BSL.ByteString+currentJwks = do+  keys <- listPublishableSigningKeys+  pure (jwksDocument (rights (map publicJwkFromStored keys)))
+ src/Shomei/SigningKey/Sign/Jwt.hs view
@@ -0,0 +1,221 @@+-- jose 0.13 deprecates addClaim/unregisteredClaims in favour of payload+-- subtypes; Shōmei deliberately carries sid/scopes/roles/permissions as custom+-- claims, so we silence that one deprecation here (see the EP-4 Decision Log).+{-# OPTIONS_GHC -Wno-deprecations #-}++-- | Building a @jose@ 'ClaimsSet' from Shōmei's 'AuthClaims', signing it into an+-- 'AccessToken', and the @effectful@ 'TokenSigner' interpreter.+--+-- The standard claims map directly (@iss@, @sub@, @aud@, @iat@, @exp@); the session+-- id, scopes, roles, and permissions travel as the custom claims @sid@, @scopes@,+-- @roles@, @permissions@. The protected JWS header's @alg@ is chosen from the key+-- material ('algForKey') and the signing key's @kid@ is copied in by hand so a+-- verifier can tell which key to use.+module Shomei.SigningKey.Sign.Jwt+  ( claimsFromAuth,+    claimsFromIdToken,+    wholeSeconds,+    signAccessToken,+    signIdToken,+    runTokenSignerJwt,+  )+where++import Control.Exception (throwIO)+import Crypto.JOSE.Compact (encodeCompact)+import Crypto.JOSE.Error (runJOSE)+import Crypto.JOSE.Header (newHeaderParamProtected)+import Crypto.JOSE.JWA.JWK (KeyMaterial (ECKeyMaterial, RSAKeyMaterial))+import Crypto.JOSE.JWA.JWS (Alg (ES256, RS256))+import Crypto.JOSE.JWK (JWK, jwkMaterial)+import Crypto.JOSE.JWS (newJWSHeaderProtected)+import Crypto.JOSE.JWS qualified as JWS+import Crypto.JWT+  ( Audience (Audience),+    ClaimsSet,+    JWTError,+    NumericDate (NumericDate),+    SignedJWT,+    StringOrURI,+    addClaim,+    claimAud,+    claimExp,+    claimIat,+    claimIss,+    claimSub,+    emptyClaimsSet,+    signClaims,+  )+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Lazy qualified as BSL+import Data.Set qualified as Set+import Data.String (fromString)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)+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)+import Shomei.Id (idText)+import Shomei.OAuth.IdToken.Domain (IdToken (IdToken), IdTokenClaims (..))+import Shomei.Prelude+import Shomei.Session.Token.Domain (AccessToken (AccessToken))+import Shomei.SigningKey.Key.Jwt (keyKid)+import Shomei.SigningKey.Signer (TokenSigner (SignAccessToken, SignIdToken))++issuerText :: Domain.Issuer -> Text+issuerText (Domain.Issuer t) = t++audienceText :: Domain.Audience -> Text+audienceText (Domain.Audience t) = t++-- | Build a 'StringOrURI' in the canonical form jose produces when it parses a+-- claim back from JSON (a scheme-bearing string becomes a URI, otherwise an+-- arbitrary string), so signed and verified values compare equal.+sou :: Text -> StringOrURI+sou = fromString . Text.unpack++-- | Build a @jose@ 'ClaimsSet' from Shōmei's 'AuthClaims'. Standard claims map+-- directly; session id, scopes, roles, and permissions travel as the custom claims+-- @sid@, @scopes@, @roles@, @permissions@.+claimsFromAuth :: AuthClaims -> ClaimsSet+claimsFromAuth 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+    -- custom claim (sid/scopes/roles/permissions below, act in 'withActor') and is+    -- applied on top, so a same-named custom key is always overwritten by Shōmei's+    -- value. Combined with 'mkExtraClaims' dropping reserved keys at construction, a+    -- service (or attacker-influenced input) can never forge a standard claim.+    addExtra ac.extraClaims emptyClaimsSet+      & claimIss+      ?~ sou (issuerText ac.issuer)+      & claimSub+      ?~ sou (idText ac.subject)+      & claimAud+      ?~ Audience [sou (audienceText ac.audience)]+      & claimIat+      ?~ NumericDate (wholeSeconds ac.issuedAt)+      & claimExp+      ?~ NumericDate (wholeSeconds ac.expiresAt)+      & addClaim "sid" (Aeson.String (idText ac.sessionId))+      & addClaim "scopes" (Aeson.toJSON (Set.toList ac.scopes))+      & addClaim "roles" (Aeson.toJSON (Set.toList ac.roles))+      & 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+    -- 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))+      Nothing -> cs++-- | The JWS algorithm to sign with for a given key, chosen directly from the key+-- material so the header can never disagree with the key. Crucially we pick 'RS256'+-- (RSASSA-PKCS1-v1_5) for RSA keys — NOT the RSASSA-PSS variant @jose@'s+-- @bestJWSAlg@/@makeJWSHeader@ would prefer (PS512), which existing JWT consumers and+-- downstream verifiers reject. Our generators only ever produce EC or RSA keys.+algForKey :: JWK -> Alg+algForKey jwk = case view jwkMaterial jwk of+  RSAKeyMaterial _ -> RS256+  ECKeyMaterial _ -> ES256+  _ -> ES256++-- | Sign an 'AuthClaims' into an 'AccessToken' using the given (active, private)+-- key. The protected header's @alg@ is pinned by 'algForKey' (RS256 for RSA, ES256+-- 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+  let hdr =+        newJWSHeaderProtected (algForKey jwk)+          & JWS.kid+          ?~ newHeaderParamProtected (keyKid jwk)+          & JWS.typ+          ?~ newHeaderParamProtected "at+jwt"+  result <- runJOSE @JWTError $ do+    signed <- signClaims jwk hdr (claimsFromAuth ac)+    pure (encodeCompact (signed :: SignedJWT))+  pure $ case result of+    Left e -> Left e+    Right wire -> Right (AccessToken (Text.decodeUtf8 (BSL.toStrict wire)))++-- | Build a @jose@ 'ClaimsSet' for an OIDC ID token (OIDC Core §2), mirroring 'claimsFromAuth'.+--+-- Deliberately narrow. @aud@ is the @client_id@, not the API audience, so the token cannot be+-- replayed at a resource server; and there is no @sid@, no @scopes@, no @roles@, and no+-- @permissions@, because an ID token is a statement about an authentication, not a bearer+-- credential.+--+-- @auth_time@ is a JSON /number/ of Unix seconds, as OIDC Core requires — not an RFC 3339 string,+-- which is what @toJSON \@UTCTime@ would produce and what a relying party would reject.+claimsFromIdToken :: IdTokenClaims -> ClaimsSet+claimsFromIdToken idc =+  withNonce $+    emptyClaimsSet+      & claimIss+      ?~ sou (issuerText idc.issuer)+      & claimSub+      ?~ sou (idText idc.subject)+      & claimAud+      ?~ Audience [sou idc.audience]+      & claimIat+      ?~ NumericDate (wholeSeconds idc.issuedAt)+      & claimExp+      ?~ NumericDate (wholeSeconds idc.expiresAt)+      & addClaim "auth_time" (Aeson.Number (fromIntegral (unixSeconds idc.authTime)))+  where+    unixSeconds :: UTCTime -> Integer+    unixSeconds = floor . utcTimeToPOSIXSeconds++    -- Present only when the authorize request sent one, so a client that sent no nonce does not+    -- receive a null it must then decide how to interpret.+    withNonce cs = case idc.nonce of+      Just n -> cs & addClaim "nonce" (Aeson.String n)+      Nothing -> cs++-- | Sign an 'IdTokenClaims' with the same active key, @alg@ and @kid@ as 'signAccessToken', so the+-- ID token verifies against the very JWKS document this deployment already publishes.+signIdToken :: JWK -> IdTokenClaims -> IO (Either JWTError IdToken)+signIdToken jwk idc = do+  let hdr =+        newJWSHeaderProtected (algForKey jwk)+          & JWS.kid+          ?~ newHeaderParamProtected (keyKid jwk)+          & JWS.typ+          ?~ newHeaderParamProtected "JWT"+  result <- runJOSE @JWTError $ do+    signed <- signClaims jwk hdr (claimsFromIdToken idc)+    pure (encodeCompact (signed :: SignedJWT))+  pure $ case result of+    Left e -> Left e+    Right wire -> Right (IdToken (Text.decodeUtf8 (BSL.toStrict wire)))++-- | Interpret the 'TokenSigner' effect by signing with a fixed active private key.+runTokenSignerJwt ::+  (IOE :> es) =>+  JWK ->+  ShomeiConfig ->+  Eff (TokenSigner : es) a ->+  Eff es a+runTokenSignerJwt jwk _cfg = interpret_ \case+  SignAccessToken ac -> do+    r <- liftIO (signAccessToken jwk ac)+    case r of+      Right tok -> pure tok+      Left e -> liftIO (throwIO (userError ("token signing failed: " <> show e)))+  SignIdToken idc -> do+    r <- liftIO (signIdToken jwk idc)+    case r of+      Right tok -> pure tok+      Left e -> liftIO (throwIO (userError ("id token signing failed: " <> show e)))++-- | JWT NumericDate values are whole Unix seconds. Truncating at the issuer+-- avoids making successful verification depend on sub-second clock ordering.+wholeSeconds :: UTCTime -> UTCTime+wholeSeconds = posixSecondsToUTCTime . fromInteger . floor . utcTimeToPOSIXSeconds
+ src/Shomei/SigningKey/Verify/Jwt.hs view
@@ -0,0 +1,265 @@+-- jose 0.13 deprecates addClaim/unregisteredClaims in favour of payload+-- subtypes; Shōmei deliberately reads sid/scopes/roles/permissions as custom+-- claims, so we silence that one deprecation here (see the EP-4 Decision Log).+{-# OPTIONS_GHC -Wno-deprecations #-}++-- | Verifying a compact JWT back into Shōmei's 'AuthClaims', the @effectful@+-- 'TokenVerifier' interpreter, and the jose-error → 'TokenError' mapping.+--+-- 'verifyToken' is the EP-4 ↔ EP-5 contract: EP-5's Servant @Authenticated@+-- combinator runs inside an @AuthHandler@ (plain 'IO', not @effectful@), so it+-- calls this ordinary-'IO' verifier directly. The @effectful@ interpreter+-- 'runTokenVerifierJwt' is implemented on top of the same 'verifyToken'.+module Shomei.SigningKey.Verify.Jwt+  ( VerifierSettings (..),+    verifierSettingsFromConfig,+    KidSelectingKeys (..),+    checkStringOrUri,+    verifyTokenWith,+    verifyToken,+    runTokenVerifierJwt,+    jwtErrorToTokenError,+  )+where++import Crypto.JOSE.Compact (decodeCompact)+import Crypto.JOSE.Error (Error (..), runJOSE)+import Crypto.JOSE.Header (HasKid (kid), HasTyp (typ), param)+import Crypto.JOSE.JWA.JWS (Alg (ES256, RS256))+import Crypto.JOSE.JWK (JWKSet (JWKSet), jwkKid)+import Crypto.JOSE.JWK.Store (VerificationKeyStore (getVerificationKeys))+import Crypto.JOSE.JWS (header, signatures, validationSettingsAlgorithms)+import Crypto.JWT+  ( Audience (Audience),+    ClaimsSet,+    JWTError (..),+    NumericDate (NumericDate),+    SignedJWT,+    StringOrURI,+    allowedSkew,+    claimAud,+    claimExp,+    claimIat,+    claimIss,+    claimSub,+    defaultJWTValidationSettings,+    issuerPredicate,+    stringOrUri,+    unregisteredClaims,+    verifyClaims,+  )+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (parseEither)+import Data.ByteString.Lazy qualified as BSL+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Time (NominalDiffTime)+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.Error (TokenError (..))+import Shomei.Id (parseId)+import Shomei.Prelude+import Shomei.Session.Token.Domain (AccessToken (AccessToken))+import Shomei.SigningKey.Verifier (TokenVerifier (VerifyAccessToken))++issuerText :: Domain.Issuer -> Text+issuerText (Domain.Issuer t) = t++audienceText :: Domain.Audience -> Text+audienceText (Domain.Audience t) = t++-- | Verification policy separated from the server's larger configuration so+-- downstream hosts can choose strict token-type enforcement independently.+data VerifierSettings = VerifierSettings+  { issuer :: !Domain.Issuer,+    audience :: !Domain.Audience,+    allowedClockSkew :: !NominalDiffTime,+    requireTokenType :: !Bool+  }+  deriving stock (Eq, Show)++verifierSettingsFromConfig :: ShomeiConfig -> VerifierSettings+verifierSettingsFromConfig cfg =+  VerifierSettings+    { issuer = cfg.issuer,+      audience = cfg.audience,+      allowedClockSkew = fromIntegral cfg.signingKeyConfig.allowedClockSkewSeconds,+      requireTokenType = False+    }++-- | 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++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+      Just wanted -> filter ((== Just wanted) . view jwkKid) keys+      Nothing -> []++-- | Validate the RFC 7519 StringOrURI shape without using its partial+-- 'IsString' instance.+checkStringOrUri :: Text -> Either Text ()+checkStringOrUri value = case preview stringOrUri value of+  Just (_ :: StringOrURI) -> Right ()+  Nothing -> Left "contains ':' but is not a valid URI (RFC 7519 StringOrURI)"++-- | 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 verifierSettings jwks raw = do+  let bytes = BSL.fromStrict (Text.encodeUtf8 raw)+      matches wanted = maybe (const False) (==) (preview stringOrUri wanted)+      settings =+        defaultJWTValidationSettings (matches (audienceText verifierSettings.audience))+          & issuerPredicate+          .~ matches (issuerText verifierSettings.issuer)+          & allowedSkew+          .~ verifierSettings.allowedClockSkew+          & validationSettingsAlgorithms+          .~ Set.fromList [ES256, RS256]+  decoded <- runJOSE @JWTError do+    signed <- decodeCompact bytes+    pure (signed :: SignedJWT)+  case decoded of+    Left err -> pure (Left (jwtErrorToTokenError err))+    Right signed -> do+      let headerKid = signed ^? signatures . header . kid . _Just . param+          headerType = signed ^? signatures . header . typ . _Just . param+      result <- runJOSE @JWTError (verifyClaims settings (KidSelectingKeys jwks) signed)+      pure case result of+        Left (JWSError NoUsableKeys) -> Left (TokenKeyNotFound headerKid)+        Left err -> Left (jwtErrorToTokenError err)+        Right claims -> checkTokenType verifierSettings headerType *> claimsToAuth claims++-- | THE core/Servant contract. Existing callers receive the hardened verifier+-- through the unchanged public function.+verifyToken :: JWKSet -> ShomeiConfig -> Text -> IO (Either TokenError AuthClaims)+verifyToken jwks cfg = verifyTokenWith (verifierSettingsFromConfig cfg) jwks++-- | Interpret the 'TokenVerifier' effect over a fixed public 'JWKSet'.+runTokenVerifierJwt ::+  (IOE :> es) =>+  JWKSet ->+  ShomeiConfig ->+  Eff (TokenVerifier : es) a ->+  Eff es a+runTokenVerifierJwt jwks cfg = interpret_ \case+  VerifyAccessToken (AccessToken raw) -> liftIO (verifyToken jwks cfg raw)++-- | Map jose's 'JWTError' into the core's transport-agnostic 'TokenError'.+jwtErrorToTokenError :: JWTError -> TokenError+jwtErrorToTokenError = \case+  JWTExpired -> TokenExpired+  JWTNotYetValid -> TokenOtherError "token not yet valid"+  JWTNotInIssuer -> TokenIssuerInvalid+  JWTNotInAudience -> TokenAudienceInvalid+  JWTIssuedAtFuture -> TokenOtherError "iat in the future"+  JWTClaimsSetDecodeError _ -> TokenMalformed+  JWSError e -> jwsErrorToTokenError e++-- | Map the inner JWS 'Error' (wrapped by 'JWSError') into a 'TokenError'.+jwsErrorToTokenError :: Error -> TokenError+jwsErrorToTokenError = \case+  CompactDecodeError _ -> TokenMalformed+  JSONDecodeError _ -> TokenMalformed+  AlgorithmNotImplemented -> TokenSignatureInvalid+  AlgorithmMismatch _ -> TokenSignatureInvalid+  KeyMismatch _ -> TokenSignatureInvalid+  JWSInvalidSignature -> TokenSignatureInvalid+  JWSNoValidSignatures -> TokenSignatureInvalid+  JWSNoSignatures -> TokenSignatureInvalid+  NoUsableKeys -> TokenKeyNotFound Nothing+  other -> TokenOtherError (Text.pack (show other))++checkTokenType :: VerifierSettings -> Maybe Text -> Either TokenError ()+checkTokenType settings = \case+  Nothing+    | settings.requireTokenType -> Left (TokenOtherError "missing typ header")+    | otherwise -> Right ()+  Just tokenType+    | Text.toCaseFold tokenType `elem` ["at+jwt", "application/at+jwt"] -> Right ()+    | 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+  subTxt <- note "missing sub" (cs ^. claimSub >>= soText)+  subj <- mapLeft (const TokenMalformed) (parseId subTxt)+  sidTxt <- note "missing sid" (lookupString "sid")+  sess <- mapLeft (const TokenMalformed) (parseId sidTxt)+  issTxt <- note "missing iss" (cs ^. claimIss >>= soText)+  audTxt <- exactAudience (cs ^. claimAud)+  issuedAt' <- note "missing iat" (dateOf (cs ^. claimIat))+  expiresAt' <- note "missing exp" (dateOf (cs ^. claimExp))+  authTime' <- case Map.lookup "auth_time" claims of+    Nothing -> Right issuedAt'+    Just value -> case parseEither Aeson.parseJSON value of+      Left _ -> Left TokenMalformed+      Right (NumericDate t) -> Right t+  scopeValues <- lookupStringList "scopes"+  roleValues <- lookupStringList "roles"+  permissionValues <- lookupStringList "permissions"+  let scs = Set.fromList (map Domain.Scope scopeValues)+      rls = Set.fromList (map Domain.Role roleValues)+      perms = Set.fromList (map Domain.Permission permissionValues)+      -- The custom claims Shōmei manages itself; everything else in the+      -- unregistered map is the consuming service's extra bag, returned verbatim.+      -- (The registered iss/sub/aud/iat/exp claims are never in this map.)+      managed = Domain.reservedClaimKeys+      extra =+        KeyMap.fromList+          [ (Key.fromText k, v)+          | (k, v) <- Map.toList claims,+            k `notElem` managed+          ]+  -- The @act@ claim is present only on delegated (impersonation) tokens. Absent+  -- → 'Nothing'; present but unparseable → a malformed token.+  actor' <- case lookupString "act" of+    Nothing -> Right Nothing+    Just actTxt -> Just <$> mapLeft (const TokenMalformed) (parseId actTxt)+  pure+    AuthClaims+      { subject = subj,+        sessionId = sess,+        issuer = Domain.Issuer issTxt,+        audience = Domain.Audience audTxt,+        issuedAt = issuedAt',+        expiresAt = expiresAt',+        authTime = authTime',+        scopes = scs,+        roles = rls,+        permissions = perms,+        actor = actor',+        extraClaims = extra+      }+  where+    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+    -- string, so toJSON recovers the original text for both forms.+    soText :: StringOrURI -> Maybe Text+    soText s = case Aeson.toJSON s of+      Aeson.String t -> Just t+      _ -> Nothing+    dateOf = fmap (\(NumericDate t) -> t)+    exactAudience = \case+      Nothing -> Left (TokenOtherError "missing aud")+      Just (Audience [singleAudience]) -> maybe (Left TokenAudienceInvalid) Right (soText singleAudience)+      Just _ -> Left TokenAudienceInvalid+    claims :: Map Text Aeson.Value+    claims = cs ^. unregisteredClaims+    lookupString k = case Map.lookup k claims of+      Just (Aeson.String s) -> Just s+      _ -> Nothing+    lookupStringList k = case Map.lookup k claims of+      Just v -> mapLeft (const TokenMalformed) (parseEither Aeson.parseJSON v)+      Nothing -> Right []
+ test/Main.hs view
@@ -0,0 +1,28 @@+module Main (main) where++import Shomei.SigningKey.Interpreter.JwtSpec qualified as InterpreterSpec+import Shomei.SigningKey.Jwks.JwtSpec qualified as JwksSpec+import Shomei.SigningKey.Key.JwtSpec qualified as KeySpec+import Shomei.SigningKey.Protection.JwtSpec qualified as KeyProtectionSpec+import Shomei.SigningKey.Rotation.JwtSpec qualified as RotationSpec+import Shomei.SigningKey.Sign.IdTokenSpec qualified as IdTokenSpec+import Shomei.SigningKey.Sign.JwtSpec qualified as SignVerifySpec+import Shomei.SigningKey.Sign.RsaCustomClaimSpec qualified as RsaCustomClaimSpec+import Shomei.SigningKey.Verify.JwtSpec qualified as VerifySpec+import Test.Tasty (defaultMain, testGroup)++main :: IO ()+main =+  defaultMain $+    testGroup+      "shomei-jwt"+      [ KeySpec.tests,+        KeyProtectionSpec.tests,+        SignVerifySpec.tests,+        IdTokenSpec.tests,+        JwksSpec.tests,+        InterpreterSpec.tests,+        RotationSpec.tests,+        RsaCustomClaimSpec.tests,+        VerifySpec.tests+      ]
+ test/Shomei/SigningKey/Interpreter/JwtSpec.hs view
@@ -0,0 +1,30 @@+-- | Scenario (h): the @effectful@ interpreters. Inside an 'Eff' computation,+-- 'runTokenSignerJwt' mints a token and 'runTokenVerifierJwt' verifies it; the+-- recovered claims equal the originals.+module Shomei.SigningKey.Interpreter.JwtSpec (tests) where++import Data.Time (getCurrentTime)+import Effectful (runEff)+import Shomei.SigningKey.Key.Jwt (generateSigningKey)+import Shomei.SigningKey.Sign.Jwt (runTokenSignerJwt)+import Shomei.SigningKey.Signer (signAccessToken)+import Shomei.SigningKey.TestSupport (coreFields, mkClaims, publicJwks, testConfig)+import Shomei.SigningKey.Verifier (verifyAccessToken)+import Shomei.SigningKey.Verify.Jwt (runTokenVerifierJwt)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Interpreter"+    [ testCase "sign-then-verify in Eff round-trips" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        tok <- runEff (runTokenSignerJwt jwk testConfig (signAccessToken ac))+        res <- runEff (runTokenVerifierJwt (publicJwks jwk []) testConfig (verifyAccessToken tok))+        case res of+          Right ac' -> coreFields ac' @?= coreFields ac+          Left e -> assertFailure ("verify failed: " <> show e)+    ]
+ test/Shomei/SigningKey/Jwks/JwtSpec.hs view
@@ -0,0 +1,45 @@+-- | Scenario (g), JWKS-shape half: 'jwksDocument' for two keys is valid JSON+-- with a top-level @"keys"@ array of two objects carrying the correct @kid@s and+-- no private @"d"@ field. The kid-selection half (sign with A, verify against+-- {A, B}) lives in 'Shomei.SigningKey.Sign.JwtSpec' because it needs the signer.+module Shomei.SigningKey.Jwks.JwtSpec (tests) where++import Data.Aeson (Value (Array, Object, String))+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString.Lazy (ByteString)+import Data.Foldable (toList)+import Data.List (sort)+import Data.Text (Text)+import Shomei.SigningKey.Jwks.Jwt (jwksDocument)+import Shomei.SigningKey.Key.Jwt (generateSigningKey, keyKid)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Jwks"+    [ testCase "publishes public-only JWKS with the right kids" $ do+        a <- generateSigningKey+        b <- generateSigningKey+        objs <- keysArray (jwksDocument [a, b])+        length objs @?= 2+        assertBool "no private d field" (not (any (KM.member (Key.fromText "d")) objs))+        sort (kidsOf objs) @?= sort [keyKid a, keyKid b]+    ]++-- | Decode a JWKS document and return the objects in its @"keys"@ array.+keysArray :: ByteString -> IO [KM.KeyMap Value]+keysArray doc =+  case Aeson.decode doc of+    Just (Object top) ->+      case KM.lookup (Key.fromText "keys") top of+        Just (Array arr) -> pure [o | Object o <- toList arr]+        _ -> assertFailure "JWKS has no \"keys\" array" >> pure []+    _ -> assertFailure "JWKS is not a JSON object" >> pure []++-- | Extract the @"kid"@ string of each key object.+kidsOf :: [KM.KeyMap Value] -> [Text]+kidsOf objs = [k | o <- objs, Just (String k) <- [KM.lookup (Key.fromText "kid") o]]
+ test/Shomei/SigningKey/Key/JwtSpec.hs view
@@ -0,0 +1,52 @@+-- | Scenario (a): a generated ES256 key round-trips through 'StoredSigningKey'+-- without losing its @kid@.+module Shomei.SigningKey.Key.JwtSpec (tests) where++import Crypto.JOSE.JWK (fromOctets)+import Data.ByteString.Char8 qualified as BS8+import Data.ByteString.Lazy qualified as BSL+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Time (getCurrentTime)+import Shomei.SigningKey.Domain (SigningAlgorithm (RS256), StoredSigningKey (..))+import Shomei.SigningKey.Jwks.Jwt (jwksDocument)+import Shomei.SigningKey.Key.Jwt+  ( fromStoredSigningKey,+    generateSigningKey,+    generateSigningKeyFor,+    keyKid,+    toStoredSigningKey,+    toStoredSigningKeyFor,+  )+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Key"+    [ testCase "round-trips a key with stable kid" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        stored <- either (assertFailure . Text.unpack) pure (toStoredSigningKey t jwk)+        assertBool "kid is non-empty" (not (Text.null stored.keyId))+        case fromStoredSigningKey stored of+          Left err -> assertFailure ("decode failed: " <> Text.unpack err)+          Right jwk' -> keyKid jwk' @?= stored.keyId,+      testCase "generates an RS256 key recorded as RS256 with a kid and an RSA JWKS" $ do+        jwk <- generateSigningKeyFor RS256+        t <- getCurrentTime+        sk <- either (assertFailure . Text.unpack) pure (toStoredSigningKeyFor RS256 t jwk)+        sk.algorithm @?= "RS256"+        assertBool "kid is non-empty" (not (Text.null sk.keyId))+        let doc = jwksDocument [jwk]+        assertBool+          "JWKS contains an RSA key"+          ("\"kty\":\"RSA\"" `Text.isInfixOf` Text.decodeUtf8 (BSL.toStrict doc)),+      testCase "refuses a key with no public projection" $ do+        t <- getCurrentTime+        let symmetric = fromOctets (BS8.pack "not-a-public-key")+        case toStoredSigningKey t symmetric of+          Left reason -> assertBool "error names the public-material boundary" ("refusing to store private material as public" `Text.isInfixOf` reason)+          Right _ -> assertFailure "a symmetric key must not be copied into public_key_jwk"+    ]
+ test/Shomei/SigningKey/Protection/JwtSpec.hs view
@@ -0,0 +1,186 @@+-- | Envelope encryption of stored private signing keys. The properties that matter:+-- a round trip recovers the key; a wrong KEK, a tampered ciphertext, or a ciphertext moved+-- to another row's @kid@ all fail authentication indistinguishably; unencrypted rows are+-- rejected; and independent encryptions use fresh nonces.+module Shomei.SigningKey.Protection.JwtSpec (tests) where++import Data.ByteArray.Encoding (Base (Base64), convertToBase)+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as BS8+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TE+import Data.Time (UTCTime (..), fromGregorian, getCurrentTime)+import Effectful (runEff)+import Shomei.SigningKey.Domain (SigningAlgorithm (ES256, RS256), StoredSigningKey (..))+import Shomei.SigningKey.Key.Jwt (generateSigningKeyFor, toStoredSigningKeyFor)+import Shomei.SigningKey.Protection.Jwt+  ( KeyDecryptError (..),+    KeyEncryptionKey,+    decryptPrivateJwk,+    decryptStoredSigningKey,+    encryptPrivateJwk,+    isEncryptedPrivateJwk,+    keyEncryptionKeyFromBase64,+    protectStoredSigningKey,+    publicJwkFromStored,+  )+import Shomei.SigningKey.Sign.Jwt (runTokenSignerJwt)+import Shomei.SigningKey.Signer (signAccessToken)+import Shomei.SigningKey.TestSupport (coreFields, mkClaims, publicJwks, testConfig)+import Shomei.SigningKey.Verifier (verifyAccessToken)+import Shomei.SigningKey.Verify.Jwt (runTokenVerifierJwt)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "KeyProtection"+    [ testGroup "KEK parsing" kekParsing,+      testGroup "envelope" envelope,+      testGroup "stored keys" storedKeys+    ]++kekParsing :: [TestTree]+kekParsing =+  [ testCase "accepts 32 base64 bytes" do+      either (assertFailure . Text.unpack) (const (pure ())) (keyEncryptionKeyFromBase64 (kekText 32)),+    testCase "rejects a 31-byte key and says how to make one" do+      case keyEncryptionKeyFromBase64 (kekText 31) of+        Right _ -> assertFailure "a 31-byte KEK must be rejected"+        Left err -> do+          assertBool ("names the length: " <> Text.unpack err) ("31 bytes" `Text.isInfixOf` err)+          assertBool "gives the generation recipe" ("/dev/urandom" `Text.isInfixOf` err),+    testCase "rejects non-base64" do+      case keyEncryptionKeyFromBase64 "not base64 !!!" of+        Right _ -> assertFailure "invalid base64 must be rejected"+        Left err -> assertBool ("names base64: " <> Text.unpack err) ("base64" `Text.isInfixOf` err),+    testCase "tolerates surrounding whitespace (a trailing newline from `| base64`)" do+      either (assertFailure . Text.unpack) (const (pure ())) (keyEncryptionKeyFromBase64 (kekText 32 <> "\n"))+  ]++envelope :: [TestTree]+envelope =+  [ testCase "round-trips" do+      kek <- testKek 1+      enc <- encryptPrivateJwk kek "kid-a" plaintextJwk+      assertBool "is tagged as encrypted" (isEncryptedPrivateJwk enc)+      decryptPrivateJwk kek "kid-a" enc @?= Right plaintextJwk,+    testCase "unencrypted private material is rejected" do+      kek <- testKek 1+      assertBool "plaintext is not tagged" (not (isEncryptedPrivateJwk plaintextJwk))+      case decryptPrivateJwk kek "kid-a" plaintextJwk of+        Left (MalformedEncryptedKey _) -> pure ()+        other -> assertFailure ("expected MalformedEncryptedKey, got " <> show other),+    testCase "the wrong KEK fails authentication" do+      kek <- testKek 1+      other <- testKek 2+      enc <- encryptPrivateJwk kek "kid-a" plaintextJwk+      decryptPrivateJwk other "kid-a" enc @?= Left KeyDecryptFailed,+    testCase "a flipped ciphertext byte fails authentication" do+      kek <- testKek 1+      enc <- encryptPrivateJwk kek "kid-a" plaintextJwk+      decryptPrivateJwk kek "kid-a" (tamper enc) @?= Left KeyDecryptFailed,+    testCase "a ciphertext moved to another row's kid fails (the AAD binding)" do+      -- This is what stops an attacker with write access from relabeling an old,+      -- compromised key as the active one.+      kek <- testKek 1+      enc <- encryptPrivateJwk kek "kid-a" plaintextJwk+      decryptPrivateJwk kek "kid-b" enc @?= Left KeyDecryptFailed,+    testCase "a structurally broken envelope is distinguished from a failed tag" do+      kek <- testKek 1+      case decryptPrivateJwk kek "kid-a" "enc:v1:nope" of+        Left (MalformedEncryptedKey _) -> pure ()+        other -> assertFailure ("expected MalformedEncryptedKey, got " <> show other),+    testCase "a short nonce is rejected" do+      kek <- testKek 1+      case decryptPrivateJwk kek "kid-a" "enc:v1:AAAA:AAAAAAAAAAAAAAAAAAAAAA" of+        Left (MalformedEncryptedKey msg) -> assertBool ("names the nonce: " <> Text.unpack msg) ("nonce" `Text.isInfixOf` msg)+        other -> assertFailure ("expected MalformedEncryptedKey, got " <> show other),+    testCase "encrypting the same plaintext twice yields different ciphertexts (fresh nonce)" do+      kek <- testKek 1+      a <- encryptPrivateJwk kek "kid-a" plaintextJwk+      b <- encryptPrivateJwk kek "kid-a" plaintextJwk+      assertBool "nonces must not repeat" (a /= b)+      decryptPrivateJwk kek "kid-a" a @?= Right plaintextJwk+      decryptPrivateJwk kek "kid-a" b @?= Right plaintextJwk+  ]++storedKeys :: [TestTree]+storedKeys =+  [ testCase "protect → decrypt → sign → verify round-trips an ES256 key" (protectAndUse ES256),+    testCase "protect → decrypt → sign → verify round-trips an RS256 key" (protectAndUse RS256),+    testCase "protecting is idempotent: an encrypted row is returned unchanged" do+      kek <- testKek 1+      stored <- storedKeyFor ES256+      once <- protectStoredSigningKey kek stored+      twice <- protectStoredSigningKey kek once+      -- Not merely "still decrypts": the bytes must be identical, or a re-run of the+      -- backfill would rewrite every row (and burn a nonce) for nothing.+      twice.privateKeyJwk @?= once.privateKeyJwk,+    testCase "the public column is never encrypted, and parses without a KEK" do+      kek <- testKek 1+      stored <- storedKeyFor ES256+      protected <- protectStoredSigningKey kek stored+      protected.publicKeyJwk @?= stored.publicKeyJwk+      assertBool "private material is encrypted" (isEncryptedPrivateJwk protected.privateKeyJwk)+      case publicJwkFromStored protected of+        Right _ -> pure ()+        Left err -> assertFailure ("public key must parse with no KEK: " <> Text.unpack err),+    testCase "decryptStoredSigningKey reports a decryptable-but-invalid payload distinctly" do+      kek <- testKek 1+      stored <- storedKeyFor ES256+      enc <- encryptPrivateJwk kek stored.keyId "not json at all"+      case decryptStoredSigningKey kek stored {privateKeyJwk = enc} of+        Left (KeyJsonInvalid _) -> pure ()+        other -> assertFailure ("expected KeyJsonInvalid, got " <> show (() <$ other))+  ]++-- | Generate a key, store it, encrypt it, recover it, and prove the recovered key still+-- signs a token that verifies against the published public key.+protectAndUse :: SigningAlgorithm -> IO ()+protectAndUse alg = do+  kek <- testKek 1+  stored <- storedKeyFor alg+  protected <- protectStoredSigningKey kek stored+  assertBool "private material is encrypted at rest" (isEncryptedPrivateJwk protected.privateKeyJwk)+  signer <- case decryptStoredSigningKey kek protected of+    Right jwk -> pure jwk+    Left err -> assertFailure ("decrypt failed: " <> show err)+  pub <- either (assertFailure . Text.unpack) pure (publicJwkFromStored protected)+  -- Claims are minted against the real clock: the verifier checks expiry, so a fixed epoch+  -- would make this test start failing an hour into the day it was written.+  now <- getCurrentTime+  claims <- mkClaims testConfig now+  tok <- runEff (runTokenSignerJwt signer testConfig (signAccessToken claims))+  result <- runEff (runTokenVerifierJwt (publicJwks pub []) testConfig (verifyAccessToken tok))+  case result of+    Right recovered -> coreFields recovered @?= coreFields claims+    Left e -> assertFailure ("a token signed with the decrypted key must verify: " <> show e)++storedKeyFor :: SigningAlgorithm -> IO StoredSigningKey+storedKeyFor alg = do+  jwk <- generateSigningKeyFor alg+  either (assertFailure . Text.unpack) pure (toStoredSigningKeyFor alg epoch jwk)++epoch :: UTCTime+epoch = UTCTime (fromGregorian 2026 7 8) 0++-- | A deterministic, distinct KEK per seed byte.+testKek :: Int -> IO KeyEncryptionKey+testKek seed = either (assertFailure . Text.unpack) pure (keyEncryptionKeyFromBase64 (kekTextFrom (toEnum (0x40 + seed))))++kekText :: Int -> Text+kekText n = TE.decodeUtf8 (convertToBase Base64 (BS.replicate n 0x2a))++kekTextFrom :: Char -> Text+kekTextFrom c = TE.decodeUtf8 (convertToBase Base64 (BS8.replicate 32 c))++-- | A JWK-shaped plaintext; the envelope does not care that it is well-formed.+plaintextJwk :: Text+plaintextJwk = "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"private-scalar\"}"++-- | Flip the last character of the base64url ciphertext.+tamper :: Text -> Text+tamper enc = Text.init enc <> if Text.last enc == 'A' then "B" else "A"
+ test/Shomei/SigningKey/Rotation/JwtSpec.hs view
@@ -0,0 +1,136 @@+-- | The signing-key lifecycle over the in-memory store: publication filters lifecycle+-- states, rotation replaces the active key atomically, and revocation removes trust.+module Shomei.SigningKey.Rotation.JwtSpec (tests) where++import Crypto.JOSE.JWK (JWKSet)+import Data.Aeson (Value (Array, Object, String))+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM+import Data.ByteArray.Encoding (Base (Base64), convertToBase)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy (ByteString)+import Data.Foldable (toList, traverse_)+import Data.IORef (newIORef, readIORef)+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Time (UTCTime (..), fromGregorian, getCurrentTime)+import Effectful (runEff)+import Shomei.Error (TokenError (TokenKeyNotFound))+import Shomei.Session.Token.Domain (AccessToken (AccessToken))+import Shomei.SigningKey.Domain (SigningAlgorithm (ES256), SigningKeyStatus (..), StoredSigningKey (..))+import Shomei.SigningKey.Key.Jwt (generateSigningKey, keyKid, toStoredSigningKey)+import Shomei.SigningKey.Protection.Jwt (KeyEncryptionKey, keyEncryptionKeyFromBase64)+import Shomei.SigningKey.Rotation.Jwt (currentJwks, rotateSigningKey)+import Shomei.SigningKey.Sign.Jwt (signAccessToken)+import Shomei.SigningKey.Store (insertSigningKey, updateSigningKeyStatus)+import Shomei.SigningKey.TestSupport (mkClaims, testConfig)+import Shomei.SigningKey.Verify.Jwt (verifyToken)+import Shomei.Test.InMemory (World (..), emptyWorld, runClock, runSigningKeyStore)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Rotation"+    [ testCase "currentJwks publishes active + retired, not pending or revoked" $ do+        activeK <- generateSigningKey+        retiredK <- generateSigningKey+        pendingK <- generateSigningKey+        revokedK <- generateSigningKey+        stored <-+          either (assertFailure . show) pure $+            traverse+              (\(k, st) -> (\sk -> sk {status = st}) <$> toStoredSigningKey epoch k)+              [ (activeK, KeyActive),+                (retiredK, KeyRetired),+                (pendingK, KeyPending),+                (revokedK, KeyRevoked)+              ]+        ref <- newIORef (emptyWorld epoch)+        doc <- runEff . runSigningKeyStore ref $ do+          traverse_ insertSigningKey stored+          currentJwks+        published <- kidsOf doc+        sort published @?= sort [keyKid activeK, keyKid retiredK]+        assertAbsent "pending" (keyKid pendingK) published+        assertAbsent "revoked" (keyKid revokedK) published,+      testCase "rotation leaves one active key and publishes alg on both overlap keys" $ do+        oldJwk <- generateSigningKey+        old <- either (assertFailure . show) pure (toStoredSigningKey epoch oldJwk)+        kek <- testKek+        ref <- newIORef (emptyWorld epoch)+        newJwk <-+          runEff . runClock ref . runSigningKeyStore ref $ do+            insertSigningKey old+            rotateSigningKey kek ES256+        world <- readIORef ref+        let rows = Map.elems world.signingKeys+            activeRows = filter ((== KeyActive) . (.status)) rows+        fmap (.keyId) activeRows @?= [keyKid newJwk]+        oldAfter <- maybe (assertFailure "old key disappeared during rotation") pure (Map.lookup old.keyId world.signingKeys)+        newAfter <- maybe (assertFailure "new key was not stored during rotation") pure (Map.lookup (keyKid newJwk) world.signingKeys)+        oldAfter.status @?= KeyRetired+        oldAfter.retiredAt @?= Just epoch+        newAfter.activatedAt @?= Just epoch+        doc <- runEff . runSigningKeyStore ref $ currentJwks+        published <- kidsOf doc+        sort published @?= sort [old.keyId, keyKid newJwk]+        algs <- algsOf doc+        assertBool "every published overlap key has ES256 alg" (length algs == 2 && all (== "ES256") algs),+      testCase "revoking a key removes it from the verifier set" $ do+        jwk <- generateSigningKey+        stored <- either (assertFailure . show) pure (toStoredSigningKey epoch jwk)+        now <- getCurrentTime+        claims <- mkClaims testConfig now+        AccessToken wire <- signAccessToken jwk claims >>= either (assertFailure . show) pure+        ref <- newIORef (emptyWorld epoch)+        before <- runEff . runSigningKeyStore ref $ do+          insertSigningKey stored+          currentJwks+        beforeSet <- decodeJwkSet before+        verifyToken beforeSet testConfig wire >>= either (assertFailure . show) (const (pure ()))+        after <- runEff . runSigningKeyStore ref $ do+          updateSigningKeyStatus stored.keyId KeyRevoked epoch+          currentJwks+        afterSet <- decodeJwkSet after+        rejected <- verifyToken afterSet testConfig wire+        rejected @?= Left (TokenKeyNotFound (Just stored.keyId))+    ]+  where+    epoch = UTCTime (fromGregorian 2026 8 27) 0+    assertAbsent label kid published+      | kid `elem` published = assertFailure (label <> " key " <> show kid <> " must not be published")+      | otherwise = pure ()++kidsOf :: ByteString -> IO [Text]+kidsOf doc =+  case Aeson.decode doc of+    Just (Object top) ->+      case KM.lookup (Key.fromText "keys") top of+        Just (Array arr) ->+          pure [kid | Object o <- toList arr, Just (String kid) <- [KM.lookup (Key.fromText "kid") o]]+        _ -> assertFailure "JWKS has no \"keys\" array" >> pure []+    _ -> assertFailure "JWKS is not a JSON object" >> pure []++algsOf :: ByteString -> IO [Text]+algsOf doc =+  case Aeson.decode doc of+    Just (Object top) ->+      case KM.lookup (Key.fromText "keys") top of+        Just (Array arr) ->+          pure [alg | Object o <- toList arr, Just (String alg) <- [KM.lookup (Key.fromText "alg") o]]+        _ -> assertFailure "JWKS has no \"keys\" array" >> pure []+    _ -> assertFailure "JWKS is not a JSON object" >> pure []++decodeJwkSet :: ByteString -> IO JWKSet+decodeJwkSet = maybe (assertFailure "JWKS did not decode as JWKSet") pure . Aeson.decode++testKek :: IO KeyEncryptionKey+testKek =+  either (assertFailure . Text.unpack) pure $+    keyEncryptionKeyFromBase64 (Text.decodeUtf8 (convertToBase Base64 (BS.replicate 32 0x2a)))
+ test/Shomei/SigningKey/Sign/IdTokenSpec.hs view
@@ -0,0 +1,140 @@+-- | EP-5: the OIDC ID token is signed by the same key machinery as the access token, and carries+-- exactly the claims OIDC Core §2 defines — no more.+--+-- An ID token a relying party cannot verify is worthless, so 'verifiesAgainstJwks' checks the real+-- signature against the real public JWK rather than merely decoding the payload. An ID token a+-- resource server /would/ accept as a bearer credential is dangerous, so 'notABearerCredential'+-- pins that the access-token verifier refuses it.+module Shomei.SigningKey.Sign.IdTokenSpec (tests) where++import Crypto.JOSE.Compact (decodeCompact)+import Crypto.JOSE.Error (runJOSE)+import Crypto.JOSE.JWK (JWK)+import Crypto.JWT (JWTError, SignedJWT, defaultJWTValidationSettings, verifyClaims)+import Data.Aeson (Object, Value (Number, String))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteArray.Encoding (Base (Base64URLUnpadded), convertFromBase)+import Data.ByteString (ByteString)+import Data.ByteString.Lazy qualified as LBS+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 (Issuer (..))+import Shomei.Error (TokenError (..))+import Shomei.Id (genUserId, idText)+import Shomei.OAuth.IdToken.Domain (IdToken (IdToken), IdTokenClaims (..))+import Shomei.SigningKey.Key.Jwt (generateSigningKey, keyKid)+import Shomei.SigningKey.Sign.Jwt (signIdToken)+import Shomei.SigningKey.TestSupport (publicJwks, testConfig, testIssuer)+import Shomei.SigningKey.Verify.Jwt (verifyToken)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "IdToken"+    [ testCase "signs an ID token that verifies against the published public key, with the access-token kid" verifiesAgainstJwks,+      testCase "carries iss/sub/aud/iat/exp/nonce/auth_time and nothing else" carriesExactlyTheOidcClaims,+      testCase "omits nonce entirely when the authorize request sent none" omitsAbsentNonce,+      testCase "auth_time is a number of Unix seconds, not an RFC 3339 string" authTimeIsANumber,+      testCase "an ID token is not a bearer credential: the access-token verifier refuses it" notABearerCredential+    ]++issuerText :: Issuer -> Text+issuerText (Issuer t) = t++-- | Sign an ID token for a user who authenticated an hour ago (so @auth_time@ and @iat@ differ),+-- returning the token, the claims, and the decoded JWS payload.+signWith :: JWK -> Maybe Text -> IO (IdToken, IdTokenClaims, Object)+signWith jwk nonce = do+  t <- getCurrentTime+  uid <- genUserId+  let idc =+        IdTokenClaims+          { issuer = testIssuer,+            subject = uid,+            audience = "oauthclient_01",+            issuedAt = t,+            expiresAt = addUTCTime 900 t,+            nonce,+            authTime = addUTCTime (-3600) t+          }+  r <- signIdToken jwk idc+  case r of+    Left e -> assertFailure ("id token signing failed: " <> show e)+    Right tok@(IdToken wire) -> do+      payload <- decodeSegment 1 wire+      pure (tok, idc, payload)++verifiesAgainstJwks :: Assertion+verifiesAgainstJwks = do+  jwk <- generateSigningKey+  (IdToken wire, _, _) <- signWith jwk (Just "n-0S6")+  -- Same key material and same kid as an access token: this is what makes the ID token checkable+  -- against the JWKS document the deployment already serves, with no new key work.+  header <- decodeSegment 0 wire+  KeyMap.lookup "kid" header @?= Just (String (keyKid jwk))+  KeyMap.lookup "typ" header @?= Just (String "JWT")+  result <-+    runJOSE @JWTError do+      jwt <- decodeCompact (LBS.fromStrict (Text.encodeUtf8 wire))+      verifyClaims (defaultJWTValidationSettings (const True)) jwk (jwt :: SignedJWT)+  case result of+    Left e -> assertFailure ("the id_token failed signature verification: " <> show e)+    Right _ -> pure ()++carriesExactlyTheOidcClaims :: Assertion+carriesExactlyTheOidcClaims = do+  jwk <- generateSigningKey+  (_, idc, payload) <- signWith jwk (Just "n-0S6")+  KeyMap.lookup "iss" payload @?= Just (String (issuerText idc.issuer))+  KeyMap.lookup "sub" payload @?= Just (String (idText idc.subject))+  KeyMap.lookup "aud" payload @?= Just (String idc.audience)+  KeyMap.lookup "nonce" payload @?= Just (String "n-0S6")+  assertBool "iat is present" (KeyMap.member "iat" payload)+  assertBool "exp is present" (KeyMap.member "exp" payload)+  assertBool "auth_time is present" (KeyMap.member "auth_time" payload)+  -- An ID token is a statement about an authentication, not a credential: no session id, no+  -- scopes, no roles.+  assertBool "no sid" (not (KeyMap.member "sid" payload))+  assertBool "no scopes" (not (KeyMap.member "scopes" payload))+  assertBool "no roles" (not (KeyMap.member "roles" payload))++omitsAbsentNonce :: Assertion+omitsAbsentNonce = do+  jwk <- generateSigningKey+  (_, _, payload) <- signWith jwk Nothing+  assertBool "nonce is absent, not null" (not (KeyMap.member "nonce" payload))++authTimeIsANumber :: Assertion+authTimeIsANumber = do+  jwk <- generateSigningKey+  (_, idc, payload) <- signWith jwk (Just "n")+  case KeyMap.lookup "auth_time" payload of+    Just (Number n) -> (round n :: Integer) @?= floor (utcTimeToPOSIXSeconds idc.authTime)+    other -> assertFailure ("auth_time must be a JSON number, got " <> show other)++notABearerCredential :: Assertion+notABearerCredential = do+  jwk <- generateSigningKey+  (IdToken wire, _, _) <- signWith jwk Nothing+  -- Its aud is the client_id, not the API audience, so the access-token verifier rejects it. This+  -- is what stops a client replaying an ID token at a resource server.+  res <- verifyToken (publicJwks jwk []) testConfig wire+  case res of+    Left TokenAudienceInvalid -> pure ()+    Left other -> assertFailure ("expected TokenAudienceInvalid, got " <> show other)+    Right _ -> assertFailure "an ID token must never verify as an access token"++-- | Decode segment @n@ (0 = header, 1 = payload) of a compact JWS as a JSON object.+decodeSegment :: Int -> Text -> IO Object+decodeSegment n wire = do+  let seg = Text.encodeUtf8 (Text.splitOn "." wire !! n)+  raw <-+    either (assertFailure . ("segment base64url decode failed: " <>)) pure $+      (convertFromBase Base64URLUnpadded seg :: Either String ByteString)+  maybe (assertFailure "segment is not a JSON object") pure (Aeson.decodeStrict raw)
+ test/Shomei/SigningKey/Sign/JwtSpec.hs view
@@ -0,0 +1,213 @@+-- | Scenarios (b)–(f) and the kid-selection half of (g): a full sign/verify+-- round trip, and rejection of tampered, expired, wrong-audience, and wrong-issuer+-- tokens, plus key selection out of a multi-key JWKSet.+module Shomei.SigningKey.Sign.JwtSpec (tests) where++import Crypto.JOSE.JWK (JWK)+import Data.Aeson (Object, Value (String))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteArray.Encoding (Base (Base64URLUnpadded), convertFromBase)+import Data.ByteString (ByteString)+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 (Audience (..), AuthClaims (..), Issuer (..), mkExtraClaims)+import Shomei.Config (defaultShomeiConfig)+import Shomei.Error (TokenError (..))+import Shomei.Id (genUserId, idText)+import Shomei.Session.Token.Domain (AccessToken (AccessToken))+import Shomei.SigningKey.Domain (SigningAlgorithm (RS256))+import Shomei.SigningKey.Key.Jwt (generateSigningKey, generateSigningKeyFor, keyKid)+import Shomei.SigningKey.Sign.Jwt (signAccessToken)+import Shomei.SigningKey.TestSupport (coreFields, mkClaims, mkClaimsWith, publicJwks, testAudience, testConfig, testIssuer)+import Shomei.SigningKey.Verify.Jwt (verifyToken)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "SignVerify"+    [ testCase "round-trips all claims" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        assertClaims ac res,+      testCase "rejects a tampered token" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig (tamper wire)+        res @?= Left TokenSignatureInvalid,+      testCase "rejects an expired token" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaimsWith testConfig (addUTCTime (-3600) t) (addUTCTime (-1800) t)+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        res @?= Left TokenExpired,+      testCase "rejects a wrong audience" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        let cfgWrong = defaultShomeiConfig testIssuer (Audience "other-audience")+        res <- verifyToken (publicJwks jwk []) cfgWrong wire+        res @?= Left TokenAudienceInvalid,+      testCase "rejects a wrong issuer" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        let cfgWrong = defaultShomeiConfig (Issuer "https://evil.test") testAudience+        res <- verifyToken (publicJwks jwk []) cfgWrong wire+        res @?= Left TokenIssuerInvalid,+      testCase "selects the signing key by kid" $ do+        a <- generateSigningKey+        b <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail a ac+        res <- verifyToken (publicJwks a [b]) testConfig wire+        assertClaims ac res,+      testCase "round-trips the act (actor) claim on a delegated token" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        op <- genUserId+        base <- mkClaims testConfig t+        let ac = base {actor = Just op}+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        case res of+          Right ac' -> ac'.actor @?= Just op+          Left e -> assertFailure ("verify failed: " <> show e),+      testCase "omits the act claim when actor is Nothing" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        case res of+          Right ac' -> ac'.actor @?= Nothing+          Left e -> assertFailure ("verify failed: " <> show e),+      testCase "an RS256 key signs a token whose header alg is RS256" $ do+        jwk <- generateSigningKeyFor RS256+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        hdr <- decodeHeader wire+        KeyMap.lookup "alg" hdr @?= Just (String "RS256")+        KeyMap.lookup "kid" hdr @?= Just (String (keyKid jwk)),+      testCase "an RS256 token verifies via the RSA public JWKS" $ do+        jwk <- generateSigningKeyFor RS256+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        assertClaims ac res,+      testCase "an ES256 key still signs with header alg ES256" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        hdr <- decodeHeader wire+        KeyMap.lookup "alg" hdr @?= Just (String "ES256"),+      testCase "custom extra claims round-trip through sign/verify" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        base <- mkClaims testConfig t+        let extra =+              mkExtraClaims+                ( KeyMap.fromList+                    [ ("impersonated", Aeson.Bool False),+                      ("userId", String "u-123"),+                      ("userInfo", Aeson.object ["userRole" Aeson..= String "agent"])+                    ]+                )+            ac = base {extraClaims = extra}+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        case res of+          Right ac' -> ac'.extraClaims @?= extra+          Left e -> assertFailure ("verify failed: " <> show e),+      testCase "a custom sub in the extra bag cannot forge the subject" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        base <- mkClaims testConfig t+        let ac = base {extraClaims = mkExtraClaims (KeyMap.fromList [("sub", String "attacker")])}+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        case res of+          Right ac' -> idText ac'.subject @?= idText base.subject+          Left e -> assertFailure ("verify failed: " <> show e),+      testCase "a custom auth_time in the extra bag cannot forge credential freshness" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        base <- mkClaims testConfig t+        let expectedAuthTime = addUTCTime (-120) t+            ac = base {authTime = expectedAuthTime, extraClaims = KeyMap.fromList [("auth_time", Aeson.Number 0)]}+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        case res of+          Right ac' -> do+            floor (utcTimeToPOSIXSeconds ac'.authTime) @?= (floor (utcTimeToPOSIXSeconds expectedAuthTime) :: Integer)+            assertBool "auth_time must not appear in extraClaims" (KeyMap.lookup "auth_time" ac'.extraClaims == Nothing)+          Left e -> assertFailure ("verify failed: " <> show e),+      -- The @permissions@ claim (EP-9) is managed like @roles@/@scopes@: the verify side reads it+      -- into the typed field and MUST strip it from the extra bag, or a consumer reading+      -- @extraClaims@ would see a duplicate it could mistake for a host claim.+      testCase "the permissions claim round-trips and never leaks into the extra bag" $ do+        jwk <- generateSigningKey+        t <- getCurrentTime+        ac <- mkClaims testConfig t+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        case res of+          Right ac' -> do+            ac'.permissions @?= ac.permissions+            assertBool "permissions must not appear in extraClaims" (KeyMap.lookup "permissions" ac'.extraClaims == Nothing)+          Left e -> assertFailure ("verify failed: " <> show e)+    ]++-- | Decode the protected-header segment of a compact JWS (the part before the+-- first @.@): base64url-decode it (unpadded) and parse the JSON object.+decodeHeader :: Text -> IO Object+decodeHeader wire = do+  let seg = Text.encodeUtf8 (Text.takeWhile (/= '.') wire)+  raw <-+    either (assertFailure . ("header base64url decode failed: " <>)) pure $+      (convertFromBase Base64URLUnpadded seg :: Either String ByteString)+  maybe (assertFailure "header is not a JSON object") pure (Aeson.decodeStrict raw)++-- | Sign claims, failing the test if signing errors; returns the compact token text.+signOrFail :: JWK -> AuthClaims -> IO Text+signOrFail jwk ac = do+  r <- signAccessToken jwk ac+  case r of+    Right (AccessToken w) -> pure w+    Left e -> assertFailure ("sign failed: " <> show e)++-- | Assert a verification result holds the expected (stable) claim fields.+assertClaims :: AuthClaims -> Either TokenError AuthClaims -> Assertion+assertClaims expected = \case+  Right ac' -> coreFields ac' @?= coreFields expected+  Left e -> assertFailure ("verify failed: " <> show e)++-- | Flip one character in the signature (last) segment of a compact JWS, so the+-- header and payload still decode but the signature no longer verifies. (jose+-- decodes the payload before checking the signature, so corrupting the payload+-- would surface as a malformed token rather than a bad signature.)+tamper :: Text -> Text+tamper w = case reverse (Text.splitOn "." w) of+  (sig : leading) -> Text.intercalate "." (reverse (flip1 sig : leading))+  [] -> w+  where+    flip1 s = case Text.uncons s of+      Just (c, cs) -> Text.cons (if c == 'A' then 'B' else 'A') cs+      Nothing -> s
+ test/Shomei/SigningKey/Sign/RsaCustomClaimSpec.hs view
@@ -0,0 +1,100 @@+-- | SH-24 acceptance: an RS256 token carrying a custom claim round-trips through+-- the public JWKS verify path, the compact token's header/payload contents are proven+-- by decoding it, reserved keys cannot be forged via the extra bag, and the config+-- selector maps the algorithm text to the closed enum.+module Shomei.SigningKey.Sign.RsaCustomClaimSpec (tests) where++import Crypto.JOSE.JWK (JWK)+import Data.Aeson (Object, Value (Bool, String), object, (.=))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteArray.Encoding (Base (Base64URLUnpadded), convertFromBase)+import Data.ByteString (ByteString)+import Data.Either (isLeft)+import Data.Text (Text)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text+import Data.Time (getCurrentTime)+import Shomei.Authorization.Claims.Domain (AuthClaims (..), mkExtraClaims)+import Shomei.Config (ShomeiConfig (..), SigningKeyConfig (..), configSigningAlgorithm)+import Shomei.Id (idText)+import Shomei.Session.Token.Domain (AccessToken (AccessToken))+import Shomei.SigningKey.Domain (SigningAlgorithm (RS256))+import Shomei.SigningKey.Key.Jwt (generateSigningKeyFor, keyKid)+import Shomei.SigningKey.Sign.Jwt (signAccessToken)+import Shomei.SigningKey.TestSupport (mkClaims, publicJwks, testConfig)+import Shomei.SigningKey.Verify.Jwt (verifyToken)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "RsaCustomClaim"+    [ testCase "RS256 token with a custom claim round-trips via JWKS" $ do+        jwk <- generateSigningKeyFor RS256+        t <- getCurrentTime+        base <- mkClaims testConfig t+        let bag =+              mkExtraClaims+                ( KeyMap.fromList+                    [ ("userId", String "u-123"),+                      ("impersonated", Bool False),+                      ("userInfo", object ["userRole" .= String "agent", "username" .= String "alice"])+                    ]+                )+            ac = base {extraClaims = bag}+        wire <- signOrFail jwk ac+        -- Prove the compact header says alg=RS256 with the right kid.+        hdr <- decodeSegment 0 wire+        KeyMap.lookup "alg" hdr @?= Just (String "RS256")+        KeyMap.lookup "kid" hdr @?= Just (String (keyKid jwk))+        -- Prove the payload carries the custom claim AND the standard claims.+        payload <- decodeSegment 1 wire+        KeyMap.lookup "userId" payload @?= Just (String "u-123")+        assertBool "sub present in payload" (KeyMap.member "sub" payload)+        assertBool "sid present in payload" (KeyMap.member "sid" payload)+        -- Verify through the public JWKS path; the custom bag is preserved.+        res <- verifyToken (publicJwks jwk []) testConfig wire+        case res of+          Right ac' -> do+            ac'.extraClaims @?= bag+            idText ac'.subject @?= idText base.subject+          Left e -> assertFailure ("verify failed: " <> show e),+      testCase "reserved keys cannot be forged via the extra bag" $ do+        jwk <- generateSigningKeyFor RS256+        t <- getCurrentTime+        base <- mkClaims testConfig t+        let ac = base {extraClaims = mkExtraClaims (KeyMap.fromList [("sub", String "attacker")])}+        wire <- signOrFail jwk ac+        res <- verifyToken (publicJwks jwk []) testConfig wire+        case res of+          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}}+        configSigningAlgorithm rs @?= Right RS256+        assertBool "unknown signing algorithms must be a boot error" (isLeft (configSigningAlgorithm bad))+    ]++-- | Sign claims, failing the test if signing errors; returns the compact token text.+signOrFail :: JWK -> AuthClaims -> IO Text+signOrFail jwk ac = do+  r <- signAccessToken jwk ac+  case r of+    Right (AccessToken w) -> pure w+    Left e -> assertFailure ("sign failed: " <> show e)++-- | Decode the @n@th dot-separated segment of a compact JWS: base64url-decode it+-- (unpadded) and parse the JSON object (segment 0 = header, 1 = payload).+decodeSegment :: Int -> Text -> IO Object+decodeSegment n wire = do+  let segs = Text.splitOn "." wire+  seg <- case drop n segs of+    (s : _) -> pure (Text.encodeUtf8 s)+    [] -> assertFailure ("no segment " <> show n <> " in token")+  raw <-+    either (assertFailure . ("base64url decode failed: " <>)) pure $+      (convertFromBase Base64URLUnpadded seg :: Either String ByteString)+  maybe (assertFailure "segment is not a JSON object") pure (Aeson.decodeStrict raw)
+ test/Shomei/SigningKey/TestSupport.hs view
@@ -0,0 +1,75 @@+-- | Shared fixtures for the sign/verify/interpreter specs.+module Shomei.SigningKey.TestSupport+  ( testIssuer,+    testAudience,+    testConfig,+    mkClaims,+    mkClaimsWith,+    publicJwks,+    coreFields,+  )+where++import Crypto.JOSE.JWK (JWK, JWKSet)+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Time (UTCTime, addUTCTime)+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)+import Shomei.Authorization.Claims.Domain (Audience (..), AuthClaims (..), Issuer (..), Permission (..), Role (..), Scope (..))+import Shomei.Config (ShomeiConfig (..), defaultShomeiConfig)+import Shomei.Id (genSessionId, genUserId, idText)+import Shomei.SigningKey.Jwks.Jwt (KeySet (..), keySetPublicJwks)++testIssuer :: Issuer+testIssuer = Issuer "https://shomei.test"++testAudience :: Audience+testAudience = Audience "shomei-clients"++testConfig :: ShomeiConfig+testConfig = defaultShomeiConfig testIssuer testAudience++-- | Build claims valid from @t@ for one hour, with two scopes and one role.+mkClaims :: ShomeiConfig -> UTCTime -> IO AuthClaims+mkClaims cfg t = mkClaimsWith cfg t (addUTCTime 3600 t)++-- | Build claims with explicit @issuedAt@ and @expiresAt@ (used by the expiry test).+mkClaimsWith :: ShomeiConfig -> UTCTime -> UTCTime -> IO AuthClaims+mkClaimsWith cfg iat expd = do+  uid <- genUserId+  sid <- genSessionId+  pure+    AuthClaims+      { subject = uid,+        sessionId = sid,+        issuer = cfg.issuer,+        audience = cfg.audience,+        issuedAt = iat,+        expiresAt = expd,+        authTime = iat,+        scopes = Set.fromList [Scope "read", Scope "write"],+        roles = Set.fromList [Role "user"],+        permissions = Set.fromList [Permission "projects:write", Permission "billing:read"],+        actor = Nothing,+        extraClaims = mempty+      }++-- | The public 'JWKSet' for an active key plus any additional keys.+publicJwks :: JWK -> [JWK] -> JWKSet+publicJwks active others = keySetPublicJwks (KeySet active others)++-- | The claim fields that must survive a sign/verify round trip (timestamps are+-- excluded because JWT numeric dates are truncated to whole seconds). Identifiers+-- are compared by their rendered text form.+coreFields :: AuthClaims -> (Text, Text, Issuer, Audience, Set Scope, Set Role, Set Permission, Integer)+coreFields ac =+  ( idText ac.subject,+    idText ac.sessionId,+    ac.issuer,+    ac.audience,+    ac.scopes,+    ac.roles,+    ac.permissions,+    floor (utcTimeToPOSIXSeconds ac.authTime)+  )
+ test/Shomei/SigningKey/Verify/JwtSpec.hs view
@@ -0,0 +1,216 @@+{-# 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)