packages feed

shomei-core-0.2.0.0: test/Shomei/OAuth/TokenExchange/WorkflowSpec.hs

-- | Behavioral tests for the RFC 8693 token-exchange workflow
-- ('Shomei.OAuth.TokenExchange.Workflow'), run entirely through the in-memory interpreter
-- ('Shomei.Test.InMemory.runInMemory'). The in-memory 'Shomei.SigningKey.Signer' renders
-- 'AuthClaims' as JSON and the matching 'Shomei.SigningKey.Verifier' parses it back, so a
-- subject\/actor token is just a signed 'AuthClaims' and the minted token decodes for inspection.
module Shomei.OAuth.TokenExchange.WorkflowSpec (tests) where

import Data.Aeson (eitherDecode)
import Data.IORef (IORef, newIORef, readIORef)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding qualified as TLE
import Data.Time (UTCTime (..), addUTCTime, fromGregorian)
import Shomei.Account.Email.Domain (Email, emailText, mkEmail)
import Shomei.Account.LoginId.Domain (mkLoginId)
import Shomei.Account.Password.Domain (PlainPassword (..))
import Shomei.Account.User.Domain (User (..), UserStatus (UserActive, UserSuspended))
import Shomei.Account.User.Store (updateUserStatus)
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Authorization.Claims.Domain (Audience (..), AuthClaims (..), Issuer (..), Scope (..))
import Shomei.Config (ImpersonationConfig (..), MachineTokenConfig (..), ShomeiConfig (..), defaultShomeiConfig)
import Shomei.Error (AuthError (..))
import Shomei.Id (SessionId, UserId, genServiceAccountDbId, genUserId, idText)
import Shomei.OAuth.TokenExchange.Workflow
  ( ExchangeRequest (..),
    ExchangedToken (..),
    accessTokenType,
    exchangeToken,
    tokenExchangeSubjectScope,
    userIdTokenType,
  )
import Shomei.ServiceAccount.Domain (ServiceAccount (..), ServiceAccountStatus (..))
import Shomei.Session.Authentication.Workflow (signup)
import Shomei.Session.Command (SignupCommand (..))
import Shomei.Session.RefreshToken.Domain (PersistedRefreshToken (..))
import Shomei.Session.Store (revokeSession)
import Shomei.Session.Token.Domain (AccessToken (..), TokenPair (..))
import Shomei.SigningKey.Signer (signAccessToken)
import Shomei.Test.InMemory (World (..), emptyWorld, runInMemory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

-- Fixtures -------------------------------------------------------------------

fixedTime :: UTCTime
fixedTime = UTCTime (fromGregorian 2026 1 1) 0

cfg :: ShomeiConfig
cfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")

impScope :: Scope
impScope = cfg.impersonationConfig.impersonateScope

ingestScope, readScope, adminScope :: Scope
ingestScope = Scope "kawa:ingest"
readScope = Scope "kawa:read"
adminScope = Scope "admin:everything"

strongPw :: PlainPassword
strongPw = PlainPassword "correct horse battery staple"

mkEmail' :: Text -> Email
mkEmail' t = either (\e -> error ("bad test email: " <> show e)) id (mkEmail t)

expectRight :: (Show e) => Either e a -> IO a
expectRight = either (\e -> assertFailure ("expected Right, got Left: " <> show e)) pure

-- | Sign up a user with the given email and return their active user/session pair.
seedPrincipal :: IORef World -> Text -> IO (UserId, SessionId)
seedPrincipal ref email = do
  let e = mkEmail' email
  (user, pair) <- expectRight =<< runInMemory ref (signup cfg (SignupCommand {loginId = either (error . show) id (mkLoginId (emailText e)), email = Just e, password = strongPw, displayName = Just "User"}))
  claims <- decodeAccess pair.accessToken
  pure (user.userId, claims.sessionId)

seedUser :: IORef World -> Text -> IO UserId
seedUser ref email = fst <$> seedPrincipal ref email

-- | Build claims for a principal. The in-memory verifier accepts them verbatim.
claimsFor :: UserId -> SessionId -> Set Scope -> Maybe UserId -> UTCTime -> AuthClaims
claimsFor uid sid scs act iat =
  AuthClaims
    { subject = uid,
      sessionId = sid,
      issuer = cfg.issuer,
      audience = cfg.audience,
      issuedAt = iat,
      expiresAt = addUTCTime 900 iat,
      authTime = iat,
      scopes = scs,
      roles = Set.empty,
      permissions = Set.empty,
      actor = act,
      extraClaims = mempty
    }

-- | Sign an access token for a principal (through the in-memory signer, so it round-trips).
signToken :: IORef World -> AuthClaims -> IO Text
signToken ref claims = do
  AccessToken t <- runInMemory ref (signAccessToken claims)
  pure t

-- | A fresh operator token holding the impersonation scope, issued now.
freshOperatorToken :: IORef World -> IO Text
freshOperatorToken ref = do
  (op, sid) <- seedPrincipal ref "operator@example.com"
  signToken ref (claimsFor op sid (Set.singleton impScope) Nothing fixedTime)

-- | A service account with the given allowed scopes, backed by 'svcUser'.
mkServiceAccount :: UserId -> Set Scope -> IO ServiceAccount
mkServiceAccount svcUser scopes = do
  dbid <- genServiceAccountDbId
  pure
    ServiceAccount
      { serviceAccountId = dbid,
        clientId = idText dbid,
        userId = svcUser,
        secretHash = "0000000000000000000000000000000000000000000000000000000000000000",
        displayName = "svc",
        allowedScopes = scopes,
        status = ServiceAccountActive,
        createdAt = fixedTime,
        rotatedAt = Nothing,
        revokedAt = Nothing
      }

-- | The base impersonation request: the target's id as the user-id subject, the operator token as
-- the actor. Extension parameters left at their defaults.
impersonationReq :: UserId -> Text -> ExchangeRequest
impersonationReq target operatorToken =
  ExchangeRequest
    { subjectToken = idText target,
      subjectTokenType = userIdTokenType,
      actorToken = Just operatorToken,
      actorTokenType = Just accessTokenType,
      requestedScopes = Nothing,
      requestedTokenType = Nothing,
      reason = Nothing,
      ticketId = Nothing,
      clientIp = Just "203.0.113.7",
      authenticatedService = Nothing
    }

-- | The base on-behalf-of request: a user's access token as the subject, presented by an
-- authenticated service account.
onBehalfReq :: Text -> ServiceAccount -> Maybe (Set Scope) -> ExchangeRequest
onBehalfReq subjectToken svc requested =
  ExchangeRequest
    { subjectToken = subjectToken,
      subjectTokenType = accessTokenType,
      actorToken = Nothing,
      actorTokenType = Nothing,
      requestedScopes = requested,
      requestedTokenType = Nothing,
      reason = Nothing,
      ticketId = Nothing,
      clientIp = Nothing,
      authenticatedService = Just svc
    }

decodeAccess :: AccessToken -> IO AuthClaims
decodeAccess (AccessToken t) =
  either
    (\e -> assertFailure ("could not decode access token: " <> e))
    pure
    (eitherDecode (TLE.encodeUtf8 (TL.fromStrict t)))

-- | Assert no refresh token was minted for the delegated session.
assertNoRefresh :: IORef World -> SessionId -> IO ()
assertNoRefresh ref sid = do
  world <- readIORef ref
  let refs = filter (\PersistedRefreshToken {sessionId = s} -> s == sid) (Map.elems world.refreshTokens)
  assertBool "delegated session has no refresh token" (null refs)

-- Tests ----------------------------------------------------------------------

tests :: TestTree
tests =
  testGroup
    "Shomei.OAuth.TokenExchange.Workflow"
    [ testImpersonationHappyPath,
      testImpersonationDefaultReason,
      testImpersonationMissingScope,
      testImpersonationStaleActor,
      testImpersonationSelfTarget,
      testImpersonationDelegatedActorRefused,
      testImpersonationRevokedOperator,
      testImpersonationSuspendedOperator,
      testOnBehalfHappyPath,
      testOnBehalfDefaultScopes,
      testOnBehalfMissingGateScope,
      testOnBehalfScopeOutsideCeiling,
      testOnBehalfGateNeverGranted,
      testOnBehalfSubjectScopeBoundOk,
      testOnBehalfSubjectScopeBoundViolation,
      testOnBehalfChainRefused,
      testOnBehalfInactiveSubject,
      testOnBehalfRevokedSubject,
      testRefreshlessRequestedTypeRejected
    ]

testImpersonationHappyPath :: TestTree
testImpersonationHappyPath = testCase "impersonation: target sub + operator act, refresh-less, audited" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedUser ref "customer@example.com"
  opTok <- freshOperatorToken ref
  operatorClaims <- decodeAccess (AccessToken opTok)
  result <- expectRight =<< runInMemory ref (exchangeToken cfg (impersonationReq target opTok))
  claims <- decodeAccess result.accessToken
  claims.subject @?= target
  claims.actor @?= Just operatorClaims.subject
  result.grantedScopes @?= Set.empty
  result.expiresIn @?= cfg.impersonationConfig.impersonationSessionTTL
  assertNoRefresh ref result.sessionId
  world <- readIORef ref
  assertBool "ImpersonationStarted published" (any (startedFor operatorClaims.subject target) world.publishedEvents)
  where
    startedFor actorId subj = \case
      Event.ImpersonationStarted d -> d.actorUserId == actorId && d.subjectUserId == subj
      _ -> False

testImpersonationDefaultReason :: TestTree
testImpersonationDefaultReason = testCase "impersonation: absent reason defaults to token_exchange" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedUser ref "customer@example.com"
  opTok <- freshOperatorToken ref
  _ <- expectRight =<< runInMemory ref (exchangeToken cfg (impersonationReq target opTok))
  world <- readIORef ref
  assertBool "reason defaulted to token_exchange" (any defaulted world.publishedEvents)
  where
    defaulted = \case
      Event.ImpersonationStarted d -> d.reason == "token_exchange"
      _ -> False

testImpersonationMissingScope :: TestTree
testImpersonationMissingScope = testCase "impersonation: operator without impersonate:user is forbidden" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedUser ref "customer@example.com"
  (op, sid) <- seedPrincipal ref "operator@example.com"
  opTok <- signToken ref (claimsFor op sid Set.empty Nothing fixedTime)
  res <- runInMemory ref (exchangeToken cfg (impersonationReq target opTok))
  fmap (const ()) res @?= Left ImpersonationForbidden

testImpersonationStaleActor :: TestTree
testImpersonationStaleActor = testCase "impersonation: operator token past the freshness window is forbidden" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedUser ref "customer@example.com"
  (op, sid) <- seedPrincipal ref "operator@example.com"
  let stale = addUTCTime (negate (cfg.impersonationConfig.actorFreshnessWindow + 1)) fixedTime
  opTok <- signToken ref (claimsFor op sid (Set.singleton impScope) Nothing stale)
  res <- runInMemory ref (exchangeToken cfg (impersonationReq target opTok))
  fmap (const ()) res @?= Left ImpersonationForbidden

testImpersonationSelfTarget :: TestTree
testImpersonationSelfTarget = testCase "impersonation: targeting the operator themselves is invalid" do
  ref <- newIORef (emptyWorld fixedTime)
  (op, sid) <- seedPrincipal ref "operator@example.com"
  opTok <- signToken ref (claimsFor op sid (Set.singleton impScope) Nothing fixedTime)
  res <- runInMemory ref (exchangeToken cfg (impersonationReq op opTok))
  fmap (const ()) res @?= Left ImpersonationTargetInvalid

testImpersonationDelegatedActorRefused :: TestTree
testImpersonationDelegatedActorRefused = testCase "impersonation: a delegated actor token is refused (no chains)" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedUser ref "customer@example.com"
  (op, sid) <- seedPrincipal ref "operator@example.com"
  other <- genUserId
  -- An actor token that already carries `act` cannot be used to start another exchange.
  opTok <- signToken ref (claimsFor op sid (Set.singleton impScope) (Just other) fixedTime)
  res <- runInMemory ref (exchangeToken cfg (impersonationReq target opTok))
  fmap (const ()) res @?= Left OAuthGrantInvalid

testImpersonationRevokedOperator :: TestTree
testImpersonationRevokedOperator = testCase "impersonation: a revoked operator session is an invalid grant" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedUser ref "customer@example.com"
  (op, sid) <- seedPrincipal ref "operator@example.com"
  opTok <- signToken ref (claimsFor op sid (Set.singleton impScope) Nothing fixedTime)
  runInMemory ref (revokeSession sid fixedTime)
  res <- runInMemory ref (exchangeToken cfg (impersonationReq target opTok))
  fmap (const ()) res @?= Left OAuthGrantInvalid

testImpersonationSuspendedOperator :: TestTree
testImpersonationSuspendedOperator = testCase "impersonation: a suspended operator is forbidden" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedUser ref "customer@example.com"
  (op, sid) <- seedPrincipal ref "operator@example.com"
  opTok <- signToken ref (claimsFor op sid (Set.singleton impScope) Nothing fixedTime)
  _ <- runInMemory ref (updateUserStatus op [UserActive] UserSuspended fixedTime)
  res <- runInMemory ref (exchangeToken cfg (impersonationReq target opTok))
  fmap (const ()) res @?= Left ImpersonationForbidden

testOnBehalfHappyPath :: TestTree
testOnBehalfHappyPath = testCase "on-behalf-of: user sub + service act, narrowed scopes, audited, refresh-less" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  subjTok <- signToken ref (claimsFor user usid Set.empty Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, readScope, tokenExchangeSubjectScope])
  result <- expectRight =<< runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.singleton ingestScope))))
  claims <- decodeAccess result.accessToken
  claims.subject @?= user
  claims.actor @?= Just svcUser
  claims.scopes @?= Set.singleton ingestScope
  result.grantedScopes @?= Set.singleton ingestScope
  result.expiresIn @?= cfg.machineTokenConfig.machineTokenTTL
  assertNoRefresh ref result.sessionId
  world <- readIORef ref
  assertBool "ServiceOnBehalfIssued published" (any (behalfFor svcUser user) world.publishedEvents)
  where
    behalfFor act subj = \case
      Event.ServiceOnBehalfIssued d ->
        d.actorUserId == act && d.subjectUserId == subj && d.scopes == Set.singleton ingestScope
      _ -> False

testOnBehalfDefaultScopes :: TestTree
testOnBehalfDefaultScopes = testCase "on-behalf-of: absent scope grants the ceiling, never the gate scope" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  subjTok <- signToken ref (claimsFor user usid Set.empty Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, readScope, tokenExchangeSubjectScope])
  result <- expectRight =<< runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc Nothing))
  -- The gate scope is stripped; the two functional scopes remain.
  result.grantedScopes @?= Set.fromList [ingestScope, readScope]
  assertBool "gate scope is never granted" (not (tokenExchangeSubjectScope `Set.member` result.grantedScopes))

testOnBehalfMissingGateScope :: TestTree
testOnBehalfMissingGateScope = testCase "on-behalf-of: account without the gate scope is refused" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  subjTok <- signToken ref (claimsFor user usid Set.empty Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, readScope])
  res <- runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.singleton ingestScope))))
  fmap (const ()) res @?= Left OAuthScopeInvalid

testOnBehalfScopeOutsideCeiling :: TestTree
testOnBehalfScopeOutsideCeiling = testCase "on-behalf-of: requesting a scope outside the ceiling is refused" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  subjTok <- signToken ref (claimsFor user usid Set.empty Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, tokenExchangeSubjectScope])
  res <- runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.singleton adminScope))))
  fmap (const ()) res @?= Left OAuthScopeInvalid

testOnBehalfGateNeverGranted :: TestTree
testOnBehalfGateNeverGranted = testCase "on-behalf-of: requesting the gate scope itself yields an empty grant, refused" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  subjTok <- signToken ref (claimsFor user usid Set.empty Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, tokenExchangeSubjectScope])
  res <- runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.singleton tokenExchangeSubjectScope))))
  fmap (const ()) res @?= Left OAuthScopeInvalid

testOnBehalfSubjectScopeBoundOk :: TestTree
testOnBehalfSubjectScopeBoundOk = testCase "on-behalf-of: non-empty subject scopes that contain the grant are allowed" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  -- The subject token itself carries a scope set; the grant must be within it.
  subjTok <- signToken ref (claimsFor user usid (Set.fromList [ingestScope, readScope]) Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, readScope, tokenExchangeSubjectScope])
  result <- expectRight =<< runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.singleton ingestScope))))
  result.grantedScopes @?= Set.singleton ingestScope

testOnBehalfSubjectScopeBoundViolation :: TestTree
testOnBehalfSubjectScopeBoundViolation = testCase "on-behalf-of: a grant exceeding non-empty subject scopes is refused" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  -- Subject holds only kawa:ingest; the service asks for kawa:read too — outside the user's authority.
  subjTok <- signToken ref (claimsFor user usid (Set.singleton ingestScope) Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, readScope, tokenExchangeSubjectScope])
  res <- runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.fromList [ingestScope, readScope]))))
  fmap (const ()) res @?= Left OAuthScopeInvalid

testOnBehalfChainRefused :: TestTree
testOnBehalfChainRefused = testCase "on-behalf-of: an already-delegated subject token is refused (no chains)" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  other <- genUserId
  -- Subject token already carries `act`: it is itself a delegated token and cannot be re-exchanged.
  subjTok <- signToken ref (claimsFor user usid Set.empty (Just other) fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, tokenExchangeSubjectScope])
  res <- runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.singleton ingestScope))))
  fmap (const ()) res @?= Left OAuthGrantInvalid

testOnBehalfInactiveSubject :: TestTree
testOnBehalfInactiveSubject = testCase "on-behalf-of: an inactive subject user is refused" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  _ <- runInMemory ref (updateUserStatus user [UserActive] UserSuspended fixedTime)
  subjTok <- signToken ref (claimsFor user usid Set.empty Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, tokenExchangeSubjectScope])
  res <- runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.singleton ingestScope))))
  fmap (const ()) res @?= Left OAuthGrantInvalid

testOnBehalfRevokedSubject :: TestTree
testOnBehalfRevokedSubject = testCase "on-behalf-of: a revoked subject session is an invalid grant" do
  ref <- newIORef (emptyWorld fixedTime)
  (user, usid) <- seedPrincipal ref "customer@example.com"
  svcUser <- seedUser ref "svc@example.com"
  subjTok <- signToken ref (claimsFor user usid Set.empty Nothing fixedTime)
  svc <- mkServiceAccount svcUser (Set.fromList [ingestScope, tokenExchangeSubjectScope])
  runInMemory ref (revokeSession usid fixedTime)
  res <- runInMemory ref (exchangeToken cfg (onBehalfReq subjTok svc (Just (Set.singleton ingestScope))))
  fmap (const ()) res @?= Left OAuthGrantInvalid

testRefreshlessRequestedTypeRejected :: TestTree
testRefreshlessRequestedTypeRejected = testCase "a requested_token_type other than access_token is malformed" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedUser ref "customer@example.com"
  opTok <- freshOperatorToken ref
  let req = (impersonationReq target opTok) {requestedTokenType = Just "urn:ietf:params:oauth:token-type:refresh_token"}
  res <- runInMemory ref (exchangeToken cfg req)
  fmap (const ()) res @?= Left OAuthRequestMalformed