shomei-core-0.2.0.0: test/Shomei/Mfa/WorkflowSpec.hs
-- | Behavioral tests for the EP-4 MFA step-up and passwordless login workflows
-- ('Shomei.Session.Authentication.Workflow.login' widened to 'LoginResult', and 'Shomei.Mfa.Workflow'), run entirely
-- through the in-memory interpreter ('Shomei.Test.InMemory.runInMemory') with EP-1's
-- deterministic fake 'Shomei.Passkey.Ceremony.Port'. No cryptography, no database, no network.
--
-- The fake accepts an assertion 'Data.Aeson.Value' that echoes the begin step's @challenge@ and
-- carries base64url @credentialId@/@userHandle@/@publicKey@ fields; 'acceptedAssertion' builds
-- one matching the seeded passkey.
module Shomei.Mfa.WorkflowSpec (tests) where
import Control.Monad (replicateM_)
import Data.Aeson (Value, object, (.=))
import Data.Aeson.Types (parseMaybe, withObject, (.:))
import Data.IORef (IORef, newIORef, readIORef)
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (UTCTime (..), fromGregorian)
import Shomei.Account.Email.Domain (Email, emailText, mkEmail)
import Shomei.Account.LoginId.Domain (LoginId, mkLoginId)
import Shomei.Account.Password.Domain (PlainPassword (..))
import Shomei.Account.User.Domain (User (..))
import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))
import Shomei.Config (ShomeiConfig, defaultShomeiConfig)
import Shomei.Error (AuthError (InvalidCredentials, MfaAssertionInvalid, PendingCeremonyNotFound, TotpCodeInvalid))
import Shomei.Id (CeremonyId, genCeremonyId, genTotpCredentialId)
import Shomei.Mfa.Totp.Algorithm (TotpSecret (..), totpCode, totpCounter)
import Shomei.Mfa.Totp.Domain (NewTotpCredential (..))
import Shomei.Mfa.Totp.Store (confirmTotp, findTotpByUser, upsertTotpEnrollment)
import Shomei.Mfa.Totp.Workflow (TotpRemovalProof (..), removeTotp)
import Shomei.Mfa.Workflow (MfaCompletion (..), beginPasswordlessLogin, completeMfa, completePasswordlessLogin)
import Shomei.Passkey.Domain
( NewPasskeyCredential (..),
PublicKeyBytes (..),
SignatureCounter (..),
UserHandle (..),
WebAuthnCredentialId (..),
)
import Shomei.Passkey.Store (createPasskey)
import Shomei.Session.Authentication.Workflow (LoginResult (..), MfaChallenge (..), login, signup)
import Shomei.Session.Command (ClientContext (..), LoginCommand (..), ProofContext (..), SignupCommand (..))
import Shomei.Session.LoginAttempt.Domain (AccountKey (..), AccountLockout (..), ClientIp (..))
import Shomei.Session.LoginAttempt.Store (setAccountLockout)
import Shomei.Session.Token.Domain (AccessToken (..), TokenPair (..))
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
-- | The default config requires a second factor when one is enrolled.
cfg :: ShomeiConfig
cfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")
aliceEmail :: Email
aliceEmail = mkEmail' "alice@example.com"
strongPw :: PlainPassword
strongPw = PlainPassword "correct horse battery staple"
proofContext :: ProofContext
proofContext = ProofContext {clientIp = ClientIp "test-ip", accountKeyOf = AccountKey}
mkEmail' :: Text -> Email
mkEmail' t = either (\e -> error ("bad test email: " <> show e)) id (mkEmail t)
ctxFor :: Email -> ClientContext
ctxFor e = ClientContext (ClientIp "test-ip") (AccountKey (emailText e))
-- The fixed bytes of the single seeded passkey.
seededCredId :: WebAuthnCredentialId
seededCredId = WebAuthnCredentialId "cred-1"
seededHandle :: UserHandle
seededHandle = UserHandle "uh-1"
seededKey :: PublicKeyBytes
seededKey = PublicKeyBytes "pk-1"
expectRight :: (Show e) => Either e a -> IO a
expectRight = either (\e -> assertFailure ("expected Right, got Left: " <> show e)) pure
-- | Sign a user up and seed one passkey for them (directly through 'createPasskey').
seedUserWithPasskey :: IORef World -> IO ()
seedUserWithPasskey ref = do
(user, _) <- expectRight =<< runInMemory ref (signup cfg (SignupCommand {loginId = either (error . show) id (mkLoginId (emailText aliceEmail)), email = Just aliceEmail, password = strongPw, displayName = Just "Alice"}))
let User {userId = uid} = user
_ <-
runInMemory
ref
( createPasskey
NewPasskeyCredential
{ userId = uid,
credentialId = seededCredId,
userHandle = seededHandle,
publicKey = seededKey,
signCounter = SignatureCounter 0,
transports = [],
label = Just "Test Key",
createdAt = fixedTime
}
)
pure ()
-- | An assertion JSON the fake accepts for the seeded passkey, echoing @challenge@.
acceptedAssertion :: Text -> Value
acceptedAssertion chal =
object
[ "challenge" .= chal,
"credentialId" .= seededCredId,
"userHandle" .= seededHandle,
"publicKey" .= seededKey
]
-- | The @challenge@ baked into a begin step's options 'Value'.
challengeOf :: Value -> Maybe Text
challengeOf = parseMaybe (withObject "options" (\o -> o .: "challenge"))
-- | Assert a token pair carries a non-empty access token.
assertTokenPresent :: (User, TokenPair) -> IO ()
assertTokenPresent (_user, TokenPair (AccessToken at) _ _) =
assertBool "access token present" (not (T.null at))
-- Tests ----------------------------------------------------------------------
tests :: TestTree
tests =
testGroup
"Shomei.Mfa.Workflow"
[ testNoPasskeyComplete,
testMfaRequired,
testCompleteMfa,
testCeremonyHygiene,
testBadAssertion,
testBadPasskeysLock,
testSecondFactorSuccessClearsLockout,
testTotpRemovalFailuresLock,
testPasswordless
]
testNoPasskeyComplete :: TestTree
testNoPasskeyComplete = testCase "no-passkey login yields LoginComplete with a token" do
ref <- newIORef (emptyWorld fixedTime)
_ <- expectRight =<< runInMemory ref (signup cfg (SignupCommand {loginId = either (error . show) id (mkLoginId (emailText aliceEmail)), email = Just aliceEmail, password = strongPw, displayName = Just "Alice"}))
res <- expectRight =<< runInMemory ref (login cfg (ctxFor aliceEmail) (LoginCommand (either (error . show) id (mkLoginId (emailText aliceEmail))) strongPw))
case res of
LoginComplete u pair -> assertTokenPresent (u, pair)
MfaRequired _ -> assertFailure "expected LoginComplete (no passkey enrolled)"
testMfaRequired :: TestTree
testMfaRequired = testCase "passkey + required second factor yields MfaRequired, no token" do
ref <- newIORef (emptyWorld fixedTime)
seedUserWithPasskey ref
res <- expectRight =<< runInMemory ref (login cfg (ctxFor aliceEmail) (LoginCommand (either (error . show) id (mkLoginId (emailText aliceEmail))) strongPw))
case res of
MfaRequired (MfaChallenge _cid opts _methods) ->
assertBool "a challenge is present in the options" (challengeOf opts /= Nothing)
LoginComplete _ _ -> assertFailure "expected MfaRequired (passkey enrolled, second factor required)"
testCompleteMfa :: TestTree
testCompleteMfa = testCase "completeMfa with a valid assertion yields a token pair" do
ref <- newIORef (emptyWorld fixedTime)
seedUserWithPasskey ref
(cid, opts) <- loginExpectingChallenge ref
chal <- maybe (assertFailure "no challenge in options") pure (challengeOf opts)
done <- expectRight =<< runInMemory ref (completeMfa cfg proofContext cid (MfaPasskey (acceptedAssertion chal)))
assertTokenPresent done
testCeremonyHygiene :: TestTree
testCeremonyHygiene = testCase "bogus or consumed ceremony is rejected (PendingCeremonyNotFound)" do
ref <- newIORef (emptyWorld fixedTime)
seedUserWithPasskey ref
-- A ceremony id that was never stored.
bogus <- genCeremonyId
bad <- runInMemory ref (completeMfa cfg proofContext bogus (MfaPasskey (acceptedAssertion "x")))
bad @?= Left PendingCeremonyNotFound
-- A real challenge succeeds once; re-completing the now-consumed ceremony is a 404.
(cid, opts) <- loginExpectingChallenge ref
chal <- maybe (assertFailure "no challenge in options") pure (challengeOf opts)
_ <- expectRight =<< runInMemory ref (completeMfa cfg proofContext cid (MfaPasskey (acceptedAssertion chal)))
again <- runInMemory ref (completeMfa cfg proofContext cid (MfaPasskey (acceptedAssertion chal)))
again @?= Left PendingCeremonyNotFound
testBadAssertion :: TestTree
testBadAssertion = testCase "completeMfa with an unknown credential fails with MfaAssertionInvalid" do
ref <- newIORef (emptyWorld fixedTime)
seedUserWithPasskey ref
(cid, opts) <- loginExpectingChallenge ref
chal <- maybe (assertFailure "no challenge in options") pure (challengeOf opts)
let wrong =
object
[ "challenge" .= chal,
"credentialId" .= WebAuthnCredentialId "cred-unknown",
"userHandle" .= UserHandle "uh-x",
"publicKey" .= PublicKeyBytes "pk-x"
]
res <- runInMemory ref (completeMfa cfg proofContext cid (MfaPasskey wrong))
res @?= Left MfaAssertionInvalid
testBadPasskeysLock :: TestTree
testBadPasskeysLock = testCase "five bad passkey assertions lock the account" do
ref <- newIORef (emptyWorld fixedTime)
seedUserWithPasskey ref
replicateM_ 5 do
(cid, opts) <- loginExpectingChallenge ref
chal <- maybe (assertFailure "no challenge in options") pure (challengeOf opts)
let wrong =
object
[ "challenge" .= chal,
"credentialId" .= WebAuthnCredentialId "cred-unknown",
"userHandle" .= UserHandle "uh-x",
"publicKey" .= PublicKeyBytes "pk-x"
]
result <- runInMemory ref (completeMfa cfg proofContext cid (MfaPasskey wrong))
result @?= Left MfaAssertionInvalid
denied <- runInMemory ref (login cfg (ctxFor aliceEmail) (LoginCommand (either (error . show) id (mkLoginId (emailText aliceEmail))) strongPw))
denied @?= Left InvalidCredentials
testSecondFactorSuccessClearsLockout :: TestTree
testSecondFactorSuccessClearsLockout = testCase "second-factor success clears an expired standing lockout" do
ref <- newIORef (emptyWorld fixedTime)
seedUserWithPasskey ref
let key = AccountKey (emailText aliceEmail)
expired = AccountLockout key 5 (Just fixedTime) fixedTime
runInMemory ref (setAccountLockout expired)
(cid, opts) <- loginExpectingChallenge ref
chal <- maybe (assertFailure "no challenge in options") pure (challengeOf opts)
_ <- expectRight =<< runInMemory ref (completeMfa cfg proofContext cid (MfaPasskey (acceptedAssertion chal)))
w <- readIORef ref
assertBool "the standing lockout row is cleared" (not (Map.member key w.accountLockouts))
testTotpRemovalFailuresLock :: TestTree
testTotpRemovalFailuresLock = testCase "wrong TOTP removal codes count and a locked account cannot remove the factor" do
ref <- newIORef (emptyWorld fixedTime)
(user, _) <- expectRight =<< runInMemory ref (signup cfg (SignupCommand {loginId = either (error . show) id (mkLoginId (emailText aliceEmail)), email = Just aliceEmail, password = strongPw, displayName = Just "Alice"}))
tcid <- genTotpCredentialId
let secret = TotpSecret "12345678901234567890"
correct = totpCode 6 secret (totpCounter fixedTime)
wrong = if correct == "000000" then "999999" else "000000"
runInMemory ref do
_ <- upsertTotpEnrollment NewTotpCredential {totpCredentialId = tcid, userId = user.userId, secret, createdAt = fixedTime}
confirmTotp tcid fixedTime
replicateM_ 5 do
result <- runInMemory ref (removeTotp cfg proofContext user (RemoveWithCode wrong))
result @?= Left TotpCodeInvalid
lockedResult <- runInMemory ref (removeTotp cfg proofContext user (RemoveWithCode correct))
lockedResult @?= Left TotpCodeInvalid
remaining <- runInMemory ref (findTotpByUser user.userId)
assertBool "the locked removal leaves the TOTP credential intact" (isJust remaining)
testPasswordless :: TestTree
testPasswordless = testCase "passwordless login resolves the user and mints tokens" do
ref <- newIORef (emptyWorld fixedTime)
seedUserWithPasskey ref
(cid, opts) <- expectRight =<< runInMemory ref (beginPasswordlessLogin cfg)
chal <- maybe (assertFailure "no challenge in options") pure (challengeOf opts)
done <- expectRight =<< runInMemory ref (completePasswordlessLogin cfg proofContext cid (acceptedAssertion chal))
assertTokenPresent done
-- | Log in (password) for the seeded user and expect an MFA challenge, returning its
-- ceremony id and options.
loginExpectingChallenge :: IORef World -> IO (CeremonyId, Value)
loginExpectingChallenge ref = do
res <- expectRight =<< runInMemory ref (login cfg (ctxFor aliceEmail) (LoginCommand (either (error . show) id (mkLoginId (emailText aliceEmail))) strongPw))
case res of
MfaRequired (MfaChallenge cid opts _methods) -> pure (cid, opts)
LoginComplete _ _ -> assertFailure "expected MfaRequired"