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
Closed
feat(mfa): optional MFA setup, passkey as a second factor, and hide passkey login when MFA is enforced#61lakhansamani wants to merge 20 commits into
lakhansamani wants to merge 20 commits into
Conversation
…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.
…ead of blocking on optional TOTP setup
…-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.
…-mfa passkey gate)
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.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Frontend for the three-part MFA work in authorizerdev/authorizer#685 and authorizerdev/authorizer-js#45:
AuthorizerBasicAuthLoginlogs the user in immediately and surfacesshould_offer_mfa_setupinstead of blocking on TOTP setup;AuthorizerMFASetupgained a working Skip action, demonstrated end to end inexample/.AuthorizerVerifyOtpoffers "verify with your passkey" alongside the TOTP code form (reusing the already-shippedauthorizerRef.loginWithPasskey(email)), with the code form correctly staying available as a fallback when the browser doesn't support WebAuthn.AuthorizerPasskeyLogin's primary "Sign in with a passkey" button no longer renders whenconfig.is_mfa_enforcedis 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.package.json's@authorizerdev/authorizer-jsdependency currently points at a localfile:path on the dev machine (file:../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate), sinceauthorizer-js@3.6.0-rc.0hasn'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 --noEmitclean,npm run buildclean, at every step--disable-email-otpto isolate the TOTP/passkey path from the separate email-OTP branch)file:link)example/app's owntsc --noEmitclean