packages feed

shomei-core-0.2.0.0: src/Shomei/Authorization/Claims/Domain.hs

-- | The claims embedded in an access token, plus the small newtypes that make the claim
-- fields type-safe.
module Shomei.Authorization.Claims.Domain
  ( Issuer (..),
    Audience (..),
    Scope (..),
    Role (..),
    Permission (..),
    AuthClaims (..),
    reservedClaimKeys,
    mkExtraClaims,
    noExtraClaims,
  )
where

import Data.Aeson (Object)
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Set (Set)
import Shomei.Id (SessionId, UserId)
import Shomei.Prelude

newtype Issuer = Issuer Text
  deriving stock (Generic)
  deriving newtype (Eq, Ord, Show, FromJSON, ToJSON)

newtype Audience = Audience Text
  deriving stock (Generic)
  deriving newtype (Eq, Ord, Show, FromJSON, ToJSON)

newtype Scope = Scope Text
  deriving stock (Generic)
  deriving newtype (Eq, Ord, Show, FromJSON, ToJSON)

newtype Role = Role Text
  deriving stock (Generic)
  deriving newtype (Eq, Ord, Show, FromJSON, ToJSON)

-- | A capability string carried in the @permissions@ claim, resolved at mint time from the
-- subject's roles (the @shomei_role_permissions@ table). Convention: @resource:verb@, e.g.
-- @projects:write@ — a documented convention, not enforced grammar.
newtype Permission = Permission Text
  deriving stock (Generic)
  deriving newtype (Eq, Ord, Show, FromJSON, ToJSON)

data AuthClaims = AuthClaims
  { subject :: !UserId,
    sessionId :: !SessionId,
    issuer :: !Issuer,
    audience :: !Audience,
    issuedAt :: !UTCTime,
    expiresAt :: !UTCTime,
    -- | when the last credential was proven. Unlike 'issuedAt', this is preserved across token
    -- refresh and is the clock for reauthentication gates.
    authTime :: !UTCTime,
    scopes :: !(Set Scope),
    roles :: !(Set Role),
    -- | the capabilities the subject's roles imply (EP-9), resolved at mint time from the
    --     @shomei_role_permissions@ catalog and carried in the @permissions@ claim. Empty for
    --     tokens minted before EP-9 (and for OAuth machine/delegation flows, which do
    --     not go through role enrichment); a token with no @permissions@ claim verifies to the
    --     empty set, exactly as @roles@ does.
    permissions :: !(Set Permission),
    -- | when this token is a delegated (impersonation) token, the real operator
    -- acting on behalf of 'subject'; serialised as the @act@ JWT claim. 'Nothing'
    -- for every ordinary login token.
    actor :: !(Maybe UserId),
    -- | additional top-level JWT claims a consuming service attaches (e.g. TAN's
    -- @userId@, @userInfo@, @impersonated@, @clientAccountId@, or a service token's
    -- @type@/@serviceInfo@). Empty ('noExtraClaims') for ordinary tokens, which then
    -- serialise byte-identically to before this field existed. Keys that collide with
    -- a standard claim are overridden by the standard claim at sign time.
    extraClaims :: !Object
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

-- | The registered and private claim keys Shōmei owns. The signer writes every
-- managed value after the extension bag, the verifier removes every managed value
-- from the recovered extension bag, and 'mkExtraClaims' drops every managed value
-- at construction. Adding a managed claim therefore starts by adding its name here.
reservedClaimKeys :: [Text]
reservedClaimKeys = ["iss", "sub", "aud", "iat", "exp", "nbf", "jti", "sid", "scopes", "roles", "permissions", "act", "auth_time"]

-- | Build an extra-claims object, dropping any reserved key (see 'reservedClaimKeys').
mkExtraClaims :: Object -> Object
mkExtraClaims = KeyMap.filterWithKey (\k _ -> Key.toText k `notElem` reservedClaimKeys)

-- | The empty extra-claims object — the default for ordinary tokens.
noExtraClaims :: Object
noExtraClaims = KeyMap.empty