packages feed

shomei-jwt-0.2.0.0: src/Shomei/SigningKey/Verify/Jwt.hs

-- 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 []