shomei-webauthn-0.3.0.0: test/Shomei/WebAuthn/WorkflowSpec.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TypeOperators #-}
module Shomei.WebAuthn.WorkflowSpec (tests) where
import Control.Monad (forM_)
import Data.Aeson (Value, encode)
import Data.Aeson.Types (parseMaybe, withObject, (.:))
import Data.ByteString.Lazy qualified as LBS
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Map.Strict qualified as Map
import Data.Time (UTCTime (..), addUTCTime, fromGregorian)
import Effectful (Eff, IOE, liftIO, runEff, (:>))
import Effectful.Dispatch.Dynamic (interpose)
import Shomei.Account.LoginId.Domain (LoginId, mkLoginId)
import Shomei.Account.Password.Domain (PlainPassword (..))
import Shomei.Account.User.Domain (NewUser (..), User (..))
import Shomei.Account.User.Store (createUser)
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))
import Shomei.Config (ShomeiConfig (..), UserVerificationPolicy (..), WebAuthnConfig (..), defaultShomeiConfig, defaultWebAuthnConfig)
import Shomei.Error (AuthError (..))
import Shomei.Id (genCeremonyId)
import Shomei.Mfa.Workflow (MfaCompletion (..), beginPasswordlessLogin, completeMfa, completePasswordlessLogin)
import Shomei.Passkey.Ceremony.Port
import Shomei.Passkey.Ceremony.Store (putPendingCeremony)
import Shomei.Passkey.Domain (CeremonyKind (..), PasskeyCredential (..), PendingCeremony (..), SignatureCounter (..), b64urlDecode)
import Shomei.Passkey.Workflow (beginPasskeyRegistration, completePasskeyRegistration)
import Shomei.Session.Authentication.Workflow (LoginResult (..), MfaChallenge (..), login, signup)
import Shomei.Session.Command (ClientContext (..), LoginCommand (..), ProofContext (..), SignupCommand (..))
import Shomei.Session.LoginAttempt.Domain (AccountKey (..), ClientIp (..))
import Shomei.Test.InMemory (InMemoryPorts, World (..), emptyWorld, runInMemory)
import Shomei.WebAuthn.Ceremony (runWebAuthnCeremonyLibrary)
import Shomei.WebAuthn.SoftwareAuthenticator
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
-- Storage, Clock, signing and password hashes use the normal fake stack. Only the
-- ceremony effect is interposed, and every completion runs the production verifier.
-- Freeze each challenge after the real begin step so fixture payloads are reproducible.
realCeremonies :: (WebAuthnCeremony :> es, IOE :> es) => WebAuthnConfig -> Eff es a -> Eff es a
realCeremonies cfg = interpose \_ -> \case
BeginRegistrationCeremony u ids -> liftIO (freeze =<< runEff (runWebAuthnCeremonyLibrary cfg (beginRegistrationCeremony u ids)))
BeginAuthenticationCeremony uv ids -> liftIO (freeze =<< runEff (runWebAuthnCeremonyLibrary cfg (beginAuthenticationCeremony uv ids)))
CompleteRegistrationCeremony b v -> liftIO (runEff (runWebAuthnCeremonyLibrary cfg (completeRegistrationCeremony b v)))
CompleteAuthenticationCeremony b c v -> liftIO (runEff (runWebAuthnCeremonyLibrary cfg (completeAuthenticationCeremony b c v)))
where
freeze BeginCeremony {optionsJson} = let v = freezeChallenge optionsJson in pure (BeginCeremony v (LBS.toStrict (encode v)))
cfg :: ShomeiConfig
cfg = (defaultShomeiConfig (Issuer "test") (Audience "test")) {webauthnConfig = defaultWebAuthnConfig {origins = ["https://localhost"], userVerification = UVDiscouraged}}
runHybrid :: IORef World -> Eff InMemoryPorts a -> IO a
runHybrid ref = runInMemory ref . realCeremonies cfg.webauthnConfig
fixedTime :: UTCTime
fixedTime = UTCTime (fromGregorian 2026 1 1) 0
right :: (Show e) => Either e a -> IO a
right = either (assertFailure . show) pure
proof :: ProofContext
proof = ProofContext (ClientIp "fixture") AccountKey
fixtureLoginId :: LoginId
fixtureLoginId = either (error . show) id (mkLoginId "fixture-user")
fixturePassword :: PlainPassword
fixturePassword = PlainPassword "correct horse battery staple"
seed :: IO (IORef World, User, ResponsePolicy)
seed = do
ref <- newIORef (emptyWorld fixedTime)
(user, _) <- right =<< runHybrid ref (signup cfg (SignupCommand fixtureLoginId Nothing fixturePassword Nothing))
(cid, options) <- right =<< runHybrid ref (beginPasskeyRegistration cfg user.userId)
handle <- case parseMaybe (withObject "options" (\o -> o .: "user" >>= withObject "user" (.: "id"))) options of
Just value -> right (b64urlDecode value)
Nothing -> assertFailure "fixture options lack user handle"
let policy = goodPolicy {responseHandle = handle, responseCounter = 0}
_ <- right =<< runHybrid ref (completePasskeyRegistration cfg user.userId cid (registrationResponse options policy) Nothing)
replay <- runHybrid ref (completePasskeyRegistration cfg user.userId cid (registrationResponse options policy) Nothing)
replay @?= Left PendingCeremonyNotFound
pure (ref, user, policy)
tests :: TestTree
tests =
testGroup
"signed WebAuthn workflows"
[ testCase "signed enrollment, password step-up and passwordless login persist counters and audit winners" do
(ref, user, policy) <- seed
challenge <- right =<< runHybrid ref (login cfg (ClientContext (ClientIp "fixture") (AccountKey "fixture-user")) (LoginCommand fixtureLoginId fixturePassword False))
case challenge of
MfaRequired MfaChallenge {ceremonyId, options} -> do
_ <- right =<< runHybrid ref (completeMfa cfg proof ceremonyId (MfaPasskey (assertionResponse options policy {responseCounter = 1, responseFlags = 1})))
replay <- runHybrid ref (completeMfa cfg proof ceremonyId (MfaPasskey (assertionResponse options policy {responseCounter = 1})))
replay @?= Left PendingCeremonyNotFound
_ -> assertFailure "expected password step-up"
(cid, options) <- right =<< runHybrid ref (beginPasswordlessLogin cfg)
(resolved, _) <- right =<< runHybrid ref (completePasswordlessLogin cfg proof cid (assertionResponse options policy {responseCounter = 2}))
resolved.userId @?= user.userId
world <- readIORef ref
map (\PasskeyCredential {signCounter} -> signCounter) (Map.elems world.passkeys) @?= [SignatureCounter 2]
length [() | Event.MfaSucceeded _ <- world.publishedEvents] @?= 2
(replayCid, replayOptions) <- right =<< runHybrid ref (beginPasswordlessLogin cfg)
cloned <- runHybrid ref (completePasswordlessLogin cfg proof replayCid (assertionResponse replayOptions policy {responseCounter = 2}))
cloned @?= Left MfaAssertionInvalid
afterClone <- readIORef ref
length [() | Event.MfaFailed _ <- afterClone.publishedEvents] @?= 1,
testCase "passwordless always requires UV; signed bad proofs consume once and audit failures" do
forM_ [UVPreferred, UVDiscouraged] \uv -> do
(ref, _, policy) <- seed
let run :: Eff InMemoryPorts a -> IO a
run = runInMemory ref . realCeremonies cfg.webauthnConfig {userVerification = uv}
(cid, options) <- right =<< run (beginPasswordlessLogin cfg)
failed <- run (completePasswordlessLogin cfg proof cid (assertionResponse options policy {responseCounter = 1, responseFlags = 1}))
failed @?= Left MfaAssertionInvalid
replay <- run (completePasswordlessLogin cfg proof cid (assertionResponse options policy {responseCounter = 1}))
replay @?= Left PendingCeremonyNotFound
world <- readIORef ref
length [() | Event.MfaFailed _ <- world.publishedEvents] @?= 1
assertBool "failed proof recorded in shared abuse budget" (not (null world.loginAttempts)),
testCase "a correctly signed credential owned by another user fails the step-up owner check" do
(ref, _, policy) <- seed
let bobLogin = either (error . show) id (mkLoginId "bob-fixture")
bob <- runHybrid ref (createUser (NewUser bobLogin Nothing Nothing))
(cid, options) <- runHybrid ref do
BeginCeremony {optionsJson, optionsBlob} <- beginAuthenticationCeremony UVRequired []
cid <- genCeremonyId
putPendingCeremony (PendingCeremony cid (Just bob.userId) AuthenticationCeremony optionsBlob fixedTime (addUTCTime 300 fixedTime))
pure (cid, optionsJson)
wrongOwner <- runHybrid ref (completeMfa cfg proof cid (MfaPasskey (assertionResponse options policy {responseCounter = 1})))
wrongOwner @?= Left MfaAssertionInvalid
world <- readIORef ref
length [() | Event.MfaFailed _ <- world.publishedEvents] @?= 1
length [() | Event.MfaSucceeded _ <- world.publishedEvents] @?= 0,
testCase "expired signed ceremony fails before minting or counter advancement" do
(ref, _, policy) <- seed
(cid, options) <- right =<< runHybrid ref (beginPasswordlessLogin cfg)
modifyIORef' ref (\w -> w {clock = addUTCTime 301 fixedTime})
expired <- runHybrid ref (completePasswordlessLogin cfg proof cid (assertionResponse options policy {responseCounter = 1}))
expired @?= Left PendingCeremonyNotFound
world <- readIORef ref
map (\PasskeyCredential {signCounter} -> signCounter) (Map.elems world.passkeys) @?= [SignatureCounter 0]
]