Skip to main content

D116 — WebAuthn assertion completes MFA (login challenge + step-up) via a factor-strategy seam

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The mechanics live in app-modules/authentication/src/Actions/WebauthnCeremony.php, app-modules/authentication/src/Actions/MfaChallengeVerifier.php, app-modules/authentication/src/Actions/Mfa/*, app-modules/authentication/src/Queries/WebauthnAssertionOptionsQuery.php + src/Handlers/Queries/WebauthnAssertionOptionsHandler.php, app-modules/authentication/src/Enums/MfaChallengeSurface.php, and app-modules/authentication/routes/authentication-routes.php.

Context

WebAuthn credentials could be enrolled as an MFA factor (MfaMethod::Webauthn/SecurityKey, registered via WebauthnRegisterOptionsQuery + RegisterWebauthnCredentialCommand) but never used to answer anything: MfaChallengeVerifier verified TOTP codes and recovery codes only, at both the login MFA challenge and step-up (sudo). A passkey-only user (registered WebAuthn, no TOTP) who spent their one-time recovery codes had no way to complete a login challenge or open a step-up window — the enrolled factor was decorative.

Decision

  1. The assertion goes through the bus; WebauthnCeremony stays the single crypto seam. New methods generateAssertionOptions(), verifyAssertion(), credentialIdFromAssertion() sit beside the existing registration methods on the same class — no second seam, no direct web-auth/webauthn-lib calls anywhere else.
  2. Two state-specific options endpoints per guard, not one dual-mode endpoint. POST /mfa/challenge/assertion-options (public, resolves the pending-challenge user from PendingMfaChallenge via the new MfaChallengeSurface enum) and POST /mfa/webauthn/assertion-options (authenticated, resolves via ActingUser) are separate routes/queries (WebauthnAssertionOptionsQuery(pendingSurface: ...)), rather than one endpoint that branches on session state internally. The two callers have genuinely different authorization postures (session-gated vs. actor-gated) — collapsing them would smuggle an authorize() that inspects request state instead of declaring it, working against AuthorizationCoverageTest's declared-permission model.
  3. The challenge is minted server-side, single-use, session-stored, server-side-TTL'd, with allowCredentials bound to the resolved actor's OWN confirmed WebAuthn/security-key credentials only (WebauthnAssertionOptionsHandler). The TTL mirrors the options' own Passkeys::timeout(), so a stale challenge can't be replayed even if the client never completes it — the same posture as the registration options envelope.
  4. Failure symmetry. A bad, unknown, or expired assertion takes the SAME failure branch as a bad code: at the login challenge, 422 MFA_CHALLENGE_REQUIRED and it counts toward the shared 5-attempt lockout; at step-up, 422 VALIDATION_ERROR (step-up has no lockout counter for either factor). No enumeration signal distinguishes "wrong code" from "wrong/replayed/expired assertion" — both are indistinguishable second-factor failure.
  5. A factor-strategy seam (Actions\Mfa\MfaFactorVerifier) behind the unchanged MfaChallengeVerifier facade. MfaChallengeVerifier now holds an ORDERED list of strategies (TotpFactorVerifierRecoveryCodeFactorVerifierWebauthnFactorVerifier, bound in AuthenticationServiceProvider) and tries each that supports() a MfaChallengeAnswer VO (code vs. credential) until one verifies. The three call sites (VerifyMfaChallengeHandler/VerifyOperatorMfaChallengeHandler/ConfirmStepUpMfaHandler) change from verify($user, $code) to answer($user, $answer) but keep the same success/failure contract. A future factor (SMS, email OTP, push) is one class + one registration line — none of the three handlers change.
  6. mfa_methods is now surfaced on the AUTHENTICATED session state too, not just the pre-challenge next: mfa-challenge response — VerifyMfaChallengeHandler, VerifyOperatorMfaChallengeHandler, and GetCurrentOperatorHandler all now pass mfaMethods: $this->availableMfaMethods->for($user) into SessionStateData/ OperatorSessionData. The abilities payload (meta.abilities, step-up freshness/version hash) is unchanged — this is additive to the session resource, not a new abilities concept.
  7. EXPLICITLY REJECTED: relaxing LAST_MFA_FACTOR for a passkey-only user. With assertion verification live at the challenge and step-up, a passkey-only posture (WebAuthn enrolled, no TOTP, recovery codes spent) is now FULLY FUNCTIONAL — the user can log in and step up with the passkey alone. The existing 409 LAST_MFA_FACTOR refusal (refusing to remove a user's literal last confirmed factor, DeleteMfaCredentialHandler) is therefore the CORRECT guard as-is; no special case is needed (or wanted) for "passkey remaining, but it's the last one" — that is precisely the case the guard exists to protect. Before this change, a passkey-only user hitting that guard was arguably being protected from a wallet a challenge could never actually redeem with; now the protection is real.

Alternatives considered

  • A single dual-mode options endpoint that inspects session state to decide whether it is serving a pending-challenge or an authenticated actor. Rejected: the two callers need different authorization decisions ($this->pendingSurface !== null || $actor !== null in WebauthnAssertionOptionsQuery::authorize()) — collapsing them into one endpoint would require branching inside authorize() on ambient request state rather than declaring the permission, which AuthorizationCoverageTest (D51) is specifically designed to catch and forbid.
  • Route the MFA assertion through the laravel/passkeys package's own login routes. Rejected: those routes sit on guest:web — the wrong guard for ClientUser, and the wrong table (central passkeys, operators-only by design per the laravel-passkeys-development skill's golden rule 4). The MFA surface's mfa_credentials table is deliberately separate and RLS-scoped; using the package routes here would either require adding tenant awareness to a central-only package surface or forking the package's routing, both worse than the existing bus-based seam this decision extends.

Consequences

  • A passkey-only user (WebAuthn enrolled, recovery codes exhausted) is no longer functionally locked out of login/step-up — the gap this decision closes.
  • AvailableMfaMethods output is now consistent pre- and post-challenge: a client's mfa_methods: ["webauthn","recovery"] on the initial next: mfa-challenge response matches the post-challenge authenticated session response.
  • Every WebAuthn assertion failure (bad credential, unknown id, expired/missing challenge, a webauthn-lib rejection) logs authentication.mfa.webauthn_assertion_failed with the same context posture as the existing registration failure line — diagnosable without ever logging credential material.
  • Four new routes (two per guard: .../mfa/challenge/assertion-options public, .../mfa/webauthn/assertion-options authenticated) plus WebauthnAssertionOptionsQuery join the AuthorizationCoverageTest allow-list/declared-permission set; the two authenticated assertion-options routes are ->requires()-gated MFA self-management (actor-only), matching the existing mfa.webauthn.options sibling. The two public mfa.challenge.assertion-options routes are session-gated like their mfa.challenge sibling, not permission-gated.

← Engineering decision log