packages feed

shomei-core-0.2.0.0: src/Shomei/OAuth/Client/Domain.hs

-- | The OAuth2 \/ OIDC client entity (EP-5): a relying party registered by an operator, which
-- drives the authorization-code flow at @GET \/oauth\/authorize@ and exchanges its code at
-- @POST \/oauth\/token@.
--
-- Distinct from 'Shomei.ServiceAccount.Domain.ServiceAccount', EP-4's machine credential. A
-- service account authenticates /as itself/ and has a backing @shomei_users@ row, because its
-- token's @sub@ is that user. An OAuth client authenticates only to prove /which client/ is
-- exchanging a code; the token it receives belongs to whichever user authenticated at authorize.
-- So an OAuth client is never a token subject and has no user row.
--
-- 'secretHash' is a lowercase 64-char SHA-256 hex digest — the same format the service accounts
-- use, so 'Shomei.ServiceAccount.Secret.verifyServiceSecret' verifies both — and is 'Nothing' for
-- exactly the 'PublicClient's.
module Shomei.OAuth.Client.Domain
  ( ClientType (..),
    OAuthClientStatus (..),
    OAuthClient (..),
    NewOAuthClient (..),
    isRegisteredRedirectUri,
  )
where

import Data.Set (Set)
import Shomei.Authorization.Claims.Domain (Scope)
import Shomei.Id (OAuthClientId)
import Shomei.Prelude

-- | A 'ConfidentialClient' can keep a secret (a server-side web app); a 'PublicClient' cannot
-- (a browser SPA, a native or CLI app). PKCE is mandatory for the latter: with no secret, the
-- code challenge is its only binding between the authorize and token requests.
data ClientType = ConfidentialClient | PublicClient
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

-- | A revoked client keeps its row: audit events naming it must still resolve, and its
-- @client_id@ must never be recycled.
data OAuthClientStatus = OAuthClientActive | OAuthClientRevoked
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

data OAuthClient = OAuthClient
  { oauthClientId :: !OAuthClientId,
    -- | the TypeID text rendering of 'oauthClientId'; the OAuth2 @client_id@. Public.
    clientId :: !Text,
    -- | 'Nothing' for exactly a 'PublicClient'
    secretHash :: !(Maybe Text),
    clientType :: !ClientType,
    displayName :: !Text,
    -- | absolute URIs, matched by exact string equality (see 'isRegisteredRedirectUri')
    redirectUris :: ![Text],
    -- | the ceiling on what an authorize request may ask for
    allowedScopes :: !(Set Scope),
    status :: !OAuthClientStatus,
    createdAt :: !UTCTime,
    revokedAt :: !(Maybe UTCTime)
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

data NewOAuthClient = NewOAuthClient
  { oauthClientId :: !OAuthClientId,
    clientId :: !Text,
    secretHash :: !(Maybe Text),
    clientType :: !ClientType,
    displayName :: !Text,
    redirectUris :: ![Text],
    allowedScopes :: !(Set Scope),
    createdAt :: !UTCTime
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromJSON, ToJSON)

-- | Is this the client's redirect URI?
--
-- Exact string equality, deliberately: no prefix matching, no wildcard, no normalization. A
-- redirect target the operator did not register must never receive a redirect, because
-- @\/oauth\/authorize@ would then be an open redirector — an attacker registers
-- @https:\/\/app.example.com\/cb@, requests @https:\/\/app.example.com\/cb\/..\/..\/@ or
-- @https:\/\/app.example.com.evil.test\/cb@, and harvests authorization codes. Comparing the
-- bytes the operator wrote down is the only rule with no edge cases.
isRegisteredRedirectUri :: OAuthClient -> Text -> Bool
isRegisteredRedirectUri client uri = uri `elem` client.redirectUris