Skip to content

feat(mfa): optional MFA setup, passkey as a second factor, and hide passkey login when MFA is enforced#61

Closed
lakhansamani wants to merge 20 commits into
mainfrom
feat/enforce-mfa-passkey-gate
Closed

feat(mfa): optional MFA setup, passkey as a second factor, and hide passkey login when MFA is enforced#61
lakhansamani wants to merge 20 commits into
mainfrom
feat/enforce-mfa-passkey-gate

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Summary

Frontend for the three-part MFA work in authorizerdev/authorizer#685 and authorizerdev/authorizer-js#45:

  • Optional MFA with skip: AuthorizerBasicAuthLogin logs the user in immediately and surfaces should_offer_mfa_setup instead of blocking on TOTP setup; AuthorizerMFASetup gained a working Skip action, demonstrated end to end in example/.
  • Passkey as a second factor: AuthorizerVerifyOtp offers "verify with your passkey" alongside the TOTP code form (reusing the already-shipped authorizerRef.loginWithPasskey(email)), with the code form correctly staying available as a fallback when the browser doesn't support WebAuthn.
  • Hide passkey login when MFA is enforced: AuthorizerPasskeyLogin's primary "Sign in with a passkey" button no longer renders when config.is_mfa_enforced is true — authenticator methods should only surface after a first factor has identified the user, never as a standalone bypass. The actual security boundary is the backend gate in #685; this is the matching UX fix, plus a defense-in-depth guard against ever treating a tokenless response as a successful login.

⚠️ Opened as a draft — not mergeable as-is. package.json's @authorizerdev/authorizer-js dependency currently points at a local file: path on the dev machine (file:../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate), since authorizer-js@3.6.0-rc.0 hasn't been published to npm yet. Once authorizerdev/authorizer-js#45 is published, this needs a follow-up commit repointing the dependency at the real registry version before this can merge or pass CI.

Test plan

  • npx tsc --noEmit clean, npm run build clean, at every step
  • Manual browser testing of the signup → optional-MFA-offer → skip/enroll flow against a local dev server (SQLite, --disable-email-otp to isolate the TOTP/passkey path from the separate email-OTP branch)
  • Manual verification of the passkey-verify-alongside-TOTP flow and the enforce-mfa button-hide against a live server with a real registered passkey (blocked on the SDK being pointed at a real package — currently only testable via the local file: link)
  • example/ app's own tsc --noEmit clean

…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.
…-app wiring

AuthorizerMFASetup gains an optional onSkip prop rendering a "Skip for
now" button in the method-list view. The example app's login.tsx
captures should_offer_mfa_setup + authenticator_* fields from onLogin
into sessionStorage, and dashboard.tsx renders AuthorizerMFASetup with
onSkip wired to authorizerRef.skipMfaSetup() when an offer is pending,
falling back to the normal dashboard otherwise.
Malformed sessionStorage data would throw and crash the dashboard on
mount instead of falling back to the normal authenticated view.
Records the 8-task cross-repo plan (authorizer, authorizer-js,
authorizer-react) this branch implements, plus deferred work
(passkey-as-MFA, email/SMS-OTP enrollment parity) for reference.
Covers wiring WebAuthn credentials into resolveMFAGate as a second
verified factor, reusing the already-shipped scoped webauthn login
flow and AuthorizerMFASetup enrollment hub instead of building new
mutations. Forced-enrollment-via-passkey and delete-your-only-factor
guard are recorded as deferred, mirroring the prior optional-MFA plan.
is_webauthn_enabled is hardcoded true (no operator toggle), so OR-ing
it into the branch guard would have fired mfaGateBlockEnroll's
unconditional TOTP-secret generation even on servers with TOTP
disabled. Scope passkey-as-MFA to servers that already have TOTP MFA
enabled instead; standalone passkey-only MFA stays deferred.
5 tasks across authorizer, authorizer-js, authorizer-react: wire
WebAuthn credentials into resolveMFAGate's verify path, expose the
new AuthResponse flag through the SDK, and offer a passkey-verify
button alongside the existing TOTP code form.
Backend gate on WebauthnLoginVerify (reuses login.go's existing TOTP
machinery) plus frontend hide of the primary passkey button when MFA
is enforced. This is the deferred "Direction B" scope from the
passkey-as-MFA plan, narrowed to close the specific bypass rather
than decide whether a passkey itself satisfies MFA.
6 tasks: extract a shared MFA-session helper, expose is_mfa_enforced
on the meta query, gate WebauthnLoginVerify on EnforceMFA, expose the
new field through the SDK, and hide the primary passkey button (plus
a defense-in-depth response guard) on the frontend when enforced.
Also wires config.is_mfa_enforced through AuthorizerConfig /
AuthorizerContextPropsType and both default config objects in
AuthorizerContext - the field existed in the linked SDK's Meta type
but was never declared on the local config types, so referencing it
failed to typecheck.
@lakhansamani

Copy link
Copy Markdown
Contributor Author

Superseded by an upcoming PR built against authorizer#686 (MFA behavior redesign — withheld-token first-time setup, unified multi-method setup screen, account lockout, OAuth MFA gating). This PR assumes the old "issue token immediately, offer setup after" model from the now-merged authorizer#685; the new backend withholds the token until setup/skip completes, which changes the core data flow this PR's components assume. Closing rather than updating in place.

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