packages feed

shomei-core-0.2.0.0: test/Shomei/Passkey/WorkflowSpec.hs

{-# LANGUAGE DataKinds #-}

-- | Pure tests for the EP-3 passkey enrollment workflows ('Shomei.Passkey.Workflow'),
-- driven over EP-2's in-memory stores and EP-1's deterministic fake 'WebAuthnCeremony'
-- interpreter via 'Shomei.Test.InMemory.runInMemory'. No HTTP, no cryptography.
--
-- The fake's @completeRegistrationCeremony@ accepts a credential JSON whose @challenge@
-- echoes the begin step's options blob and returns the credential id / user handle / public
-- key carried in that JSON, so each test extracts the challenge from the begin response and
-- crafts a matching credential. The same behavior is re-proven over HTTP by the
-- @shomei-servant@ end-to-end test.
module Shomei.Passkey.WorkflowSpec (tests) where

import Data.Aeson (Value, object, (.=))
import Data.Aeson.Types (parseMaybe, withObject, (.:))
import Data.ByteString (ByteString)
import Data.IORef (IORef, newIORef)
import Data.Text (Text)
import Data.Time (UTCTime (..), fromGregorian)
import Shomei.Account.Email.Domain (Email, emailText, mkEmail)
import Shomei.Account.LoginId.Domain (LoginId, mkLoginId)
import Shomei.Account.User.Domain (NewUser (..), User (..))
import Shomei.Account.User.Store (createUser)
import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))
import Shomei.Config (ShomeiConfig, defaultShomeiConfig)
import Shomei.Error (AuthError (..))
import Shomei.Id (PasskeyId, UserId, genCeremonyId, genUserId)
import Shomei.Passkey.Ceremony.Port (WebAuthnError (..))
import Shomei.Passkey.Domain
  ( PasskeyCredential (..),
    PublicKeyBytes (..),
    UserHandle (..),
    WebAuthnCredentialId (..),
  )
import Shomei.Passkey.Workflow
  ( beginPasskeyRegistration,
    completePasskeyRegistration,
    listPasskeys,
    removePasskey,
  )
import Shomei.Test.InMemory (World, emptyWorld, runInMemory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "Shomei.Passkey.Workflow"
    [ testCase "begin then complete stores a passkey; list returns it; remove deletes it" enrollListRemove,
      testCase "wrong-user complete is rejected" wrongUserComplete,
      testCase "absent ceremony is rejected" absentCeremony,
      testCase "an already-consumed ceremony is rejected on the second complete" consumedCeremony,
      testCase "a credential the verifier rejects yields WebAuthnCeremonyError" rejectedCredential
    ]

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

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

aliceEmail :: Email
aliceEmail = case mkEmail "alice@example.com" of
  Right e -> e
  Left err -> error ("bad test email: " <> show err)

newWorld :: IO (IORef World)
newWorld = newIORef (emptyWorld t0)

seedUser :: IORef World -> IO UserId
seedUser ref = runInMemory ref do
  User {userId} <- createUser NewUser {loginId = either (error . show) id (mkLoginId (emailText aliceEmail)), email = Just aliceEmail, displayName = Just "Ada"}
  pure userId

-- Field accessors (OverloadedRecordDot is unreliable for these EP-1 records).
pkPasskeyId :: PasskeyCredential -> PasskeyId
pkPasskeyId PasskeyCredential {passkeyId} = passkeyId

pkLabel :: PasskeyCredential -> Maybe Text
pkLabel PasskeyCredential {label} = label

-- | The challenge the fake baked into a begin step's options JSON (we echo it back).
challengeOf :: Value -> Text
challengeOf v = case parseMaybe (withObject "options" (.: "challenge")) v of
  Just c -> c
  Nothing -> error "challengeOf: no challenge in options"

cid1, uh1, pk1 :: ByteString
cid1 = "passkey-cred-1"
uh1 = "passkey-uh-1"
pk1 = "passkey-pk-1"

-- | A credential JSON the fake accepts: it echoes the challenge and carries base64url bytes.
credentialJson :: Text -> Value
credentialJson chal =
  object
    [ "challenge" .= chal,
      "credentialId" .= WebAuthnCredentialId cid1,
      "userHandle" .= UserHandle uh1,
      "publicKey" .= PublicKeyBytes pk1
    ]

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

enrollListRemove :: IO ()
enrollListRemove = do
  ref <- newWorld
  uid <- seedUser ref
  (cid, opts) <- mustRight =<< runInMemory ref (beginPasskeyRegistration cfg uid)
  pk <- mustRight =<< runInMemory ref (completePasskeyRegistration cfg uid cid (credentialJson (challengeOf opts)) (Just "YubiKey"))
  pkLabel pk @?= Just "YubiKey"
  -- list returns exactly the enrolled passkey
  listed <- runInMemory ref (listPasskeys uid)
  map pkPasskeyId listed @?= [pkPasskeyId pk]
  -- remove deletes it
  _ <- mustRight =<< runInMemory ref (removePasskey uid (pkPasskeyId pk))
  listed2 <- runInMemory ref (listPasskeys uid)
  map pkPasskeyId listed2 @?= []

wrongUserComplete :: IO ()
wrongUserComplete = do
  ref <- newWorld
  uid <- seedUser ref
  otherUid <- genUserId
  (cid, opts) <- mustRight =<< runInMemory ref (beginPasskeyRegistration cfg uid)
  result <- runInMemory ref (completePasskeyRegistration cfg otherUid cid (credentialJson (challengeOf opts)) Nothing)
  result @?= Left PendingCeremonyNotFound

absentCeremony :: IO ()
absentCeremony = do
  ref <- newWorld
  uid <- seedUser ref
  bogusCid <- genCeremonyId
  result <- runInMemory ref (completePasskeyRegistration cfg uid bogusCid (credentialJson "anything") Nothing)
  result @?= Left PendingCeremonyNotFound

consumedCeremony :: IO ()
consumedCeremony = do
  ref <- newWorld
  uid <- seedUser ref
  (cid, opts) <- mustRight =<< runInMemory ref (beginPasskeyRegistration cfg uid)
  _ <- mustRight =<< runInMemory ref (completePasskeyRegistration cfg uid cid (credentialJson (challengeOf opts)) Nothing)
  -- the ceremony was consumed by the first complete; a second is rejected
  again <- runInMemory ref (completePasskeyRegistration cfg uid cid (credentialJson (challengeOf opts)) Nothing)
  again @?= Left PendingCeremonyNotFound

rejectedCredential :: IO ()
rejectedCredential = do
  ref <- newWorld
  uid <- seedUser ref
  (cid, _opts) <- mustRight =<< runInMemory ref (beginPasskeyRegistration cfg uid)
  -- a credential whose challenge does not match the ceremony fails verification
  result <- runInMemory ref (completePasskeyRegistration cfg uid cid (credentialJson "not-the-challenge") Nothing)
  result @?= Left (WebAuthnCeremonyError WebAuthnChallengeMismatch)