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, andapp-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
- The assertion goes through the bus;
WebauthnCeremonystays the single crypto seam. New methodsgenerateAssertionOptions(),verifyAssertion(),credentialIdFromAssertion()sit beside the existing registration methods on the same class — no second seam, no directweb-auth/webauthn-libcalls anywhere else. - Two state-specific options endpoints per guard, not one dual-mode endpoint.
POST /mfa/challenge/assertion-options(public, resolves the pending-challenge user fromPendingMfaChallengevia the newMfaChallengeSurfaceenum) andPOST /mfa/webauthn/assertion-options(authenticated, resolves viaActingUser) 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 anauthorize()that inspects request state instead of declaring it, working againstAuthorizationCoverageTest's declared-permission model. - The challenge is minted server-side, single-use, session-stored, server-side-TTL'd, with
allowCredentialsbound to the resolved actor's OWN confirmed WebAuthn/security-key credentials only (WebauthnAssertionOptionsHandler). The TTL mirrors the options' ownPasskeys::timeout(), so a stale challenge can't be replayed even if the client never completes it — the same posture as the registration options envelope. - Failure symmetry. A bad, unknown, or expired assertion takes the SAME failure branch as a
bad code: at the login challenge, 422
MFA_CHALLENGE_REQUIREDand it counts toward the shared 5-attempt lockout; at step-up, 422VALIDATION_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. - A factor-strategy seam (
Actions\Mfa\MfaFactorVerifier) behind the unchangedMfaChallengeVerifierfacade.MfaChallengeVerifiernow holds an ORDERED list of strategies (TotpFactorVerifier→RecoveryCodeFactorVerifier→WebauthnFactorVerifier, bound inAuthenticationServiceProvider) and tries each thatsupports()aMfaChallengeAnswerVO (code vs. credential) until one verifies. The three call sites (VerifyMfaChallengeHandler/VerifyOperatorMfaChallengeHandler/ConfirmStepUpMfaHandler) change fromverify($user, $code)toanswer($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. mfa_methodsis now surfaced on the AUTHENTICATED session state too, not just the pre-challengenext: mfa-challengeresponse —VerifyMfaChallengeHandler,VerifyOperatorMfaChallengeHandler, andGetCurrentOperatorHandlerall now passmfaMethods: $this->availableMfaMethods->for($user)intoSessionStateData/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.- EXPLICITLY REJECTED: relaxing
LAST_MFA_FACTORfor 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 409LAST_MFA_FACTORrefusal (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 !== nullinWebauthnAssertionOptionsQuery::authorize()) — collapsing them into one endpoint would require branching insideauthorize()on ambient request state rather than declaring the permission, whichAuthorizationCoverageTest(D51) is specifically designed to catch and forbid. - Route the MFA assertion through the
laravel/passkeyspackage's own login routes. Rejected: those routes sit onguest:web— the wrong guard forClientUser, and the wrong table (centralpasskeys, operators-only by design per thelaravel-passkeys-developmentskill's golden rule 4). The MFA surface'smfa_credentialstable 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.
AvailableMfaMethodsoutput is now consistent pre- and post-challenge: a client'smfa_methods: ["webauthn","recovery"]on the initialnext: mfa-challengeresponse 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_failedwith 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-optionspublic,.../mfa/webauthn/assertion-optionsauthenticated) plusWebauthnAssertionOptionsQueryjoin theAuthorizationCoverageTestallow-list/declared-permission set; the two authenticatedassertion-optionsroutes are->requires()-gated MFA self-management (actor-only), matching the existingmfa.webauthn.optionssibling. The two publicmfa.challenge.assertion-optionsroutes are session-gated like theirmfa.challengesibling, not permission-gated.