Skip to content

feat(mfa): behavior redesign - triage helper, login-time offer/skip, OAuth redirect handling#62

Merged
lakhansamani merged 11 commits into
mainfrom
feat/mfa-behavior-redesign
Jul 16, 2026
Merged

feat(mfa): behavior redesign - triage helper, login-time offer/skip, OAuth redirect handling#62
lakhansamani merged 11 commits into
mainfrom
feat/mfa-behavior-redesign

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Summary

  • config exposes per-method MFA meta fields (totp/email_otp/sms_otp/webauthn, is_mfa_enforced)
  • shared resolveAuthStep triage helper replaces each component's own ad hoc should_show_/should_offer_ checks
  • AuthorizerMFASetup gains loginContext - same component now doubles as the login-time offer/skip screen, not just the settings-screen hub
  • login, signup, and passkey-login wired through the triage helper
  • fixes real bug: AuthorizerPasskeyLogin treated a withheld (null access_token) response as a successful login
  • new locked-account screen for the permanent-lockout case
  • AuthorizerRoot detects the OAuth mfa_required redirect param and routes into the MFA screen

Design spec and implementation plan: docs/superpowers/plans/2026-07-15-mfa-behavior-redesign-react.md

Known limitations (accepted, not fixed here)

OAuth-redirect MFA path only, confirmed against the live backend source:

  • Skip / email-OTP / SMS-OTP setup calls from that screen fail server-side - skip_mfa_setup/otp_mfa_setup require email or phone_number to resolve the pending user, and the redirect (mfa_required=1&mfa_methods=...) carries neither.
  • the redirect can't distinguish first-time setup from already-enrolled verify state, so a verify-gate user sees a "Set up" screen with a dead button instead of a code prompt.

Fix needs a small backend change (carry email + an offer/verify signal in the redirect query params) - out of scope for this repo, tracked separately.

Test plan

  • npx tsc --noEmit clean across the whole package
  • grep for deprecated should_offer_mfa_setup - zero hits
  • each task's diff independently reviewed against the plan (see .superpowers/sdd/ review artifacts on the branch)
  • manual E2E: first-time TOTP offer, Skip, passkey-verify-required (Task 4 Steps 7-9) - not run this session, needs a live backend
  • manual E2E: OAuth mfa_required redirect - needs a configured OAuth provider

…stop the auto-firing passkey prompt

- AuthorizerRoot/AuthorizerContext: surface a configLoadError banner instead
  of silently rendering a blank login screen when the backend meta fetch
  fails.
- AuthorizerRoot: gate AuthorizerSocialLogin behind the Login view like its
  siblings, instead of rendering on every view.
- AuthorizerBasicAuthLogin/AuthorizerSignup: switch validation effects to
  functional state updates and null-guard pristine fields, so simultaneous
  effects can't clobber each other's error state on mount.
- AuthorizerBasicAuthLogin: defer the WebAuthn conditional-mediation
  (passkey autofill) ceremony from mount to the email field's first focus,
  so it no longer fires a native passkey prompt on every page load.
- example app: drop noisy console.log callbacks (onStateChangeCallback,
  onLogin/onMagicLinkLogin/onSignup) that spammed the console on every
  state dispatch.
Fixes the actual gaps hit during manual testing: login/signup/passkey
handlers have zero wiring for the withheld-token contract, and
AuthorizerPasskeyLogin silently treats a withheld (null-token)
response as a successful login. Reuses the existing AuthorizerMFASetup
picker and AuthorizerVerifyOtp screens rather than rebuilding them.
6 tasks: expose new meta fields on config, shared AuthResponse triage
helper, AuthorizerMFASetup login mode with skip + real email/SMS OTP
wiring, wire login/signup/passkey-login through the triage helper
(fixes the passkey null-token bug), locked-account screen, OAuth
mfa_required redirect handling.
Focusing the email field triggered a conditional-mediation WebAuthn
ceremony (loginWithPasskeyAutofill) on every fresh mount, surfacing a
1Password/passkey popup unexpectedly on the login page. The explicit
"Sign in with a passkey" button already covers this path without the
surprise prompt.
@lakhansamani
lakhansamani merged commit ccd1c3c into main Jul 16, 2026
0 of 5 checks passed
@lakhansamani
lakhansamani deleted the feat/mfa-behavior-redesign branch July 16, 2026 06:49
lakhansamani added a commit that referenced this pull request Jul 16, 2026
Passkey is a single factor (something you have). Offering it as a
standalone primary-login path when the org requires MFA would let a
user complete auth without ever hitting the second factor. The
server already refuses this independently, but the button shouldn't
invite the attempt - and resolveAuthStep's existing 'verify' branch
already surfaces the server's refusal cleanly if this guard is ever
bypassed (e.g. a stale config read).

Ported forward from the closed PR #61 (feat/enforce-mfa-passkey-gate,
superseded by the plan-based #62) - the only piece of that branch not
already covered by the merged behavior redesign.
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.

1 participant