shomei-core-0.2.0.0: src/Shomei/Mfa/Workflow.hs
-- | The second-factor (MFA step-up) and passwordless passkey login workflows (EP-4).
--
-- 'prepareMfaChallenge' is the step-up branch of 'Shomei.Session.Authentication.Workflow.login': after a correct
-- password for an account that has a second factor (and MFA is required), it begins a WebAuthn
-- authentication ceremony restricted to the user's credentials, stashes it consume-once, and
-- returns the ceremony id + browser options WITHOUT issuing a token. 'completeMfa' finishes
-- that step-up: it consumes the pending ceremony, verifies the browser's assertion against the
-- user's stored passkey, and mints the session/tokens. 'beginPasswordlessLogin' /
-- 'completePasswordlessLogin' authenticate with the passkey ALONE (no password): begin emits
-- options for a discoverable credential, complete resolves the account from the asserted
-- credential id, verifies, and mints tokens.
--
-- All token-minting paths share 'Shomei.Session.Workflow.issueSession' so the tail never
-- drifts. The EP-1 passkey/ceremony records are read via plain record-pattern matching, not
-- @value.field@ dot syntax, because @OverloadedRecordDot@/@HasField@ is unreliable for those
-- @DuplicateRecordFields@ records (a MasterPlan-3 discovery).
module Shomei.Mfa.Workflow
( prepareMfaChallenge,
completeMfa,
MfaCompletion (..),
beginPasswordlessLogin,
completePasswordlessLogin,
)
where
import Data.Aeson (Value, object)
import Data.Aeson.Types (parseMaybe, withObject, (.:))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Time (addUTCTime)
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)
import Shomei.Account.LoginId.Domain (loginIdText)
import Shomei.Account.User.Domain (User (..), UserStatus (UserActive))
import Shomei.Account.User.Store (UserStore, findUserById)
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)
import Shomei.Authorization.Claims.Store (ClaimsEnricher)
import Shomei.Authorization.Role.Store (RoleStore)
import Shomei.Config (ShomeiConfig (..), UserVerificationPolicy (UVRequired), WebAuthnConfig (..))
import Shomei.Error (AuthError (..))
import Shomei.Id (CeremonyId, UserId, genCeremonyId)
import Shomei.Mfa.RecoveryCode.Store (RecoveryCodeStore, consumeRecoveryCode, countUnusedRecoveryCodes)
import Shomei.Mfa.Totp.Algorithm (verifyTotp)
import Shomei.Mfa.Totp.Domain (TotpCredential (..))
import Shomei.Mfa.Totp.Store (TotpCredentialStore, findTotpByUser, setTotpLastUsedCounter)
import Shomei.Mfa.Totp.Workflow (recoveryCodeHash)
import Shomei.Passkey.Ceremony.Port
( BeginCeremony (..),
StoredCredentialForVerify (..),
VerifiedAuthentication (..),
WebAuthnCeremony,
beginAuthenticationCeremony,
completeAuthenticationCeremony,
)
import Shomei.Passkey.Ceremony.Store (PendingCeremonyStore, putPendingCeremony, takePendingCeremony)
import Shomei.Passkey.Domain
( CeremonyKind (AuthenticationCeremony),
PasskeyCredential (..),
PendingCeremony (..),
WebAuthnCredentialId (..),
b64urlEncode,
)
import Shomei.Passkey.Store
( PasskeyStore,
findPasskeyByCredentialId,
findPasskeysByUser,
updatePasskeySignCounter,
)
import Shomei.Prelude
import Shomei.Session.Command (ProofContext, proofContextFor)
import Shomei.Session.LoginAttempt.Domain (AttemptFactor (..))
import Shomei.Session.LoginAttempt.Store (LoginAttemptStore)
import Shomei.Session.LoginAttempt.Workflow (AbuseGate (..), guardAbuse, recordProofFailure, recordProofSuccess)
import Shomei.Session.Token.Domain (TokenPair)
import Shomei.Session.Token.Generator (TokenGen)
import Shomei.Session.UnitOfWork.Store (AuthUnitOfWork)
import Shomei.Session.Workflow (ensureEmailVerified, issueSession)
import Shomei.SigningKey.Signer (TokenSigner)
import Shomei.Time.Store (Clock, now)
-- | How a client completes an MFA challenge. Exactly one arm is populated by the HTTP layer's
-- 'Shomei.Mfa.Dto.MfaCompleteRequest' decoder: 'MfaPasskey' is a WebAuthn assertion,
-- 'MfaTotp' a six-digit code, 'MfaRecoveryCode' a single-use recovery code.
data MfaCompletion
= MfaPasskey Value
| MfaTotp Text
| MfaRecoveryCode Text
deriving stock (Generic, Eq, Show)
-- Field accessors for the TOTP credential (DuplicateRecordFields make @value.field@ unreliable).
totpConfirmed :: TotpCredential -> Bool
totpConfirmed TotpCredential {confirmedAt} = isJust confirmedAt
-- | The step-up branch of 'Shomei.Session.Authentication.Workflow.login'. Begins a WebAuthn authentication
-- ceremony whose @allowCredentials@ is restricted to this user's enrolled passkeys, stashes
-- the consume-once pending ceremony (bound to the user, expiring after the configured TTL),
-- publishes 'MfaChallenged', and returns the ceremony id + the browser-facing options. NO
-- token is issued: the caller returns this as the @mfa_required@ outcome.
prepareMfaChallenge ::
( PasskeyStore :> es,
PendingCeremonyStore :> es,
WebAuthnCeremony :> es,
TotpCredentialStore :> es,
RecoveryCodeStore :> es,
AuthEventPublisher :> es,
IOE :> es
) =>
ShomeiConfig ->
User ->
UTCTime ->
Eff es (CeremonyId, Value, [Text])
prepareMfaChallenge cfg user ts = do
let User {userId = uid} = user
creds <- findPasskeysByUser uid
mTotp <- findTotpByUser uid
unusedRecovery <- countUnusedRecoveryCodes uid
let hasPasskey = not (null creds)
hasTotp = maybe False totpConfirmed mTotp
methods =
["passkey" | hasPasskey]
<> ["totp" | hasTotp]
<> ["recovery_code" | unusedRecovery > 0]
-- Passkey-holders get a real WebAuthn ceremony (options carry the challenge); a TOTP-only
-- user gets an empty options object and no ceremony call — the empty @optionsBlob@ is what
-- 'completeMfa' checks to refuse a passkey assertion for a challenge that never began one.
(optionsJson, optionsBlob) <-
if hasPasskey
then do
let allowIds = map (\PasskeyCredential {credentialId} -> credentialId) creds
BeginCeremony {optionsJson, optionsBlob} <-
beginAuthenticationCeremony (userVerification (webauthnConfig cfg)) allowIds
pure (optionsJson, optionsBlob)
else pure (object [], BS.empty)
cid <- genCeremonyId
putPendingCeremony
PendingCeremony
{ ceremonyId = cid,
userId = Just uid,
kind = AuthenticationCeremony,
optionsBlob = optionsBlob,
createdAt = ts,
expiresAt = addUTCTime (pendingCeremonyTTL (webauthnConfig cfg)) ts
}
publishAuthEvent (Event.MfaChallenged (Event.MfaChallengedData uid cid ts))
pure (cid, optionsJson, methods)
-- | Finish a password-then-passkey step-up. The client posts the ceremony id from the
-- 'MfaRequired' challenge plus the browser's signed assertion. We consume the pending ceremony
-- (rejecting a missing/expired/consumed/non-authentication/no-user ceremony with a 404-mapped
-- 'PendingCeremonyNotFound'), verify the assertion against the user's stored passkey, confirm
-- the asserted credential is owned by that user, bump the sign counter, publish 'MfaSucceeded',
-- and mint tokens via the shared 'issueSession'. A verification failure publishes 'MfaFailed'
-- and returns 'MfaAssertionInvalid'.
completeMfa ::
( UserStore :> es,
AuthUnitOfWork :> es,
PasskeyStore :> es,
PendingCeremonyStore :> es,
WebAuthnCeremony :> es,
TotpCredentialStore :> es,
RecoveryCodeStore :> es,
TokenSigner :> es,
RoleStore :> es,
ClaimsEnricher :> es,
AuthEventPublisher :> es,
LoginAttemptStore :> es,
Clock :> es,
TokenGen :> es
) =>
ShomeiConfig ->
ProofContext ->
CeremonyId ->
MfaCompletion ->
Eff es (Either AuthError (User, TokenPair))
completeMfa cfg pctx ceremonyId completion = runErrorNoCallStack do
ts <- now
PendingCeremony {kind, userId = mUid, optionsBlob} <-
maybe (throwError PendingCeremonyNotFound) pure =<< takePendingCeremony ceremonyId ts
when (kind /= AuthenticationCeremony) (throwError PendingCeremonyNotFound)
uid <- maybe (throwError PendingCeremonyNotFound) pure mUid
user <- maybe (throwError InvalidCredentials) pure =<< findUserById uid
let User {status = userStatus} = user
when (userStatus /= UserActive) (throwError UserNotActive)
-- 'login' already gates before handing out a ceremony id, so this rarely fires; it keeps
-- the guarantee local to every path that can issue a token.
either throwError pure (ensureEmailVerified cfg user)
let ctx = proofContextFor pctx (loginIdText user.loginId)
factor = completionFactor completion
failure = recordProofFailure cfg.rateLimitConfig ctx factor ts
gate <- guardAbuse cfg.rateLimitConfig ctx ts
when gate.locked do
publishAuthEvent (Event.MfaFailed (Event.MfaFailedData (Just uid) "account_locked" ts))
throwError (completionError completion)
-- Each arm proves the factor (spending the consume-once ceremony on any outcome); all three
-- converge on the shared 'issueSession' tail.
case completion of
MfaPasskey assertion -> do
-- The ceremony must have begun a WebAuthn challenge; a TOTP-only user's empty blob cannot
-- carry an assertion, so refuse it rather than let the ceremony interpreter fail obscurely.
when (BS.null optionsBlob) (failMfa failure (Just uid) "no passkey ceremony was begun")
(passkey, verified) <- verifyAssertion failure (Just uid) optionsBlob assertion
let PasskeyCredential {userId = pkUid, passkeyId} = passkey
VerifiedAuthentication {newSignCounter} = verified
when (pkUid /= uid) (failMfa failure (Just uid) "credential not owned by user")
won <- updatePasskeySignCounter passkeyId newSignCounter ts
unless won (failMfa failure (Just uid) "signature counter replayed")
MfaTotp code -> completeTotp failure uid ts code
MfaRecoveryCode code -> completeRecovery failure uid ts code
recordProofSuccess ctx factor gate.standingLockout ts
(sid, pair) <- issueSession cfg user ts
publishAuthEvent (Event.MfaSucceeded (Event.MfaSucceededData uid sid ts))
pure (user, pair)
-- | Verify a presented TOTP code against the user's /confirmed/ credential and persist the
-- accepted counter (RFC 6238 replay defense). Any failure — no credential, unconfirmed, wrong
-- code, replayed counter — publishes 'MfaFailed' and throws 'TotpCodeInvalid'.
completeTotp ::
(TotpCredentialStore :> es, AuthEventPublisher :> es, Error AuthError :> es) =>
Eff es () ->
UserId ->
UTCTime ->
Text ->
Eff es ()
completeTotp onFailure uid ts code = do
mtc <- findTotpByUser uid
case mtc of
Just TotpCredential {totpCredentialId, secret, lastUsedCounter, confirmedAt}
| isJust confirmedAt ->
case verifyTotp secret lastUsedCounter ts code of
Just accepted -> do
won <- setTotpLastUsedCounter totpCredentialId accepted
unless won (failTyped onFailure (Just uid) "totp_replayed" TotpCodeInvalid ts)
Nothing -> failTyped onFailure (Just uid) "totp_invalid" TotpCodeInvalid ts
_ -> failTyped onFailure (Just uid) "totp_invalid" TotpCodeInvalid ts
-- | Spend a recovery code to complete the challenge: normalize (strip the dash, casefold), hash,
-- and consume via the store's compare-and-set. Success publishes 'RecoveryCodeUsed'; a miss
-- publishes 'MfaFailed' and throws 'RecoveryCodeInvalid'.
completeRecovery ::
(RecoveryCodeStore :> es, AuthEventPublisher :> es, Error AuthError :> es) =>
Eff es () ->
UserId ->
UTCTime ->
Text ->
Eff es ()
completeRecovery onFailure uid ts code = do
ok <- consumeRecoveryCode uid (recoveryCodeHash code) ts
if ok
then publishAuthEvent (Event.RecoveryCodeUsed (Event.RecoveryCodeUsedData uid ts))
else failTyped onFailure (Just uid) "recovery_invalid" RecoveryCodeInvalid ts
-- | Publish 'MfaFailed' with the reason (recorded only in the audit event) and abort with a
-- specific typed error. Unlike 'failMfa' (which always throws the generic 'MfaAssertionInvalid'),
-- this lets the TOTP and recovery arms surface their own machine codes.
failTyped ::
(AuthEventPublisher :> es, Error AuthError :> es) =>
Eff es () ->
Maybe UserId ->
Text ->
AuthError ->
UTCTime ->
Eff es a
failTyped onFailure mUid reason err ts = do
onFailure
publishAuthEvent (Event.MfaFailed (Event.MfaFailedData mUid reason ts))
throwError err
-- | Begin a passwordless login: emit authentication options with NO @allowCredentials@ so
-- the browser offers its discoverable passkeys, stash the pending ceremony with no user
-- attached, and hand the client the ceremony id + options.
beginPasswordlessLogin ::
( PendingCeremonyStore :> es,
WebAuthnCeremony :> es,
Clock :> es,
IOE :> es
) =>
ShomeiConfig ->
Eff es (Either AuthError (CeremonyId, Value))
beginPasswordlessLogin cfg = runErrorNoCallStack do
ts <- now
BeginCeremony {optionsJson, optionsBlob} <- beginAuthenticationCeremony UVRequired []
cid <- genCeremonyId
putPendingCeremony
PendingCeremony
{ ceremonyId = cid,
userId = Nothing,
kind = AuthenticationCeremony,
optionsBlob = optionsBlob,
createdAt = ts,
expiresAt = addUTCTime (pendingCeremonyTTL (webauthnConfig cfg)) ts
}
pure (cid, optionsJson)
-- | Finish a passwordless login: consume the pending ceremony, resolve the user from the
-- asserted credential id (via 'findPasskeyByCredentialId', whose result carries the owning
-- user), verify, bump the counter, publish 'MfaSucceeded', and mint tokens.
completePasswordlessLogin ::
( UserStore :> es,
AuthUnitOfWork :> es,
PasskeyStore :> es,
PendingCeremonyStore :> es,
WebAuthnCeremony :> es,
TokenSigner :> es,
RoleStore :> es,
ClaimsEnricher :> es,
AuthEventPublisher :> es,
LoginAttemptStore :> es,
Clock :> es,
TokenGen :> es
) =>
ShomeiConfig ->
ProofContext ->
CeremonyId ->
Value ->
Eff es (Either AuthError (User, TokenPair))
completePasswordlessLogin cfg pctx ceremonyId assertion = runErrorNoCallStack do
ts <- now
PendingCeremony {kind, optionsBlob} <-
maybe (throwError PendingCeremonyNotFound) pure =<< takePendingCeremony ceremonyId ts
when (kind /= AuthenticationCeremony) (throwError PendingCeremonyNotFound)
let failureCtx = proofContextFor pctx (assertionAccountKey assertion)
failure = recordProofFailure cfg.rateLimitConfig failureCtx FactorPasskey ts
failureGate <- guardAbuse cfg.rateLimitConfig failureCtx ts
when failureGate.locked do
publishAuthEvent (Event.MfaFailed (Event.MfaFailedData Nothing "account_locked" ts))
throwError MfaAssertionInvalid
(passkey, verified) <- verifyAssertion failure Nothing optionsBlob assertion
let PasskeyCredential {userId = pkUid, passkeyId} = passkey
VerifiedAuthentication {newSignCounter} = verified
user <- maybe (throwError InvalidCredentials) pure =<< findUserById pkUid
let User {status = userStatus} = user
when (userStatus /= UserActive) (throwError UserNotActive)
-- The assertion is already verified above, so the account's existence is not in question.
either throwError pure (ensureEmailVerified cfg user)
let ctx = proofContextFor pctx (loginIdText user.loginId)
gate <- guardAbuse cfg.rateLimitConfig ctx ts
when gate.locked do
publishAuthEvent (Event.MfaFailed (Event.MfaFailedData (Just pkUid) "account_locked" ts))
throwError MfaAssertionInvalid
won <- updatePasskeySignCounter passkeyId newSignCounter ts
unless won (failMfa failure (Just pkUid) "signature counter replayed")
recordProofSuccess ctx FactorPasskey gate.standingLockout ts
(sid, pair) <- issueSession cfg user ts
publishAuthEvent (Event.MfaSucceeded (Event.MfaSucceededData pkUid sid ts))
pure (user, pair)
-- | Verify a WebAuthn assertion against the stored passkey it names. Reads the credential
-- id from the assertion JSON (the lookup key — the cryptographic verification still happens in
-- the ceremony interpreter), looks the passkey up to build the verifier input, and calls
-- 'completeAuthenticationCeremony'. On a decode/verify failure, a clone-counter warning, or a
-- missing credential, publishes 'MfaFailed' and throws 'MfaAssertionInvalid'. Returns the
-- looked-up passkey (so callers can read its owning user) alongside the verified result.
verifyAssertion ::
( PasskeyStore :> es,
WebAuthnCeremony :> es,
AuthEventPublisher :> es,
Clock :> es,
Error AuthError :> es
) =>
Eff es () ->
Maybe UserId ->
ByteString ->
Value ->
Eff es (PasskeyCredential, VerifiedAuthentication)
verifyAssertion onFailure mUid blob assertion = do
cid <- maybe (failMfa onFailure mUid "missing credential id") pure (assertionCredentialId assertion)
passkey <- maybe (failMfa onFailure mUid "unknown credential") pure =<< findPasskeyByCredentialId cid
let PasskeyCredential {credentialId, userHandle, publicKey, signCounter, transports} = passkey
stored =
StoredCredentialForVerify
{ credentialId,
userHandle,
publicKey,
signCounter,
transports
}
res <- completeAuthenticationCeremony blob stored assertion
case res of
Left _ -> failMfa onFailure mUid "assertion verification failed"
Right verified ->
let VerifiedAuthentication {cloneWarning} = verified
in if cloneWarning
then failMfa onFailure mUid "signature counter clone warning"
else pure (passkey, verified)
-- | Publish 'MfaFailed' and abort with the generic 'MfaAssertionInvalid'. The reason is
-- recorded in the audit event only; the HTTP body the caller eventually returns stays generic.
failMfa ::
(AuthEventPublisher :> es, Clock :> es, Error AuthError :> es) =>
Eff es () ->
Maybe UserId ->
Text ->
Eff es a
failMfa onFailure mUid reason = do
ts <- now
onFailure
publishAuthEvent (Event.MfaFailed (Event.MfaFailedData mUid reason ts))
throwError MfaAssertionInvalid
-- | Read the credential id out of the browser's assertion JSON, the key used to look the
-- stored passkey up. The deterministic fake interpreter uses @"credentialId"@; a real
-- @webauthn-json@ assertion uses @"rawId"@ (or @"id"@). All three are base64url text decoded
-- by 'WebAuthnCredentialId''s 'FromJSON'. This is the one place the core peeks into the
-- assertion JSON, and only for a lookup key — the cryptographic verification is entirely in the
-- ceremony interpreter.
assertionCredentialId :: Value -> Maybe WebAuthnCredentialId
assertionCredentialId v =
parseField "credentialId" <|> parseField "rawId" <|> parseField "id"
where
parseField k = parseMaybe (withObject "assertion" (\o -> o .: k)) v
completionFactor :: MfaCompletion -> AttemptFactor
completionFactor = \case
MfaPasskey _ -> FactorPasskey
MfaTotp _ -> FactorTotp
MfaRecoveryCode _ -> FactorRecoveryCode
completionError :: MfaCompletion -> AuthError
completionError = \case
MfaPasskey _ -> MfaAssertionInvalid
MfaTotp _ -> TotpCodeInvalid
MfaRecoveryCode _ -> RecoveryCodeInvalid
-- | A passwordless failure has no resolved login id yet. Hash the presented credential id as
-- the account key (or a fixed miss key if it cannot be decoded) while the IP budget remains shared.
assertionAccountKey :: Value -> Text
assertionAccountKey assertion = case assertionCredentialId assertion of
Just (WebAuthnCredentialId bytes) -> b64urlEncode bytes
Nothing -> "unknown-credential"