Skip to content

fix(mfa): route passkey-verify errors through resolveAuthStep#70

Merged
lakhansamani merged 1 commit into
mainfrom
fix/webotp-abort-passkey-lockout
Jul 24, 2026
Merged

fix(mfa): route passkey-verify errors through resolveAuthStep#70
lakhansamani merged 1 commit into
mainfrom
fix/webotp-abort-passkey-lockout

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Summary

  • onVerifyWithPasskey (AuthorizerVerifyOtp.tsx) returned early on any errors.length before ever calling resolveAuthStep, making its 'locked' branch (setWebauthnLocked → renders AuthorizerMfaLocked) dead code. A locked-account error during passkey verify surfaced as a generic dismissable message instead of the dedicated lockout screen — unlike every other login-capable component, which already routes unconditionally through resolveAuthStep.
  • Fix keeps the isUserDismissed (NotAllowedError/AbortError) short-circuit for cancelled ceremonies, but everything else now goes through resolveAuthStep so the locked path is reachable.
  • Adds e2e coverage for the interaction issue No e2e coverage for WebOTP's webauthnLocked abort path (untestable in Chromium) #69 was tracking: a passkey-verify lockout now correctly aborts a pending WebOTP navigator.credentials.get() listener (webauthnLocked is in AuthorizerVerifyOtp's WebOTP effect's dependency array specifically for this). WebOTP is Android-Chrome-only, so the new test shims window.OTPCredential and the otp-branch of navigator.credentials.get to force the effect's feature-detection true — it proves the abort wiring, not real WebOTP interop, and says so in its own comments.

Closes #69

Test plan

  • npm run test (tsc --noEmit): clean.
  • Full example/e2e/ Playwright suite (local backend + app, Chromium): 7/7 passing, including the 6 pre-existing MFA specs (no regression) and the new mfa-webotp-abort.spec.ts.
  • Verified the new test actually catches the fix: reverted the onVerifyWithPasskey change locally and re-ran — the new test fails (times out waiting for the abort signal) as expected, confirming it isn't a vacuous pass.

onVerifyWithPasskey returned early on any errors.length before ever
calling resolveAuthStep, so its 'locked' branch (setWebauthnLocked,
which renders AuthorizerMfaLocked) was dead code - a locked-account
error surfaced as a generic dismissable message instead of the
lockout screen, unlike every other login-capable component that
already routes through resolveAuthStep unconditionally.

Keep the isUserDismissed (NotAllowedError/AbortError) short-circuit,
since those are ceremony cancellations that should stay silent, not
be shown as an error or misread as a lock.

Adds e2e coverage for the interaction this enables: a passkey-verify
lockout now correctly aborts a pending WebOTP navigator.credentials
.get() listener (webauthnLocked in AuthorizerVerifyOtp's effect deps).
WebOTP is Android-Chrome-only, so the test shims window.OTPCredential
and the 'otp' branch of navigator.credentials.get to force the
effect's feature-detection true - it proves the abort wiring, not
real WebOTP interop.

Closes #69
@github-actions

Copy link
Copy Markdown

size-limit report 📦

Path Size
dist/index.cjs 60.37 KB (-0.14% 🔽)
dist/index.mjs 59.99 KB (+0.06% 🔺)

@lakhansamani
lakhansamani merged commit c8b4c15 into main Jul 24, 2026
5 checks passed
@lakhansamani
lakhansamani deleted the fix/webotp-abort-passkey-lockout branch July 24, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No e2e coverage for WebOTP's webauthnLocked abort path (untestable in Chromium)

1 participant