Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
af92fd6
fix(auth): surface config-load errors, fix condition/state bugs, and …
lakhansamani Jul 13, 2026
ff81756
chore: ignore .worktrees/ (local git-worktree scratch dir)
lakhansamani Jul 13, 2026
9d91c4c
chore(deps): bump @authorizerdev/authorizer-js to 3.4.0-rc.0 (optiona…
lakhansamani Jul 13, 2026
e6e0ffa
feat(mfa): log in immediately and surface should_offer_mfa_setup inst…
lakhansamani Jul 13, 2026
88888f4
feat(mfa): add Skip action to AuthorizerMFASetup and demonstrate host…
lakhansamani Jul 13, 2026
785a27a
fix(mfa): guard mfaSetupOffer JSON.parse against corrupt sessionStorage
lakhansamani Jul 14, 2026
b4fa0c4
docs: add optional-mfa-with-skip implementation plan
lakhansamani Jul 14, 2026
38eaa66
docs: add passkey-as-MFA design spec
lakhansamani Jul 14, 2026
0bd503a
fix(spec): keep MFA branch guard TOTP-gated, drop bad webauthn OR-arm
lakhansamani Jul 14, 2026
62b40fa
docs: add passkey-as-MFA implementation plan
lakhansamani Jul 14, 2026
d52aa4c
chore(deps): bump @authorizerdev/authorizer-js to 3.5.0-rc.0 (passkey…
lakhansamani Jul 14, 2026
ae15b31
feat(mfa): thread should_offer_webauthn_mfa_verify to the OTP screen
lakhansamani Jul 14, 2026
744fa6e
feat(mfa): offer passkey verification alongside the OTP code form
lakhansamani Jul 14, 2026
32f649a
fix(mfa): don't hide the OTP code form when passkey verify isn't actu…
lakhansamani Jul 14, 2026
9d689f4
fix(mfa): match Resend OTP guard to showCodeForm's passkeySupported c…
lakhansamani Jul 14, 2026
1f3a1d2
fix(mfa): explain the unrecoverable state when passkey-only MFA isn't…
lakhansamani Jul 14, 2026
6fbcf48
docs: add design spec for closing the EnforceMFA passkey-login bypass
lakhansamani Jul 14, 2026
02b558d
docs: add implementation plan for the enforce-mfa passkey gate
lakhansamani Jul 14, 2026
8810ebe
chore(deps): bump @authorizerdev/authorizer-js to 3.6.0-rc.0 (enforce…
lakhansamani Jul 14, 2026
9f77110
feat(mfa): hide the primary passkey button when MFA is enforced
lakhansamani Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ dist
.parcel-cache
.yalc
*storybook.log
storybook-static
storybook-static
.worktrees/
972 changes: 972 additions & 0 deletions docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md

Large diffs are not rendered by default.

600 changes: 600 additions & 0 deletions docs/superpowers/plans/2026-07-14-enforce-mfa-passkey-gate.md

Large diffs are not rendered by default.

595 changes: 595 additions & 0 deletions docs/superpowers/plans/2026-07-14-passkey-as-mfa.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Close the EnforceMFA Bypass via Passkey Primary Login — Design

**Status:** Approved, ready for implementation planning.

**Goal:** When an organization has `--enforce-mfa` on, a user cannot bypass password + MFA verification by using the pre-existing "Sign in with a passkey" (primary, passwordless) login button instead. Today they can: `WebauthnLoginVerify` issues a token unconditionally, with no `EnforceMFA` check at all.

**Relationship to prior work:** Found while manually testing `docs/superpowers/plans/2026-07-14-passkey-as-mfa.md`'s shipped feature. That plan deliberately scoped "passkey as a second factor" only, explicitly declining to touch passkey-as-primary-login's relationship to MFA (framed as "Direction B" during brainstorming and initially deferred). This spec is that deferred work, narrowed to close the specific bypass rather than resolve the broader "is a passkey itself AAL2-equivalent" question — it does not claim a passkey satisfies MFA; it makes passkey-only login *unavailable* when the org demands two factors.

**Two-part fix, both required:**
1. **Backend (the actual security boundary):** `WebauthnLoginVerify` refuses to issue a token unconditionally when `EnforceMFA=true` — it reuses the same TOTP-verify/enrollment machinery `login.go`'s password path already uses.
2. **Frontend (UX correctness, not a security control on its own):** `AuthorizerPasskeyLogin`'s primary button doesn't render at all when MFA is enforced — matching standard MFA UX (Okta/Auth0-style): authenticator methods surface only *after* a first factor has identified the user, never as a standalone bypass on the anonymous login screen. Hiding the button does **not** replace the backend fix — the GraphQL mutations remain directly callable regardless of what the UI shows.

## Backend design

**Gate condition:** `p.Config.EnforceMFA && refs.BoolValue(user.IsMultiFactorAuthEnabled)` — mirrors the exact precondition `login.go`'s TOTP branch and `resolveMFAGate` use, so a user for whom MFA isn't individually enabled is unaffected, consistent with today's password-login behavior.

**Inside the gate**, in `WebauthnLoginVerify`, after the existing revoked/email-verified checks and before the `issueAuthResponse` call:
- If TOTP login is disabled server-wide (`!p.Config.EnableTOTPLogin`): there is no enrollment-tracked second factor to pair with the passkey today (email/SMS OTP have no enrollment state — confirmed in the original plan's research). Refuse the passkey login outright with a clear, actionable error telling the user to sign in with their password instead. Do not silently fall through to email/SMS OTP.
- Else, check the user's TOTP authenticator (same `GetAuthenticatorDetailsByUserId` call `login.go` makes):
- **Already verified:** set the MFA session cookie (same mechanism `login.go` uses) and respond with `should_show_totp_screen=true`, no token — the user must enter their TOTP code via the *existing* `verify_otp` mutation and `AuthorizerVerifyOtp` screen.
- **Not yet enrolled:** set the MFA session cookie, generate a fresh TOTP secret/QR (same `generateTOTPEnrollment` helper `login.go` uses), and respond with `should_show_totp_screen=true` plus the enrollment payload, no token.
- If the gate condition is false (`EnforceMFA=false`, or this specific user doesn't have MFA individually enabled): **completely unchanged** — passkey login issues a token directly, exactly as it does today. The fast, one-tap passkey convenience stays intact for the common case; only the org-enforced-and-applicable-to-this-user case changes.

**Refactor needed to support this cleanly:** `login.go`'s `setOTPMFaSession` is currently a closure local to `Login()`, capturing `user`/`meta`/`side`. Extract it into a shared provider method (e.g. `p.setMFASession(meta RequestMetadata, side *ResponseSideEffects, userID string, expiresAt int64) error`) so both `Login` and `WebauthnLoginVerify` can call it — pure mechanical extraction, no behavior change to the password path.

**New `Meta` field:** `is_mfa_enforced: Boolean!` (mirrors `p.Config.EnforceMFA`), added the same way `is_webauthn_enabled` etc. already are. This is what the frontend uses to decide whether to render the primary passkey button — it's the only new piece of server-exposed configuration this fix needs.

## Frontend design

**`AuthorizerPasskeyLogin.tsx`:** its existing early return (`if (!isWebauthnSupported()) return null;`) gains the new condition: also return `null` when `config.is_mfa_enforced` is true. No other component needs to change — `AuthorizerRoot.tsx` already renders this component unconditionally on the login view and lets it self-manage visibility (including its own trailing "OR" separator logic), so this is fully self-contained.

**Defense in depth in the same component's `onClick` handler:** today it treats any non-error `loginWithPasskey()` result as a successful login and calls `setAuthData`/`onLogin` unconditionally. Once the backend can return a tokenless `should_show_totp_screen` response (reachable only if this component's button somehow rendered despite `is_mfa_enforced` — e.g. a brief pre-config-load window — since the backend is authoritative regardless of the UI), the handler must not misinterpret that as a successful login. Guard on `res.access_token` before calling `setAuthData`/`onLogin`, matching the pattern `AuthorizerBasicAuthLogin` already uses. On a tokenless response, show a message directing the user to sign in with their password instead, rather than building out a full TOTP-verify flow inside this compact button component — the real, fully-built verify path is password login, which this state should never normally be reachable from.

## Deferred (explicitly out of scope)

- **The narrower case of a user who has personally enrolled TOTP but `EnforceMFA` is org-wide false.** Per the original passkey-as-MFA plan's constraint ("a user's own opted-in MFA is never skippable"), `resolveMFAGate` already blocks password login for such a user regardless of `EnforceMFA`. This fix does **not** extend that same guarantee to passkey-primary-login — a user who set up TOTP for themselves, in a non-enforced org, could still use "Sign in with a passkey" to skip their own TOTP. Closing this would mean gating on `resolveMFAGate`'s full decision (not just `EnforceMFA`), which also pulls in the offer/skip states that don't make sense for a primary-login button. Left for a future pass if it turns out to matter in practice.
- **Whether a passkey itself should count as AAL2/MFA-equivalent** (the NIST research question from earlier in this session) remains untouched — this fix does not make that determination either way; it only prevents passkey login from silently satisfying an org's explicit two-factor requirement.
89 changes: 89 additions & 0 deletions docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Passkey as a Second MFA Factor — Design

**Status:** Approved, ready for implementation planning.

**Goal:** Let a user who already has a registered passkey use it to satisfy an MFA challenge — instead of, or alongside, TOTP — when logging in with email + password. Passkey continues to also work as today's separate passwordless *primary* login method; this plan does not touch that.

**Relationship to prior work:** This is the "Passkey-as-MFA" item explicitly deferred at the end of `docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md`, which shipped optional TOTP-based MFA with a skip action. That plan's `resolveMFAGate` decision function, `has_skipped_mfa_setup_at` field, and MFA-session-cookie mechanism are reused as-is here.

**Architecture, in one sentence:** almost everything needed already exists — `webauthn_login_options`(scoped by email) + `webauthn_login_verify` already run as an MFA-session-gated "prove your passkey" ceremony, and `AuthorizerMFASetup`'s Passkey tile already enrolls credentials post-login — so this plan is mostly *wiring the MFA gate to recognize a WebAuthn credential as a verified factor* and *exposing one new response flag* to tell the frontend which verify option(s) to render.

## Credential scoping decision

No "purpose" field on `WebauthnCredential`. Any registered passkey — whether the user registered it for passwordless primary login or explicitly for MFA — satisfies the passkey-MFA factor. This is deliberately simple because the passkey-primary-login feature itself is still pre-release (no installed base to break by changing its semantics). If that assumption stops holding after release, a `purpose` field can be added later without disturbing this plan's gate logic (`authenticatorVerified` would just become `hasWebauthnCredential(purpose=mfa)`).

## Backend (`authorizer` repo)

### Task: Broaden the MFA branch guard and wire WebAuthn into `resolveMFAGate`

**Files:**
- Modify: `internal/service/login.go` (the MFA branch — currently guarded by `isMFAEnabled && isTOTPLoginEnabled`, and the TOTP-specific `authenticatorVerified` computation)
- Modify: `internal/graph/schema.graphqls` (`AuthResponse` — new field)
- Modify: `internal/graph/model/models_gen.go` (regenerated)

**Changes:**

1. Guard: **unchanged** — stays `if isMFAEnabled && isTOTPLoginEnabled {`. `is_webauthn_enabled` in the GraphQL schema is hardcoded `true` (no operator toggle — see `schema.graphqls:44`'s comment), so it can't be used as a second `||` arm the way `isTOTPLoginEnabled` is: doing so would make this branch (and `mfaGateBlockEnroll`'s unconditional TOTP-secret generation, see below) fire even on servers where TOTP is deliberately disabled. Passkey-as-MFA in this plan is therefore scoped to *servers that already have TOTP MFA enabled* — it adds passkey as an alternative verify method within that existing umbrella, not a standalone way to turn MFA on. See "Deferred" for the TOTP-independent case.
2. `authenticatorVerified` computation: currently `authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil` (TOTP only). Change to:
```go
totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil
webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID)
hasWebauthnCredential := len(webauthnCreds) > 0
authenticatorVerified := totpVerified || hasWebauthnCredential
```
(Ignore the WebAuthn list-credentials error rather than failing login on it — treat "couldn't check" as "no credential found," matching how a fresh/never-enrolled user is handled today. Same fail-open-to-"not verified" posture as a missing TOTP authenticator row.)
3. `resolveMFAGate` itself is unchanged — it already takes a single `authenticatorVerified` bool and doesn't care which factor produced it.
4. On the `mfaGateBlockVerify` case (today: unconditionally `ShouldShowTotpScreen: true`), branch by which factor(s) the user actually has, so a passkey-only user isn't forced through a TOTP screen they never set up, and a dual-enrolled user is offered a choice:
```go
case mfaGateBlockVerify:
expiresAt := time.Now().Add(3 * time.Minute).Unix()
if err := setOTPMFaSession(expiresAt); err != nil { ... }
res := &model.AuthResponse{Message: `Proceed to mfa verification`}
if totpVerified {
res.ShouldShowTotpScreen = refs.NewBoolRef(true)
}
if hasWebauthnCredential {
res.ShouldOfferWebauthnMfaVerify = refs.NewBoolRef(true)
}
return res, side, nil
```
5. `mfaGateBlockEnroll` (org-*enforced*, first-time, no factor yet at all) is **unchanged** — stays TOTP-only. See "Deferred" below.
6. `mfaGateOfferSetup` / `mfaGateSkippedSetup` are **unchanged** — no code here even knows about WebAuthn, because enrollment for these two states happens later, post-token, through the already-shipped `AuthorizerMFASetup` hub.
7. New `AuthResponse` field, added the same way `should_show_totp_screen` was:
```graphql
should_show_totp_screen: Boolean
# should_offer_webauthn_mfa_verify is true when the user has a registered
# passkey and MFA verification (not enrollment) is required at login. The
# frontend should offer a "verify with your passkey" action in addition to
# (or instead of) should_show_totp_screen's code-entry form.
should_offer_webauthn_mfa_verify: Boolean
```

**Testing:** extend the existing TOTP branch's integration test coverage with cases for: a user who enrolled only a passkey (no verified TOTP) reaching `mfaGateBlockVerify` and getting only `should_offer_webauthn_mfa_verify`; a dual-enrolled user getting both flags; confirm a server with `isTOTPLoginEnabled=false` still skips this whole branch unchanged (guard untouched).

## SDK (`authorizer-js` repo)

### Task: Expose the new field

**Files:** `src/types.ts` (`AuthResponse` interface — add `should_offer_webauthn_mfa_verify: boolean | null;` next to `should_show_totp_screen`).

No new methods. `loginWithPasskey(email)` (already shipped, drives `webauthn_login_options` → browser ceremony → `webauthn_login_verify`) is reused verbatim for the verify step — passing the user's email is what makes the backend treat it as the scoped/MFA flow instead of a discoverable primary login.

## Frontend (`authorizer-react` repo)

### Task: Offer "verify with passkey" during MFA challenge

**Files:**
- `src/components/AuthorizerBasicAuthLogin.tsx` — the `res && !res.access_token` branch that currently checks `should_show_totp_screen`/`should_show_email_otp_screen`/`should_show_mobile_otp_screen` gains a check for `should_offer_webauthn_mfa_verify`, routing to `AuthorizerVerifyOtp` with a new prop (e.g. `offerWebauthnVerify`) instead of (or alongside) the existing OTP-screen routing.
- `src/components/AuthorizerVerifyOtp.tsx` — when `offerWebauthnVerify` is true, render a "Verify with your passkey" button above/alongside the code-entry form (hide the form entirely when the user has *only* a passkey and no TOTP, i.e. `should_show_totp_screen` was false). Clicking it calls `authorizerRef.loginWithPasskey(email)` directly and, on success, follows the exact same `setAuthData` + `onLogin(res)` path the form's `onSubmit` already uses.
- No changes needed to `AuthorizerMFASetup.tsx` (enrollment already works) or `AuthorizerPasskeyRegister.tsx`.

**Error handling:** a cancelled/failed passkey ceremony (`NotAllowedError`/`AbortError`, same as `AuthorizerPasskeyLogin`'s existing handling) should silently return the user to the code-entry form when one is available (dual-enrolled case), or show a dismissible message when it's the only option (passkey-only case) — never a raw browser exception.

**Testing:** one assert-based smoke check that `should_offer_webauthn_mfa_verify: true` on the login response results in the passkey verify affordance being offered (component-level), matching the existing pattern for `should_show_totp_screen`.

## Deferred (explicitly out of scope)

- **Passkey enrollment during org-*enforced* first-time MFA** (`mfaGateBlockEnroll`, no factor enrolled yet, no token issued yet). Today's `webauthn_registration_options`/`_verify` mutations require a real access token (`callerTokenData`), which doesn't exist at this point — only an MFA-session cookie does. Offering passkey here would need new MFA-session-gated (not bearer-token-gated) registration mutations, a materially bigger and higher-risk change (unauthenticated-but-scoped credential registration). TOTP remains the only forced-enrollment path until this is tackled as its own plan.
- **Blocking/warning when deleting your only remaining verified MFA factor** (e.g. `WebauthnDeleteCredential` leaving a user with zero verified factors while MFA is enabled for them). Not handled for TOTP today either; left as future work.
- **`AvailableMfaMethods.passkey` sourced from a real backend capability field** instead of a host-app-supplied prop. Pre-existing gap in `AuthorizerMFASetup`, orthogonal to this plan.
3 changes: 0 additions & 3 deletions example/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
authorizerURL: 'http://localhost:8080',
redirectURL: window.location.origin,
}}
onStateChangeCallback={async ({ user, token }: any) => {
console.log(user, token);
}}
>
<App />
</AuthorizerProvider>
Expand Down
30 changes: 28 additions & 2 deletions example/src/pages/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import * as React from 'react';
import { useAuthorizer } from 'authorizer-react';
import { AuthorizerMFASetup, useAuthorizer } from 'authorizer-react';

const Dashboard: React.FC = () => {
const { user, loading, logout } = useAuthorizer();
const { user, loading, logout, authorizerRef } = useAuthorizer();
const [mfaOffer, setMfaOffer] = React.useState<any>(() => {
const raw = sessionStorage.getItem('mfaSetupOffer');
try {
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
});

const dismissMfaOffer = () => {
sessionStorage.removeItem('mfaSetupOffer');
setMfaOffer(null);
};

if (mfaOffer) {
return (
<AuthorizerMFASetup
availableMfaMethods={{ totp: true }}
totpEnrollment={mfaOffer}
onSkip={async () => {
await authorizerRef.skipMfaSetup();
dismissMfaOffer();
}}
/>
);
}

return (
<div>
Expand Down
19 changes: 11 additions & 8 deletions example/src/pages/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ const Login: React.FC = () => {
<h1 style={{ textAlign: 'center' }}>Welcome to Authorizer</h1>
<br />
<Authorizer
onLogin={(loginData: any) => {
console.log({ loginData });
}}
onMagicLinkLogin={(mData: any) => {
console.log({ mData });
}}
onSignup={(sData: any) => {
console.log({ sData });
onLogin={(data: any) => {
if (data?.should_offer_mfa_setup) {
sessionStorage.setItem(
'mfaSetupOffer',
JSON.stringify({
authenticator_scanner_image: data.authenticator_scanner_image,
authenticator_secret: data.authenticator_secret,
authenticator_recovery_codes: data.authenticator_recovery_codes,
})
);
}
}}
/>
</>
Expand Down
Loading
Loading