diff --git a/.gitignore b/.gitignore index 8041469..0ffafa8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist .parcel-cache .yalc *storybook.log -storybook-static \ No newline at end of file +storybook-static +.worktrees/ diff --git a/docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md b/docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md new file mode 100644 index 0000000..bb2f3af --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md @@ -0,0 +1,972 @@ +# Optional MFA Setup With Skip Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When MFA is available but not organization-enforced, a user who hasn't set up MFA gets logged in immediately and is offered (not forced into) MFA setup, with a Skip action that's remembered so they aren't nagged again — while `--enforce-mfa` keeps working exactly as it does today (no token until MFA is complete, never skippable). + +**Architecture:** Three repos, in dependency order: `authorizer` (Go backend — new schema field, new decision logic, new mutations) → `authorizer-js` (TS API client — exposes the new response fields and mutations) → `authorizer-react` (React UI — wires the login form and the existing-but-unused `AuthorizerMFASetup` hub into the actual login flow with a working Skip button). + +**Tech Stack:** Go 1.x / gqlgen / GORM (+ driver-specific code for Cassandra, DynamoDB, Couchbase) for the backend; TypeScript for `authorizer-js`; React + TypeScript for `authorizer-react`. + +## Global Constraints + +- **`EnforceMFA` is absolute.** When `p.Config.EnforceMFA` is true, behavior is unchanged from today: no access token is issued until MFA is completed, and skip is never offered. This must be enforced server-side in every new code path — never trust a client-supplied "skip me" request. +- **A user's own opted-in MFA is never skippable.** If a user has a *verified* authenticator (or has otherwise completed MFA setup), every login requires that verification, regardless of `EnforceMFA`. Skip only applies to the *first-time setup* decision, not to already-configured MFA. +- **M2M / service-account / client-credentials / gRPC workload-identity flows are out of scope and must not be touched.** Confirmed in research: `service.Login` (this plan's surface) is reached only via the interactive GraphQL `login` mutation and the gRPC `authorizer.Login` handler, both driven by `model.LoginRequest` (email+password). Machine-to-machine token issuance (`internal/http_handlers/token.go:handleClientCredentialsGrant`) operates on a completely separate `schemas.Client` type and never reads `IsMultiFactorAuthEnabled`. Do not add any MFA check reachable from that path. +- **Passkey-as-MFA and Email/SMS-OTP "not yet enrolled" parity are explicitly OUT of scope for this plan** — see "Deferred Work" at the end. Do not attempt them as part of these tasks. +- Every schema change must update all backends the codebase currently supports: SQL (GORM auto-migrates), MongoDB, ArangoDB (both pick up new struct fields automatically — no separate migration file), Cassandra/ScyllaDB (needs an explicit `ALTER TABLE` plus updates to every hand-written `SELECT`/`Scan`), DynamoDB (needs a nil-attribute-removal branch), Couchbase (needs updates to every hand-written `SELECT` column list). Per the codebase's own convention comment in `internal/storage/schemas/user.go:11`: "any change here should be reflected in providers/casandra/provider.go as it does not have model support in collection creation." +- Local verification happens against the SQLite-backed dev server started by `authorizer/../examples/with-auth-recipes/run-server.sh` (already running with `--enforce-mfa=false`, `--enable-mfa`, `--enable-totp-login` — this is the exact server and the exact stuck account, `lakhan@yopmail.com`, that surfaced this bug). + +--- + +## Backend (`authorizer` repo) + +### Task 1: Add `HasSkippedMFASetupAt` field across all storage backends + +**Files:** +- Modify: `internal/storage/schemas/user.go:36` (insert new field), `:82` (AsAPIUser mapping) +- Modify: `internal/graph/schema.graphqls:82` (User type), `internal/graph/model/models_gen.go` (regenerated) +- Modify: `internal/storage/db/cassandradb/provider.go` (near line 178-183, ALTER TABLE) +- Modify: `internal/storage/db/cassandradb/user.go` (lines ~156, 227-228, 238-239, 316-317, 328-329 — column lists and `Scan` args) +- Modify: `internal/storage/db/dynamodb/user.go` (near line 112-113, nil-attribute removal) +- Modify: `internal/storage/db/couchbase/user.go` (lines ~125, 158, 182, 204, 260 — SELECT column lists) +- Test: `internal/storage/schemas/user_test.go` (create if it doesn't exist) + +**Interfaces:** +- Produces: `schemas.User.HasSkippedMFASetupAt *int64` — a nullable Unix-seconds timestamp. `nil` means "never skipped, and not yet known whether they'll set up MFA." Non-nil means "user explicitly chose Skip at this time." +- Produces: `model.User.HasSkippedMfaSetupAt *int64` (GraphQL-exposed, exact name matches gqlgen's Go-casing convention — check the generated name for `is_multi_factor_auth_enabled` → `IsMultiFactorAuthEnabled` and mirror it: `has_skipped_mfa_setup_at` → `HasSkippedMfaSetupAt`). + +- [ ] **Step 1: Add the field to the canonical schema struct** + +Edit `internal/storage/schemas/user.go`, insert after line 36 (`IsMultiFactorAuthEnabled`): + +```go + IsMultiFactorAuthEnabled *bool `json:"is_multi_factor_auth_enabled" bson:"is_multi_factor_auth_enabled" cql:"is_multi_factor_auth_enabled" dynamo:"is_multi_factor_auth_enabled"` + // HasSkippedMFASetupAt is set the moment a user explicitly skips the + // optional MFA setup prompt shown at login (never set when EnforceMFA is + // on — skip is not offered in that mode). Nil means "never skipped." + HasSkippedMFASetupAt *int64 `json:"has_skipped_mfa_setup_at" bson:"has_skipped_mfa_setup_at" cql:"has_skipped_mfa_setup_at" dynamo:"has_skipped_mfa_setup_at"` + UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at" dynamo:"updated_at"` +``` + +- [ ] **Step 2: Map it into the API-facing user in `AsAPIUser`** + +Edit `internal/storage/schemas/user.go` inside `AsAPIUser` (around line 82), add after the `IsMultiFactorAuthEnabled:` line: + +```go + IsMultiFactorAuthEnabled: user.IsMultiFactorAuthEnabled, + HasSkippedMfaSetupAt: user.HasSkippedMFASetupAt, +``` + +- [ ] **Step 3: Add the field to the GraphQL schema** + +Edit `internal/graph/schema.graphqls`, in the `type User {` block (line 61-84), add after `is_multi_factor_auth_enabled: Boolean` (line 82): + +```graphql + is_multi_factor_auth_enabled: Boolean + # has_skipped_mfa_setup_at is set once the user explicitly skips the + # optional MFA setup prompt shown at login. Null means never skipped. + has_skipped_mfa_setup_at: Int64 + app_data: Map +``` + +- [ ] **Step 4: Regenerate GraphQL code** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +Expected: `internal/graph/model/models_gen.go`'s `User` struct now has a `HasSkippedMfaSetupAt *int64` field with a `has_skipped_mfa_setup_at,omitempty` json tag. Confirm with: +`grep -n "HasSkippedMfaSetupAt" internal/graph/model/models_gen.go` + +- [ ] **Step 5: Cassandra — add the column and wire every hand-written query** + +Edit `internal/storage/db/cassandradb/provider.go` near line 178-183, add a sibling `ALTER TABLE` immediately after the existing `is_multi_factor_auth_enabled` one: + +```go + userTableAlterQuery = fmt.Sprintf(`ALTER TABLE %s.%s ADD has_skipped_mfa_setup_at bigint`, KeySpace, schemas.Collections.User) + err = session.Query(userTableAlterQuery).Exec() + if err != nil { + deps.Log.Debug().Err(err).Msg("Failed to alter table as has_skipped_mfa_setup_at column exists") + // continue + } +``` + +Then in `internal/storage/db/cassandradb/user.go`, add `has_skipped_mfa_setup_at` to the column list and a corresponding `&user.HasSkippedMFASetupAt` to the `.Scan(...)` args at each of lines ~156, 227-228, 238-239, 316-317, 328-329 (wherever `is_multi_factor_auth_enabled` currently appears in that file's SELECTs — add the new column immediately after it, and the new `Scan` arg immediately after `&user.IsMultiFactorAuthEnabled`). + +- [ ] **Step 6: DynamoDB — nil-attribute removal** + +Edit `internal/storage/db/dynamodb/user.go` near line 112-113, add a sibling branch: + +```go + if u.IsMultiFactorAuthEnabled == nil { + remove = append(remove, "is_multi_factor_auth_enabled") + } + if u.HasSkippedMFASetupAt == nil { + remove = append(remove, "has_skipped_mfa_setup_at") + } +``` + +- [ ] **Step 7: Couchbase — add the column to every SELECT** + +Edit `internal/storage/db/couchbase/user.go` at lines ~125, 158, 182, 204, 260: add `has_skipped_mfa_setup_at` to each SELECT's column list, immediately after `is_multi_factor_auth_enabled`. + +- [ ] **Step 8: Verify SQL/Mongo/Arango need no extra edits** + +SQL: GORM's `AutoMigrate(&schemas.User{}, ...)` (`internal/storage/db/sql/provider.go:98`) picks up the new struct field automatically on next boot — no action needed. +MongoDB/ArangoDB: both marshal `*schemas.User` directly — no action needed. + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./...` +Expected: builds clean. + +- [ ] **Step 9: Commit** + +```bash +git add internal/storage/schemas/user.go internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/storage/db/cassandradb/provider.go internal/storage/db/cassandradb/user.go internal/storage/db/dynamodb/user.go internal/storage/db/couchbase/user.go +git commit -m "feat(mfa): add has_skipped_mfa_setup_at to the user record" +``` + +--- + +### Task 2: Extract the MFA-gate decision as a pure, unit-tested function + +**Files:** +- Create: `internal/service/mfa_gate.go` +- Test: `internal/service/mfa_gate_test.go` + +**Interfaces:** +- Consumes: nothing from other tasks — pure function, no DB/network access. +- Produces: `type mfaGateDecision int` with values `mfaGateNone`, `mfaGateBlockVerify`, `mfaGateBlockEnroll`, `mfaGateOfferSetup`, `mfaGateSkippedSetup` — and `func resolveMFAGate(userMFAEnabled bool, enforceMFA bool, authenticatorVerified bool, hasSkippedSetup bool) mfaGateDecision`, consumed by Task 3. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/service/mfa_gate_test.go +package service + +import "testing" + +func TestResolveMFAGate(t *testing.T) { + cases := []struct { + name string + userMFAEnabled bool + enforceMFA bool + authenticatorVerified bool + hasSkippedSetup bool + want mfaGateDecision + }{ + {"mfa off for user", false, false, false, false, mfaGateNone}, + {"mfa off for user, enforced anyway (inconsistent state defends safe)", false, true, false, false, mfaGateNone}, + {"enforced, not yet enrolled", true, true, false, false, mfaGateBlockEnroll}, + {"enforced, already verified", true, true, true, false, mfaGateBlockVerify}, + {"enforced, skip flag present but ignored", true, true, false, true, mfaGateBlockEnroll}, + {"optional, already verified -> still verify every time", true, false, true, false, mfaGateBlockVerify}, + {"optional, already verified, skip flag stale -> still verify", true, false, true, true, mfaGateBlockVerify}, + {"optional, not enrolled, never skipped -> offer", true, false, false, false, mfaGateOfferSetup}, + {"optional, not enrolled, already skipped -> quiet login", true, false, false, true, mfaGateSkippedSetup}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := resolveMFAGate(c.userMFAEnabled, c.enforceMFA, c.authenticatorVerified, c.hasSkippedSetup) + if got != c.want { + t.Errorf("resolveMFAGate(%v,%v,%v,%v) = %v, want %v", c.userMFAEnabled, c.enforceMFA, c.authenticatorVerified, c.hasSkippedSetup, got, c.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/service/ -run TestResolveMFAGate -v` +Expected: FAIL — `resolveMFAGate`/`mfaGateDecision`/`mfaGateNone` undefined. + +- [ ] **Step 3: Write the implementation** + +```go +// internal/service/mfa_gate.go +package service + +// mfaGateDecision is what login.go should do once it knows a user has MFA +// available. See resolveMFAGate for the truth table. +type mfaGateDecision int + +const ( + // mfaGateNone: user has no MFA to worry about. Issue the token normally. + mfaGateNone mfaGateDecision = iota + // mfaGateBlockVerify: user has a verified/completed MFA method already. + // Withhold the token until they verify it. Never skippable — this is the + // user's own opted-in second factor. + mfaGateBlockVerify + // mfaGateBlockEnroll: MFA is org-enforced and this user hasn't finished + // enrollment yet. Withhold the token until enrollment is completed. + // Never skippable. + mfaGateBlockEnroll + // mfaGateOfferSetup: MFA is available but not enforced, the user hasn't + // enrolled, and they've never skipped before. Issue the token now AND + // tell the frontend to offer (not force) MFA setup. + mfaGateOfferSetup + // mfaGateSkippedSetup: same as mfaGateOfferSetup but the user has already + // chosen Skip in the past. Issue the token, don't nag. + mfaGateSkippedSetup +) + +// resolveMFAGate decides what login.go does for a user whose +// IsMultiFactorAuthEnabled flag might be set. Only called when the caller +// has already confirmed MFA is available on this server at all +// (Config.EnableMFA) — see login.go call sites. +// +// - userMFAEnabled: schemas.User.IsMultiFactorAuthEnabled +// - enforceMFA: Config.EnforceMFA (org-wide mandate — absolute, never +// bypassed by hasSkippedSetup) +// - authenticatorVerified: true when the user has a completed/verified MFA +// method already (e.g. a verified TOTP authenticator) — their own opted-in +// second factor, always required once true, regardless of enforceMFA or +// hasSkippedSetup +// - hasSkippedSetup: schemas.User.HasSkippedMFASetupAt != nil +func resolveMFAGate(userMFAEnabled, enforceMFA, authenticatorVerified, hasSkippedSetup bool) mfaGateDecision { + if !userMFAEnabled { + return mfaGateNone + } + if authenticatorVerified { + // The user's own completed second factor. Always required, never + // skippable, regardless of current enforcement policy. + return mfaGateBlockVerify + } + if enforceMFA { + return mfaGateBlockEnroll + } + if hasSkippedSetup { + return mfaGateSkippedSetup + } + return mfaGateOfferSetup +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/service/ -run TestResolveMFAGate -v` +Expected: PASS, all 9 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/service/mfa_gate.go internal/service/mfa_gate_test.go +git commit -m "feat(mfa): add pure MFA-gate decision function with unit tests" +``` + +--- + +### Task 3: Rewire `login.go`'s TOTP branch to use the gate, and add `should_offer_mfa_setup` to `AuthResponse` + +**Files:** +- Modify: `internal/graph/schema.graphqls:117` (AuthResponse), `internal/graph/model/models_gen.go` (regenerated) +- Modify: `internal/service/login.go:349-386` (the TOTP MFA branch) + +**Interfaces:** +- Consumes: `resolveMFAGate` from Task 2. +- Produces: `model.AuthResponse.ShouldOfferMfaSetup *bool` — when true alongside a populated `AccessToken`, the frontend should show (not force) MFA setup. `AuthenticatorScannerImage`/`AuthenticatorSecret`/`AuthenticatorRecoveryCodes` are populated on the SAME response in this case, so the frontend can render the TOTP QR immediately if the user chooses to set it up. + +- [ ] **Step 1: Add the field to the GraphQL schema** + +Edit `internal/graph/schema.graphqls`, inside `type AuthResponse {` (line 113-129), add after `should_show_totp_screen: Boolean` (line 117): + +```graphql + should_show_totp_screen: Boolean + # should_offer_mfa_setup is true when MFA is available but not enforced, + # the user hasn't enrolled, and they haven't skipped setup before. Unlike + # should_show_totp_screen, access_token is ALREADY populated alongside + # this flag — the frontend should log the user in and separately offer + # (not force) MFA setup, e.g. via a dismissible hub with a Skip action. + should_offer_mfa_setup: Boolean +``` + +Regenerate: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +- [ ] **Step 2: Rewrite the TOTP branch in `login.go`** + +Replace the block at `internal/service/login.go:349-386` (the `if refs.BoolValue(user.IsMultiFactorAuthEnabled) && isMFAEnabled && isTOTPLoginEnabled {` block): + +```go + // If mfa enabled and also totp enabled + if isMFAEnabled && isTOTPLoginEnabled { + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + authenticatorVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + gate := resolveMFAGate( + refs.BoolValue(user.IsMultiFactorAuthEnabled), + p.Config.EnforceMFA, + authenticatorVerified, + user.HasSkippedMFASetupAt != nil, + ) + switch gate { + case mfaGateBlockVerify: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := setOTPMFaSession(expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + return &model.AuthResponse{ + Message: `Proceed to totp screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + }, side, nil + case mfaGateBlockEnroll: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := setOTPMFaSession(expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + authConfig, err := p.AuthenticatorProvider.Generate(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp") + return nil, nil, err + } + recoveryCodes := []*string{} + for _, code := range authConfig.RecoveryCodes { + recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) + } + return &model.AuthResponse{ + Message: `Proceed to totp verification screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + AuthenticatorScannerImage: refs.NewStringRef(authConfig.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(authConfig.Secret), + AuthenticatorRecoveryCodes: recoveryCodes, + }, side, nil + case mfaGateOfferSetup: + authConfig, err := p.AuthenticatorProvider.Generate(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp for optional setup") + return nil, nil, err + } + recoveryCodes := []*string{} + for _, code := range authConfig.RecoveryCodes { + recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) + } + // Falls through to normal token issuance below, with the offer + // flag and enrollment payload attached after CreateAuthToken. + side.PendingTOTPOffer = &pendingTOTPOffer{ + ScannerImage: authConfig.ScannerImage, + Secret: authConfig.Secret, + RecoveryCodes: recoveryCodes, + } + case mfaGateSkippedSetup: + side.OfferMFASetupQuiet = true + case mfaGateNone: + // fall through, nothing to do + } + } +``` + +This introduces two new fields on `ResponseSideEffects` (`internal/service/sideeffects.go` — find this file and add): + +```go + // PendingTOTPOffer carries a freshly generated (unverified) TOTP + // enrollment payload to attach to the successful AuthResponse when the + // MFA gate decided to OFFER (not force) setup. Nil otherwise. + PendingTOTPOffer *pendingTOTPOffer + // OfferMFASetupQuiet is true when the MFA gate decided the user already + // skipped setup before — no enrollment payload, no offer flag, just a + // normal login. + OfferMFASetupQuiet bool +} + +type pendingTOTPOffer struct { + ScannerImage string + Secret string + RecoveryCodes []*string +} +``` + +- [ ] **Step 3: Attach the offer to the final success response** + +In `internal/service/login.go`, find the final success response construction (around line 453, `res := &model.AuthResponse{...}`). Immediately after it, before the `for _, c := range cookie.BuildSessionCookies` loop, add: + +```go + res := &model.AuthResponse{ + Message: `Logged in successfully`, + AccessToken: &authToken.AccessToken.Token, + IDToken: &authToken.IDToken.Token, + ExpiresIn: &expiresIn, + User: user.AsAPIUser(), + } + if side.PendingTOTPOffer != nil { + res.ShouldOfferMfaSetup = refs.NewBoolRef(true) + res.AuthenticatorScannerImage = refs.NewStringRef(side.PendingTOTPOffer.ScannerImage) + res.AuthenticatorSecret = refs.NewStringRef(side.PendingTOTPOffer.Secret) + res.AuthenticatorRecoveryCodes = side.PendingTOTPOffer.RecoveryCodes + } +``` + +- [ ] **Step 4: Build and run the unit tests** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... && go test ./internal/service/ -run TestResolveMFAGate -v` +Expected: builds clean, tests still pass. + +- [ ] **Step 5: Manual local verification** + +With the recipe server running (`--enforce-mfa=false`, per `examples/with-auth-recipes/run-server.sh`), log in as `lakhan@yopmail.com` / `Test@123#` via the raw GraphQL call used earlier in this session: + +```bash +curl -s http://localhost:8080/graphql -X POST -H 'Content-Type: application/json' -d '{"query":"mutation login($data: LoginRequest!) { login(params: $data) { message access_token should_show_totp_screen should_offer_mfa_setup authenticator_secret } }","variables":{"data":{"email":"lakhan@yopmail.com","password":"Test@123#"}}}' +``` + +Expected: `access_token` is now populated (not null), `should_show_totp_screen` is null/false, `should_offer_mfa_setup` is true, `authenticator_secret` is a fresh TOTP secret. This confirms the exact stuck-login bug from this session is fixed. + +- [ ] **Step 6: Commit** + +```bash +git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/service/login.go internal/service/sideeffects.go +git commit -m "feat(mfa): issue a session immediately and offer (not force) TOTP setup when MFA isn't enforced" +``` + +--- + +### Task 4: Authenticated `skip_mfa_setup` mutation + +**Files:** +- Modify: `internal/graph/schema.graphqls` (Mutation block, line ~1319) +- Create: `internal/service/skip_mfa_setup.go` +- Modify: `internal/service/provider.go` (interface) +- Create: `internal/graphql/skip_mfa_setup.go` +- Modify: `internal/graphql/provider.go` (interface) +- Modify: `internal/graph/schema.resolvers.go` (delegating stub, auto-inserted by codegen — hand-fill the body) + +**Interfaces:** +- Consumes: `p.callerTokenData` pattern from `internal/service/deactivate_account.go:21` for authenticated-user extraction. +- Produces: GraphQL mutation `skip_mfa_setup: Response!`, consumed by `authorizer-js` Task 5 and `authorizer-react` Task 8. + +- [ ] **Step 1: Add the mutation to the schema** + +Edit `internal/graph/schema.graphqls`, in the `Mutation` block, add after `resend_otp(params: ResendOTPRequest!): Response!` (line ~1319): + +```graphql + resend_otp(params: ResendOTPRequest!): Response! + # skip_mfa_setup records that the authenticated caller explicitly declined + # the optional MFA setup prompt. Fails with FAILED_PRECONDITION if MFA is + # organization-enforced (enforce-mfa) — enforcement is never skippable. + skip_mfa_setup: Response! +``` + +- [ ] **Step 2: Write the service-layer implementation** + +```go +// internal/service/skip_mfa_setup.go +package service + +import ( + "context" + "time" + + "github.com/authorizerdev/authorizer/internal/graph/model" +) + +// SkipMFASetup records that the authenticated caller explicitly declined the +// optional MFA setup prompt shown at login. Never allowed when MFA is +// org-enforced — that path never offers a skip in the first place +// (resolveMFAGate never returns mfaGateOfferSetup when EnforceMFA is true), +// but this is re-checked here server-side so a client can never forge the +// request to bypass enforcement. +// +// Permissions: authenticated user (bearer token or session cookie). +func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "SkipMFASetup").Logger() + + if p.Config.EnforceMFA { + log.Debug().Msg("Cannot skip MFA setup as it is enforced") + return nil, nil, FailedPrecondition("cannot skip multi factor authentication setup as it is enforced by organization") + } + + tokenData, err := p.callerTokenData(ctx, meta) + if err != nil || tokenData == nil || tokenData.UserID == "" { + log.Debug().Err(err).Msg("Failed to get user id from session or access token") + return nil, nil, Unauthenticated("unauthorized") + } + user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user by id") + return nil, nil, err + } + now := time.Now().Unix() + user.HasSkippedMFASetupAt = &now + if _, err := p.StorageProvider.UpdateUser(ctx, user); err != nil { + log.Debug().Err(err).Msg("Failed to update user") + return nil, nil, err + } + return &model.Response{Message: "MFA setup skipped"}, nil, nil +} +``` + +- [ ] **Step 3: Add to the service provider interface** + +Edit `internal/service/provider.go`, add near `DeactivateAccount` (line ~101): + +```go + DeactivateAccount(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) + // SkipMFASetup records that the authenticated caller declined optional + // MFA setup. Fails if MFA is org-enforced. + SkipMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) +``` + +- [ ] **Step 4: Write the GraphQL-transport adapter** + +```go +// internal/graphql/skip_mfa_setup.go +package graphql + +import ( + "context" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/utils" +) + +func (g *graphqlProvider) SkipMFASetup(ctx context.Context) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + g.Log.Debug().Err(err).Msg("failed to get gin context") + metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql") + return nil, err + } + res, side, err := g.ServiceProvider.SkipMFASetup(ctx, service.MetaFromGin(gc)) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} +``` + +- [ ] **Step 5: Add to the GraphQL provider interface** + +Edit `internal/graphql/provider.go`, add near `DeactivateAccount` (line ~98): + +```go + DeactivateAccount(ctx context.Context) (*model.Response, error) + // SkipMFASetup is the method to skip optional MFA setup. + SkipMFASetup(ctx context.Context) (*model.Response, error) +``` + +- [ ] **Step 6: Regenerate and fill the resolver stub** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +This inserts a stub in `internal/graph/schema.resolvers.go`. Fill it to match the existing `DeactivateAccount` resolver pattern: + +```go +func (r *mutationResolver) SkipMfaSetup(ctx context.Context) (*model.Response, error) { + return r.GraphQLProvider.SkipMFASetup(ctx) +} +``` + +- [ ] **Step 7: Build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./...` +Expected: builds clean. + +- [ ] **Step 8: Manual local verification** + +Log in as `lakhan@yopmail.com` (Task 3's curl), capture the `access_token`, then: + +```bash +curl -s http://localhost:8080/graphql -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer " \ + -d '{"query":"mutation { skip_mfa_setup { message } }"}' +``` + +Expected: `{"data":{"skip_mfa_setup":{"message":"MFA setup skipped"}}}`. Re-run the Task 3 login curl again: `should_offer_mfa_setup` should now be false/null (quiet login). + +- [ ] **Step 9: Commit** + +```bash +git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/graph/schema.resolvers.go internal/service/skip_mfa_setup.go internal/service/provider.go internal/graphql/skip_mfa_setup.go internal/graphql/provider.go +git commit -m "feat(mfa): add skip_mfa_setup mutation, blocked when MFA is enforced" +``` + +--- + +## SDK (`authorizer-js` repo) + +### Task 5: Expose the new response fields and mutation in the TypeScript client + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts` (`AuthResponse`, `User`) +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/src/index.ts` (new `skipMfaSetup` method) +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/package.json` (version bump) + +**Interfaces:** +- Consumes: backend fields/mutation from Tasks 1-4. +- Produces: `Types.AuthResponse.should_offer_mfa_setup: boolean | null`, `Types.User.has_skipped_mfa_setup_at: number | null`, `authorizer.skipMfaSetup(): Promise>` — consumed by `authorizer-react` Tasks 9-10. + +- [ ] **Step 1: Add the new fields to `types.ts`** + +Edit `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts`, in `interface User` (line 80-101), add after `is_multi_factor_auth_enabled: boolean | null;` (line 99): + +```typescript + is_multi_factor_auth_enabled: boolean | null; + has_skipped_mfa_setup_at: number | null; + app_data: Record | null; +``` + +In `interface AuthResponse` (line 135-148), add after `should_show_totp_screen: boolean | null;` (line 139): + +```typescript + should_show_totp_screen: boolean | null; + should_offer_mfa_setup: boolean | null; +``` + +- [ ] **Step 2: Add the `skipMfaSetup` method** + +Edit `/Users/lakhansamani/projects/authorizer/authorizer-js/src/index.ts`, add a new method mirroring `resendOtp` (line 595-618) — place it near `verifyOtp`/`resendOtp`: + +```typescript + skipMfaSetup = async (): Promise> => { + try { + const res = await this.dispatch( + 'skipMfaSetup', + ['graphql'], + { + query: 'mutation skip_mfa_setup { skip_mfa_setup { message } }', + operationName: 'skip_mfa_setup', + op: 'skip_mfa_setup', + }, + { method: 'POST', path: '/v1/skip_mfa_setup', body: {} }, + {}, + ); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data); + } catch (err) { + return this.errorResponse([err]); + } + }; +``` + +Note: this mutation is authenticated via cookie/bearer token already attached by `dispatch` for other authenticated calls (check how `deactivateAccount` — if present — sends auth; mirror that exactly rather than `resendOtp`'s unauthenticated pattern if they differ). + +- [ ] **Step 3: Build and bump version** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-js && npm run build` +Expected: builds clean, `lib/index.js` and `lib/index.d.ts` regenerated with the new field/method. + +Edit `package.json`: bump `"version"` from `3.3.0-rc.1` to `3.4.0-rc.0` (new feature, prerelease — matches the existing prerelease convention this repo already uses for `authorizer-react` integration, per this session's earlier `3.3.0-rc.1` bump). + +- [ ] **Step 4: Commit** + +```bash +git add src/types.ts src/index.ts package.json lib/ +git commit -m "feat: expose should_offer_mfa_setup, has_skipped_mfa_setup_at, and skipMfaSetup()" +``` + +--- + +## Frontend (`authorizer-react` repo) + +### Task 6: Point `authorizer-react` at the new local SDK build + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-react/package.json` + +- [ ] **Step 1: Link the local SDK build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npm install /Users/lakhansamani/projects/authorizer/authorizer-js` + +This updates `package.json`'s `@authorizerdev/authorizer-js` dependency to the local `3.4.0-rc.0` build (same mechanism as the existing `3.3.0-rc.1` dependency from the passkey-autofill work). + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: clean (no errors — this step just confirms the new SDK types are visible; nothing consumes them yet). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): bump @authorizerdev/authorizer-js to 3.4.0-rc.0 (optional MFA setup)" +``` + +--- + +### Task 7: Wire `AuthorizerBasicAuthLogin` to log in immediately and offer MFA setup + +**Files:** +- Modify: `src/components/AuthorizerBasicAuthLogin.tsx` (the `onSubmit` TOTP branch, current lines ~102-118, and the render method) + +**Interfaces:** +- Consumes: `res.should_offer_mfa_setup`, `res.authenticator_scanner_image/secret/recovery_codes`, `res.access_token` from Task 5's `AuthResponse`. +- Produces: nothing new — `res` (with `should_offer_mfa_setup` and the `authenticator_*` fields already on it once Task 6 is done) continues to flow to the existing `onLogin(res)` callback unchanged. The host app reads `should_offer_mfa_setup` off the argument it already receives — no new component state, no new prop. This is why `AuthorizerMFASetup` deliberately lives outside the login flow (Task 8): `AuthorizerBasicAuthLogin` unmounts the moment `token` is set (the host app's own router swaps away from the login screen, e.g. `example/src/App.tsx`'s `if (token) return `), so any state held *inside* this component past that point would never render. Surfacing the offer as data on `res`, for the host app to act on wherever it renders next, sidesteps that entirely. + +- [ ] **Step 1: Replace the forced-TOTP branch with the gate-aware branch** + +In `src/components/AuthorizerBasicAuthLogin.tsx`, replace the block currently at (post-earlier-session-edits) roughly lines 101-118: + +```typescript + // if totp is enabled for the first time show totp screen with scanner + if ( + res && + res.should_show_totp_screen && + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ) { + setTotpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + authenticator_scanner_image: res.authenticator_scanner_image, + authenticator_secret: res.authenticator_secret, + authenticator_recovery_codes: res.authenticator_recovery_codes, + }); + return; + } + if ( + res && + (res?.should_show_email_otp_screen || + res?.should_show_mobile_otp_screen || + res?.should_show_totp_screen) + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res?.should_show_totp_screen || false, + }); + return; + } + + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + + if (onLogin) { + onLogin(res); + } +``` + +with: + +```typescript + // res.access_token is only absent when MFA is enforced/blocking (see + // resolveMFAGate in the backend) — in every other case, including the + // optional-setup-offer case, the user is already logged in. + if (res && !res.access_token) { + if ( + res.should_show_totp_screen && + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ) { + setTotpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + authenticator_scanner_image: res.authenticator_scanner_image, + authenticator_secret: res.authenticator_secret, + authenticator_recovery_codes: res.authenticator_recovery_codes, + }); + return; + } + if ( + res.should_show_email_otp_screen || + res.should_show_mobile_otp_screen || + res.should_show_totp_screen + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res.should_show_totp_screen || false, + }); + return; + } + } + + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + + if (onLogin) { + onLogin(res); + } +``` + +Nothing else in this component changes — `res` (now carrying `should_offer_mfa_setup` and the `authenticator_*` fields per Task 6's type update) is already passed to `onLogin` unmodified by the existing `if (onLogin) { onLogin(res); }` call. + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/AuthorizerBasicAuthLogin.tsx +git commit -m "feat(mfa): log in immediately and surface should_offer_mfa_setup instead of blocking on optional TOTP setup" +``` + +--- + +### Task 8: Add a working Skip button to `AuthorizerMFASetup` and document the host-app integration + +**Files:** +- Modify: `src/components/AuthorizerMFASetup.tsx` +- Modify: `example/src/pages/login.tsx` (demonstrate the new `onLogin` shape) and `example/src/pages/dashboard.tsx` (add a "Set up MFA" prompt, sourced from AuthorizerMFASetup — this is how a host app is expected to use it) + +**Interfaces:** +- Consumes: `authorizerRef.skipMfaSetup()` from Task 5. +- Produces: `AuthorizerMFASetup` gains an `onSkip?: () => void` prop and a "Skip for now" link in the method-list view. + +- [ ] **Step 1: Add the Skip action to the component** + +In `src/components/AuthorizerMFASetup.tsx`, add `onSkip` to the props type (near `onSetupMethod`, line 67): + +```typescript + // Fired when a method is chosen that this component can't complete on its + // own (email/SMS OTP, or TOTP without an enrolment payload). + onSetupMethod?: (method: MfaMethod) => void; + // Fired when the user explicitly declines to set up MFA right now. Absent + // when MFA is organization-enforced — the host app must not render this + // component with onSkip set in that case (check config before rendering). + onSkip?: () => void; + heading?: string; +``` + +Add it to the destructured props (line 69-74) and render a skip link at the bottom of the method-list view (after the `
    ` block, before the closing `` around line 214-216): + +```typescript + )} + {onSkip && ( + + )} + + ); +}; +``` + +- [ ] **Step 2: Wire an example usage in the demo app** + +`AuthorizerMFASetup` is a standalone hub the *host app* renders wherever it wants (a settings page, a post-login interstitial) — it is intentionally NOT auto-rendered inside the login flow (confirmed in this session: its `availableMfaMethods`/`totpEnrollment` props require the host to already have this data, which now comes from `res.should_offer_mfa_setup` + `res.authenticator_*` on the `onLogin` callback argument, per Task 7). + +Edit `example/src/pages/login.tsx` to capture the offer: + +```typescript +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Authorizer } from 'authorizer-react'; + +const Login: React.FC = () => { + const navigate = useNavigate(); + return ( + <> +

    Welcome to Authorizer

    +
    + { + if (loginData?.should_offer_mfa_setup) { + sessionStorage.setItem( + 'mfaSetupOffer', + JSON.stringify({ + authenticator_scanner_image: loginData.authenticator_scanner_image, + authenticator_secret: loginData.authenticator_secret, + authenticator_recovery_codes: loginData.authenticator_recovery_codes, + }) + ); + } + }} + /> + + ); +}; + +export default Login; +``` + +Edit `example/src/pages/dashboard.tsx` to render the offer once, using `AuthorizerMFASetup` + `useAuthorizer()`'s `authorizerRef.skipMfaSetup()`: + +```typescript +import * as React from 'react'; +import { AuthorizerMFASetup, useAuthorizer } from 'authorizer-react'; + +const Dashboard: React.FC = () => { + const { authorizerRef, logout } = useAuthorizer(); + const [offer, setOffer] = React.useState(() => { + const raw = sessionStorage.getItem('mfaSetupOffer'); + return raw ? JSON.parse(raw) : null; + }); + + const dismiss = () => { + sessionStorage.removeItem('mfaSetupOffer'); + setOffer(null); + }; + + if (offer) { + return ( + { + await authorizerRef.skipMfaSetup(); + dismiss(); + }} + /> + ); + } + + return ( +
    +

    Dashboard

    + +
    + ); +}; + +export default Dashboard; +``` + +- [ ] **Step 3: Typecheck and build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json && npm run build` +Expected: clean. + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react/example && npx tsc --noEmit -p tsconfig.json` +Expected: clean. + +- [ ] **Step 4: End-to-end local verification** + +With the recipe server running and `lakhan@yopmail.com`'s `HasSkippedMFASetupAt` cleared (fresh state — re-run Task 3's curl to confirm `should_offer_mfa_setup: true` server-side first), start the example app (`npm start` in `example/`) and log in as `lakhan@yopmail.com` / `Test@123#` through the actual UI: + +Expected: immediate login to the Dashboard (no forced TOTP screen), followed by the `AuthorizerMFASetup` hub showing the TOTP QR with a working "Skip for now" link. Click Skip — expected: returns to the plain dashboard. Log out and log back in — expected: straight to dashboard, no MFA prompt (quiet, per `mfaGateSkippedSetup`). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/AuthorizerMFASetup.tsx example/src/pages/login.tsx example/src/pages/dashboard.tsx +git commit -m "feat(mfa): add Skip action to AuthorizerMFASetup and demonstrate host-app wiring" +``` + +--- + +## Deferred Work (documented, not built in this plan) + +**Passkey-as-MFA (second-factor WebAuthn, distinct from today's passkey-as-primary-login).** Confirmed absent from the backend entirely: no `EnvKeyWebauthnAuthenticator` constant (only `EnvKeyTOTPAuthenticator` exists in `internal/constants/authenticator_method.go`), no enrollment-during-MFA-gate logic. This is a legitimate MFA pattern (Okta and Auth0 both support "security key/biometric" as a second factor, distinct from passwordless primary login) but is its own project: new authenticator-type constant, WebAuthn ceremony wiring inside `resolveMFAGate`'s `mfaGateBlockEnroll`/`mfaGateOfferSetup` cases, and a fourth option in `AuthorizerMFASetup`'s method list (which already has the UI scaffolding — `availableMfaMethods.passkey` — just no backend behind it for the MFA case). Should be its own plan once this one has shipped and been used for a while. + +**Email/SMS OTP "not yet enrolled" parity.** Unlike TOTP, email and SMS OTP have no persistent enrollment artifact to pre-register — each login simply sends a fresh code. There is no equivalent to "scan this QR once" for these methods, so the enforced/optional/skip distinction this plan builds for TOTP doesn't map onto them the same way. The top-level "does this user have MFA turned on at all" decision (Tasks 3-4) already covers email/SMS OTP correctly; per-method "enrollment" simply isn't a concept that applies to them. No further work needed here unless a future requirement specifically calls for it. diff --git a/docs/superpowers/plans/2026-07-14-enforce-mfa-passkey-gate.md b/docs/superpowers/plans/2026-07-14-enforce-mfa-passkey-gate.md new file mode 100644 index 0000000..2354fb7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-enforce-mfa-passkey-gate.md @@ -0,0 +1,600 @@ +# Close the EnforceMFA Passkey-Login Bypass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A user cannot bypass an org's `--enforce-mfa` policy by using "Sign in with a passkey" (primary, passwordless login) instead of password + MFA verify. `WebauthnLoginVerify` gains the same TOTP-verify/enrollment gate `login.go`'s password path already has; the frontend stops offering the primary passkey button at all once MFA is enforced. + +**Architecture:** Backend-first, three small Go changes in `authorizer` (extract a shared session-setting helper, expose one new `Meta` field, wire the gate into `WebauthnLoginVerify`), then two small frontend changes (`authorizer-js` exposes the field, `authorizer-react` uses it to hide a button and guard a response handler). + +**Tech Stack:** Go 1.x / gqlgen for `authorizer`; TypeScript for `authorizer-js`; React + TypeScript for `authorizer-react`. + +**Spec:** `docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md` + +## Global Constraints + +- **`EnforceMFA=false` (or `IsMultiFactorAuthEnabled=false` for the specific user) leaves `WebauthnLoginVerify` completely unchanged.** Only the org-enforced-and-applicable-to-this-user case changes behavior. The fast, one-tap passkey login stays intact for everyone else. +- **Do not call `resolveMFAGate` for this fix.** It would pull in the offer/skip states (`mfaGateOfferSetup`/`mfaGateSkippedSetup`), which don't apply to a primary-login button. Write the gate condition and TOTP-verify/enroll branching directly, mirroring `login.go`'s shape without reusing the gate function itself. +- **When TOTP login is disabled server-wide (`!EnableTOTPLogin`) and the gate would otherwise apply, refuse the passkey login outright** with an actionable error pointing at password login — do not silently fall through to email/SMS OTP (those have no enrollment state, per the original passkey-as-MFA plan's research) and do not issue a token. +- **The refactor of `setOTPMFaSession` into a shared method must not change `login.go`'s behavior.** It is a pure extraction — every existing `login.go` test must pass unchanged after Task 1, before any new behavior is added in Task 3. + +--- + +## Backend (`authorizer` repo) + +### Task 1: Extract `setOTPMFaSession` into a shared provider method + +**Files:** +- Modify: `internal/service/login.go:166-176` (closure definition), and its 6 call sites at lines 215, 256, 329, 358, 398, 412 + +**Interfaces:** +- Produces: `func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects, userID string, expiresAt int64) error` — consumed by Task 3's `WebauthnLoginVerify` change, and by `login.go`'s own 6 existing call sites (updated in place). + +- [ ] **Step 1: Add the shared method** + +Add to `internal/service/login.go` (or a new small file `internal/service/mfa_session.go` — either is fine, this plan uses `login.go` to keep the diff in one place), near the top of the file after the imports: + +```go +// setMFASession arms a short-lived MFA session (memory-store entry + cookie) +// proving the caller already completed a first authentication factor for +// userID. verify_otp and the scoped webauthn_login_options/_verify flow both +// require this session before they'll act. Shared by Login's TOTP branch and +// WebauthnLoginVerify's EnforceMFA gate. +func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects, userID string, expiresAt int64) error { + mfaSession := uuid.NewString() + if err := p.MemoryStoreProvider.SetMfaSession(userID, mfaSession, expiresAt); err != nil { + return err + } + for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure) { + side.AddCookie(c) + } + return nil +} +``` + +Check `internal/service/login.go`'s existing imports already include `github.com/google/uuid` and `github.com/authorizerdev/authorizer/internal/cookie` (the closure being replaced already used both) — no new imports needed in this file. + +- [ ] **Step 2: Remove the closure and update call sites** + +Delete the closure definition at `internal/service/login.go:166-176`: + +```go + setOTPMFaSession := func(expiresAt int64) error { + mfaSession := uuid.NewString() + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + if err != nil { + log.Debug().Msg("Failed to set mfa session") + return err + } + for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure) { + side.AddCookie(c) + } + return nil + } +``` + +Replace every one of its 6 call sites (`internal/service/login.go:215,256,329,358,398,412`) — each currently reads `if err := setOTPMFaSession(expiresAt); err != nil {` — with: + +```go + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { +``` + +(Match each call site's existing indentation — they vary between the email/mobile OTP branches and the TOTP branch's switch cases. Only the function being called changes; the surrounding `if err := ...; err != nil { log.Debug()...; return nil, nil, err }` blocks are untouched.) + +- [ ] **Step 3: Build and run the existing login test suite to confirm no behavior change** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... && go test ./internal/integration_tests/ -run 'TestLogin|TestLoginMFAGateTokenWithholding|TestVerifyOtp' -v` + +Expected: builds clean, all existing subtests still pass unchanged — this is the regression guard proving the extraction didn't alter behavior. + +- [ ] **Step 4: Commit** + +```bash +git add internal/service/login.go +git commit -m "refactor(mfa): extract setOTPMFaSession into a shared setMFASession method" +``` + +--- + +### Task 2: Expose `is_mfa_enforced` on the `Meta` query + +**Files:** +- Modify: `internal/graph/schema.graphqls` (`Meta` type) +- Modify: `internal/service/meta.go` +- Modify: `internal/graph/model/models_gen.go` (regenerated) +- Test: `internal/integration_tests/meta_test.go` + +**Interfaces:** +- Produces: `model.Meta.IsMfaEnforced bool` (GraphQL field `is_mfa_enforced`) — consumed by `authorizer-js` Task 4 and `authorizer-react` Task 6. + +- [ ] **Step 1: Add the field to the schema** + +Edit `internal/graph/schema.graphqls`, in `type Meta {`, add after `is_webauthn_enabled: Boolean!`: + +```graphql + # is_webauthn_enabled indicates WebAuthn/passkey enrollment is available (always on; no operator flag). + is_webauthn_enabled: Boolean! + # is_mfa_enforced mirrors EnforceMFA — when true, the frontend must not + # offer a standalone passkey primary-login path (it would bypass the org's + # two-factor requirement); passkey should only be offered as a second + # factor after password/social login. The server enforces this + # independently in webauthn_login_verify regardless of what the frontend + # shows. + is_mfa_enforced: Boolean! +} +``` + +- [ ] **Step 2: Write the failing test** + +Edit `internal/integration_tests/meta_test.go`, add a new subtest inside `TestMeta` (after the `"should reflect disabled basic auth"` subtest, or anywhere in the same `t.Run` sequence): + +```go + t.Run("should reflect enforced MFA", func(t *testing.T) { + cfg2 := getTestConfig() + cfg2.EnforceMFA = true + ts2 := initTestSetup(t, cfg2) + _, ctx2 := createContext(ts2) + + meta, err := ts2.GraphQLProvider.Meta(ctx2) + require.NoError(t, err) + assert.NotNil(t, meta) + assert.True(t, meta.IsMfaEnforced) + }) + + t.Run("should reflect non-enforced MFA by default", func(t *testing.T) { + meta, err := ts.GraphQLProvider.Meta(ctx) + require.NoError(t, err) + assert.False(t, meta.IsMfaEnforced) + }) +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... 2>&1 | head -20` +Expected: FAIL at compile time — `meta.IsMfaEnforced undefined` (field doesn't exist on `model.Meta` yet). + +- [ ] **Step 4: Regenerate GraphQL code** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +Confirm: `grep -n "IsMfaEnforced" internal/graph/model/models_gen.go` — expect a `bool` field with `is_mfa_enforced` json tag. + +- [ ] **Step 5: Implement** + +Edit `internal/service/meta.go`, add after `IsWebauthnEnabled: true,`: + +```go + // WebAuthn/passkey ships always-on with no operator flag. + IsWebauthnEnabled: true, + IsMfaEnforced: c.EnforceMFA, +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestMeta -v` +Expected: PASS, all subtests including the two new ones. + +- [ ] **Step 7: Commit** + +```bash +git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/service/meta.go internal/integration_tests/meta_test.go +git commit -m "feat(mfa): expose is_mfa_enforced on the meta query" +``` + +--- + +### Task 3: Gate `WebauthnLoginVerify` on `EnforceMFA` + +**Files:** +- Modify: `internal/service/webauthn.go:133-171` (`WebauthnLoginVerify`) +- Test: `internal/integration_tests/webauthn_enforce_mfa_test.go` (new file) + +**Interfaces:** +- Consumes: `p.setMFASession` from Task 1, `p.generateTOTPEnrollment` (already shipped, `internal/service/login.go:67`), `p.StorageProvider.GetAuthenticatorDetailsByUserId` (already shipped). +- Produces: nothing new for other tasks — this is the actual security fix, self-contained. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/integration_tests/webauthn_enforce_mfa_test.go`: + +```go +package integration_tests + +import ( + "testing" + "time" + + "github.com/descope/virtualwebauthn" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// registerPasskeyForNewUser signs up a fresh verified user, registers one +// passkey via a simulated ceremony, and returns everything a login-time +// assertion needs. Mirrors the setup in webauthn_test.go's +// TestWebauthnPasskeyRegistrationAndLogin. +func registerPasskeyForNewUser(t *testing.T, ts *testSetup) (*schemas.User, virtualwebauthn.RelyingParty, virtualwebauthn.Authenticator, virtualwebauthn.Credential) { + t.Helper() + req, ctx := createContext(ts) + rp := testRelyingParty(t, ts) + credential := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + email := "enforce_mfa_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + signupRes, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, signupRes.AccessToken) + req.Header.Set("Authorization", "Bearer "+*signupRes.AccessToken) + + optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil) + require.NoError(t, err) + attOpts, err := virtualwebauthn.ParseAttestationOptions(optRes.Options) + require.NoError(t, err) + authenticator := virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{ + UserHandle: []byte(attOpts.UserID), + }) + authenticator.AddCredential(credential) + attResp := virtualwebauthn.CreateAttestationResponse(rp, authenticator, credential, *attOpts) + _, err = ts.GraphQLProvider.WebauthnRegistrationVerify(ctx, &model.WebauthnRegistrationVerifyRequest{Credential: attResp}) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + req.Header.Del("Authorization") + return user, rp, authenticator, credential +} + +func assertPasskeyLogin(t *testing.T, ts *testSetup, rp virtualwebauthn.RelyingParty, authenticator virtualwebauthn.Authenticator, credential virtualwebauthn.Credential) (*model.AuthResponse, error) { + t.Helper() + _, ctx := createContext(ts) + optRes, err := ts.GraphQLProvider.WebauthnLoginOptions(ctx, nil) + require.NoError(t, err) + assertOpts, err := virtualwebauthn.ParseAssertionOptions(optRes.Options) + require.NoError(t, err) + assertResp := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credential, *assertOpts) + return ts.GraphQLProvider.WebauthnLoginVerify(ctx, &model.WebauthnLoginVerifyRequest{Credential: assertResp}) +} + +func TestWebauthnLoginVerifyEnforceMFA(t *testing.T) { + t.Run("EnforceMFA=false — unchanged, issues token", func(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes.AccessToken, "EnforceMFA=false must not block passkey login") + }) + + t.Run("EnforceMFA=true, user MFA not individually enabled — unaffected", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + _, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + // IsMultiFactorAuthEnabled left false: mirrors resolveMFAGate's own + // precondition, consistent with password login's existing behavior. + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes.AccessToken) + }) + + t.Run("EnforceMFA=true, TOTP verified — blocks token, offers totp screen", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + now := time.Now().Unix() + _, err = ts.StorageProvider.AddAuthenticator(t.Context(), &schemas.Authenticator{ + UserID: user.ID, Method: constants.EnvKeyTOTPAuthenticator, + Secret: "dummy-secret", VerifiedAt: &now, + }) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes) + assert.Nil(t, authRes.AccessToken, "a user with verified TOTP must not get a token straight off a passkey login when MFA is enforced") + assert.True(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) + assert.Nil(t, authRes.AuthenticatorSecret, "already-enrolled path must not hand back a fresh enrollment payload") + }) + + t.Run("EnforceMFA=true, TOTP not enrolled — blocks token, returns enrollment payload", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes) + assert.Nil(t, authRes.AccessToken) + assert.True(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) + assert.NotNil(t, authRes.AuthenticatorSecret, "not-yet-enrolled path must hand back a fresh TOTP enrollment payload") + }) + + t.Run("EnforceMFA=true, TOTP disabled server-wide — refuses passkey login entirely", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + cfg.EnableTOTPLogin = false + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.Error(t, err, "must refuse rather than silently issue a token with no compatible second factor available") + assert.Nil(t, authRes) + }) +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestWebauthnLoginVerifyEnforceMFA -v` +Expected: the `EnforceMFA=false` and `not individually enabled` subtests PASS already (current unconditional-issue behavior happens to satisfy them); the three `EnforceMFA=true` subtests FAIL (today's code always issues a token / never refuses). + +- [ ] **Step 3: Implement the gate** + +Edit `internal/service/webauthn.go`. Add `"time"` to the import block (currently `context`, `strings`, then the internal packages, then `gin`): + +```go +import ( + "context" + "strings" + "time" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/gin-gonic/gin" +) +``` + +In `WebauthnLoginVerify`, insert this block immediately after the email-verified check (after the `if user.EmailVerifiedAt == nil { ... }` block, i.e. right before the `p.AuditProvider.LogEvent(...)` call that currently precedes `issueAuthResponse`): + +```go + // EnforceMFA is absolute and applies to passkey primary login exactly + // like it applies to password login: a passkey may not silently satisfy + // an org's two-factor requirement. This does not claim a passkey is + // itself insufficient as a factor - it only prevents passkey login from + // becoming an unintended bypass of a policy the org explicitly turned on. + if p.Config.EnforceMFA && refs.BoolValue(user.IsMultiFactorAuthEnabled) { + if !p.Config.EnableTOTPLogin { + log.Debug().Msg("EnforceMFA is on but no compatible second factor is configured for passkey login") + return nil, nil, FailedPrecondition("multi-factor authentication is required but no compatible verification method is available for passkey sign-in; please sign in with your password instead") + } + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + if totpVerified { + return &model.AuthResponse{ + Message: `Proceed to mfa verification`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + }, side, nil + } + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp") + return nil, nil, err + } + return &model.AuthResponse{ + Message: `Proceed to totp verification screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), + AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, + }, side, nil + } +``` + +Note `side` already exists in this function (`side := &ResponseSideEffects{}` at the top) — reuse it, don't redeclare. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestWebauthnLoginVerifyEnforceMFA -v` +Expected: PASS, all 5 subtests. + +- [ ] **Step 5: Run the full webauthn and login test files to confirm no regressions** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... && go vet ./... && go test ./internal/integration_tests/ -run 'TestWebauthn|TestLogin' -v` +Expected: builds clean, vet clean, all subtests pass — including the original `TestWebauthnPasskeyRegistrationAndLogin`'s usernameless/scoped login subtests (both run with `EnforceMFA` unset/false via `getTestConfig()`, so they must be completely unaffected). + +- [ ] **Step 6: Commit** + +```bash +git add internal/service/webauthn.go internal/integration_tests/webauthn_enforce_mfa_test.go +git commit -m "feat(mfa): refuse passkey primary login to silently bypass EnforceMFA" +``` + +--- + +## SDK (`authorizer-js` repo) + +### Task 4: Expose `is_mfa_enforced` + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts` (`Meta` interface) +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/package.json` (version bump) + +**Interfaces:** +- Consumes: `is_mfa_enforced` from Task 2's `Meta` query. +- Produces: `Types.Meta.is_mfa_enforced: boolean` — consumed by `authorizer-react` Task 6. This flows into React's `config` object automatically via the existing `getMetaData()` → context-merge path (no SDK method changes needed — `Meta` is already fetched and merged wholesale). + +- [ ] **Step 1: Add the field to `types.ts`** + +Edit `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts`, in `interface Meta`, add after `is_webauthn_enabled: boolean;`: + +```typescript + is_webauthn_enabled: boolean; + is_mfa_enforced: boolean; +``` + +- [ ] **Step 2: Build and bump version** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-js && npm run build` +Expected: builds clean, `lib/index.d.ts` regenerated with the new field. + +Edit `package.json`: bump `"version"` from `3.5.0-rc.0` to `3.6.0-rc.0`. + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts package.json lib/ +git commit -m "feat: expose is_mfa_enforced on Meta" +``` + +--- + +## Frontend (`authorizer-react` repo) + +### Task 5: Point `authorizer-react` at the new local SDK build + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-react/package.json` + +- [ ] **Step 1: Link the local SDK build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npm install /Users/lakhansamani/projects/authorizer/authorizer-js` + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): bump @authorizerdev/authorizer-js to 3.6.0-rc.0 (enforce-mfa passkey gate)" +``` + +--- + +### Task 6: Hide the primary passkey button when MFA is enforced, guard the response handler + +**Files:** +- Modify: `src/components/AuthorizerPasskeyLogin.tsx` + +**Interfaces:** +- Consumes: `config.is_mfa_enforced` (from Task 5's linked SDK, already flows into `config` via the existing `useAuthorizer()` context — same pattern as `config.is_google_login_enabled` etc. already used in this same file). + +- [ ] **Step 1: Hide the button when MFA is enforced** + +In `src/components/AuthorizerPasskeyLogin.tsx`, find the early return: + +```typescript + if (!isWebauthnSupported()) { + return null; + } +``` + +Replace with: + +```typescript + // When the org enforces MFA, passkey must never be offered as a + // standalone primary-login path - it would let a user skip the org's + // two-factor requirement entirely. The server refuses this independently + // (webauthn_login_verify checks EnforceMFA itself), but the button + // shouldn't invite the attempt in the first place: authenticator methods + // belong after a first factor has identified the user, not before. + if (!isWebauthnSupported() || config.is_mfa_enforced) { + return null; + } +``` + +- [ ] **Step 2: Guard the response handler against a tokenless response** + +Find the `onClick` handler's success path: + +```typescript + if (res) { + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } +``` + +Replace with: + +```typescript + if (res && !res.access_token) { + // Reachable only if this button somehow rendered despite the + // is_mfa_enforced guard above (e.g. a brief pre-config-load + // window) - the server is the real boundary and just refused to + // issue a token. Don't treat this as a successful login. + setError( + `Additional verification is required. Please sign in with your email and password instead.` + ); + return; + } + if (res) { + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } +``` + +- [ ] **Step 3: Typecheck and build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json && npm run build` +Expected: clean. + +- [ ] **Step 4: Manual local verification** + +With the recipe server running with `--enforce-mfa`, a user who has a registered passkey but no TOTP: + +1. Load the login screen. Expected: "Sign in with a passkey" button is absent. +2. Log in with password. Expected: forced into TOTP enrollment (existing `mfaGateBlockEnroll` behavior, unchanged by this plan). +3. Complete TOTP enrollment, log out, log back in with password. Expected: TOTP verify screen (existing `mfaGateBlockVerify` behavior). +4. Confirm no code path in the UI ever offers a bare "sign in with passkey, skip everything" option while `--enforce-mfa` is set. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/AuthorizerPasskeyLogin.tsx +git commit -m "feat(mfa): hide the primary passkey button when MFA is enforced" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** backend gate (Task 3) ✓, backend refactor prerequisite (Task 1) ✓, `is_mfa_enforced` field (Task 2) ✓, SDK exposure (Task 4) ✓, frontend hide + defense-in-depth (Task 6) ✓. Both explicitly-deferred items from the spec (self-opted-but-not-enforced TOTP skippability via passkey; the NIST AAL2 question) are correctly absent from this plan. +- **Type consistency:** `EnforceMFA` (Go, unchanged existing field) → `IsMfaEnforced`/`is_mfa_enforced` (Meta, gqlgen-generated → GraphQL/JSON) → `is_mfa_enforced` (TS `Meta`) → `config.is_mfa_enforced` (React, via the existing meta-merge path, no new plumbing) — traced end to end, no mismatches, no new naming introduced beyond this one field. +- **No placeholders:** every step has complete, compilable code. diff --git a/docs/superpowers/plans/2026-07-14-passkey-as-mfa.md b/docs/superpowers/plans/2026-07-14-passkey-as-mfa.md new file mode 100644 index 0000000..baaf4a2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-passkey-as-mfa.md @@ -0,0 +1,595 @@ +# Passkey as a Second MFA Factor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A user who has registered a passkey can use it to satisfy an MFA challenge at login (email + password), as an alternative to entering a TOTP code, without changing today's separate passwordless-primary-login passkey flow. + +**Architecture:** `resolveMFAGate`'s `authenticatorVerified` input becomes "TOTP verified OR has a registered WebAuthn credential." The already-shipped, MFA-session-cookie-gated "scoped" `webauthn_login_options(email)` + `webauthn_login_verify` mutations (`internal/service/webauthn.go`) do the actual verification — no new backend mutation is needed. One new `AuthResponse` boolean (`should_offer_webauthn_mfa_verify`) tells the frontend a passkey option exists; the frontend calls the already-shipped SDK method `authorizerRef.loginWithPasskey(email)` to complete it. + +**Tech Stack:** Go 1.x / gqlgen / GORM (SQL auto-migrates; no new schema field in this plan, so no per-backend migration work) for `authorizer`; TypeScript for `authorizer-js`; React + TypeScript for `authorizer-react`. + +**Spec:** `docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md` + +## Global Constraints + +- **Base branch is `feat/optional-mfa-setup`, not `main`, in all three repos.** This plan's `authenticatorVerified` computation reads `resolveMFAGate`, `HasSkippedMFASetupAt`, and the `mfaGateBlockVerify` case — none of which exist on `main` yet in any of the three repos as of this writing. Branch `feat/passkey-mfa` from the tip of each repo's `feat/optional-mfa-setup` work (worktree paths: `authorizer/.worktrees/feat/optional-mfa-setup`, `authorizer-js/.worktrees/feat/optional-mfa-setup`, `authorizer-react/.worktrees/feat/optional-mfa-setup`). +- **The MFA branch guard in `login.go` (`if isMFAEnabled && isTOTPLoginEnabled`) is NOT changed.** `is_webauthn_enabled` is a hardcoded-`true` GraphQL field, not an operator config toggle — OR-ing it into the guard would make `mfaGateBlockEnroll`'s unconditional TOTP-secret generation fire even on servers with TOTP disabled. Passkey-as-MFA in this plan only becomes available on servers that already have TOTP MFA enabled (`EnableMFA && EnableTOTPLogin`); it adds passkey as an alternative *verify* method within that existing umbrella. +- **No `purpose` field on `WebauthnCredential`.** Any registered passkey — however it was registered — satisfies the passkey-MFA factor. `authenticatorVerified` (passkey) = `len(ListWebauthnCredentialsByUserID(userID)) > 0`, full stop. +- **`mfaGateBlockEnroll` (org-enforced, zero factors enrolled yet) is untouched.** Passkey enrollment in that pre-token state needs new MFA-session-gated registration mutations (today's `webauthn_registration_options`/`_verify` require a real access token) — out of scope for this plan. +- **No new frontend test framework.** This repo's `"test"` script is `tsc --noEmit` — there is no Jest/RTL component-test setup. "Testing" for `authorizer-react` tasks means: library `tsc --noEmit`, library `npm run build`, and (where noted) the `example/` app's `tsc --noEmit`. Do not add a test runner as part of this plan. + +--- + +## Backend (`authorizer` repo) + +### Task 1: Wire WebAuthn credentials into the MFA gate's verify path + +**Files:** +- Modify: `internal/graph/schema.graphqls:116-131` (`AuthResponse` type — new field) +- Modify: `internal/graph/model/models_gen.go` (regenerated) +- Modify: `internal/service/login.go:377-430` (the TOTP/MFA branch) +- Test: `internal/integration_tests/mfa_gate_login_test.go` (extend `TestLoginMFAGateTokenWithholding`) + +**Interfaces:** +- Consumes: `resolveMFAGate` (unchanged signature, from the already-shipped `internal/service/mfa_gate.go`), `p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, userID) ([]*schemas.WebauthnCredential, error)` (already shipped, `internal/storage/provider.go:254`). +- Produces: `model.AuthResponse.ShouldOfferWebauthnMfaVerify *bool` — true on the `mfaGateBlockVerify` response when the user has ≥1 registered WebAuthn credential. Consumed by `authorizer-js` Task 2 and `authorizer-react` Tasks 4-5. + +- [ ] **Step 1: Add the field to the GraphQL schema** + +Edit `internal/graph/schema.graphqls`, inside `type AuthResponse {` (line 116-131), add after `should_show_totp_screen: Boolean` (line 120): + +```graphql + should_show_totp_screen: Boolean + # should_offer_webauthn_mfa_verify is true when the authenticated-with- + # password user has a registered passkey and MFA verification (not + # enrollment) is required before a token is issued. The frontend should + # offer a "verify with your passkey" action — calling the existing scoped + # webauthn_login_options(email)/webauthn_login_verify mutations — in + # addition to (or instead of) should_show_totp_screen's code-entry form. + should_offer_webauthn_mfa_verify: Boolean +``` + +Regenerate: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +Confirm: `grep -n "ShouldOfferWebauthnMfaVerify" internal/graph/model/models_gen.go` — expect a `*bool` field with `should_offer_webauthn_mfa_verify,omitempty` json tag. + +- [ ] **Step 2: Write the failing integration tests** + +Edit `internal/integration_tests/mfa_gate_login_test.go`. Add a helper next to `addVerifiedAuthenticator` (after line 64): + +```go + // addWebauthnCredential gives the user a registered passkey, the + // condition login.go reads (via ListWebauthnCredentialsByUserID) as an + // alternative authenticatorVerified=true source alongside TOTP. + addWebauthnCredential := func(t *testing.T, ts *testSetup, ctx context.Context, userID string) { + t.Helper() + _, err := ts.StorageProvider.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{ + UserID: userID, + CredentialID: uuid.NewString(), + PublicKey: "dummy-public-key-for-gate-test", + }) + require.NoError(t, err) + } +``` + +Add two new subtests inside `TestLoginMFAGateTokenWithholding`, after the `"mfaGateBlockVerify withholds the token"` subtest (after line 84): + +```go + t.Run("mfaGateBlockVerify offers passkey verify for a passkey-only user", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + addWebauthnCredential(t, ts, ctx, user.ID) + // No TOTP authenticator enrolled — passkey is this user's only factor. + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "a user with a registered passkey must not receive a token before verifying it") + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen), "must not force a TOTP screen on a user who never enrolled TOTP") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify)) + }) + + t.Run("mfaGateBlockVerify offers both methods for a dual-enrolled user", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + addVerifiedAuthenticator(t, ts, ctx, user.ID) + addWebauthnCredential(t, ts, ctx, user.ID) + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken) + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify)) + }) +``` + +Also extend the existing `"mfaGateBlockVerify withholds the token"` subtest (lines 66-84) with one more assertion right after `assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen))` (line 83): + +```go + assert.False(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify), "a TOTP-only user must not be offered a passkey verify option they never registered") +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestLoginMFAGateTokenWithholding -v` +Expected: the two new subtests FAIL (`ShouldOfferWebauthnMfaVerify` is always nil/false because `login.go` doesn't set it yet); the extended assertion in the first subtest passes trivially (field is already nil). + +- [ ] **Step 4: Implement the gate wiring in `login.go`** + +Edit `internal/service/login.go`, replace lines 378-397 (the branch guard through the end of `case mfaGateBlockVerify:`): + +```go + if isMFAEnabled && isTOTPLoginEnabled { + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + // A WebAuthn credential registered for ANY purpose (passwordless + // primary login or explicit MFA setup — there is no `purpose` field) + // counts as a verified second factor. Ignore a list error rather than + // failing login on it: treat "couldn't check" the same as "found + // none," matching how a missing TOTP authenticator row is handled. + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + hasWebauthnCredential := len(webauthnCreds) > 0 + authenticatorVerified := totpVerified || hasWebauthnCredential + gate := resolveMFAGate( + refs.BoolValue(user.IsMultiFactorAuthEnabled), + p.Config.EnforceMFA, + authenticatorVerified, + user.HasSkippedMFASetupAt != nil, + ) + switch gate { + case mfaGateBlockVerify: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := setOTPMFaSession(expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + 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 +``` + +Leave `case mfaGateBlockEnroll:` (originally starting at line 398) and everything after it exactly as-is — only the code above it changes. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestLoginMFAGateTokenWithholding -v` +Expected: PASS, all 7 subtests (5 original + 2 new). + +- [ ] **Step 6: Build and vet the whole repo** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... && go vet ./... && go test ./internal/service/ -run TestResolveMFAGate -v` +Expected: builds clean, vet clean, `TestResolveMFAGate`'s 9 subtests still pass unchanged (that function itself was not touched). + +- [ ] **Step 7: Commit** + +```bash +git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/service/login.go internal/integration_tests/mfa_gate_login_test.go +git commit -m "feat(mfa): let a registered passkey satisfy MFA verification" +``` + +--- + +## SDK (`authorizer-js` repo) + +### Task 2: Expose the new response field + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts` (`AuthResponse` interface) +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/package.json` (version bump) + +**Interfaces:** +- Consumes: `should_offer_webauthn_mfa_verify` from Task 1's `AuthResponse`. +- Produces: `Types.AuthResponse.should_offer_webauthn_mfa_verify: boolean | null` — consumed by `authorizer-react` Tasks 4-5. `loginWithPasskey(email?, opts?)` (already shipped, `src/index.ts:782`) needs no changes — it already accepts an `email` to scope the ceremony to one account, which is exactly the MFA-verify usage this plan needs. + +- [ ] **Step 1: Add the field to `types.ts`** + +Edit `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts`, in `interface AuthResponse`, add after `should_show_totp_screen: boolean | null;` (line 140): + +```typescript + should_show_totp_screen: boolean | null; + should_offer_webauthn_mfa_verify: boolean | null; +``` + +- [ ] **Step 2: Build and bump version** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-js && npm run build` +Expected: builds clean, `lib/index.d.ts` regenerated with the new field. + +Edit `package.json`: bump `"version"` from `3.4.0-rc.0` to `3.5.0-rc.0` (new feature, matches this repo's existing prerelease convention). + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts package.json lib/ +git commit -m "feat: expose should_offer_webauthn_mfa_verify on AuthResponse" +``` + +--- + +## Frontend (`authorizer-react` repo) + +### Task 3: Point `authorizer-react` at the new local SDK build + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-react/package.json` + +- [ ] **Step 1: Link the local SDK build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npm install /Users/lakhansamani/projects/authorizer/authorizer-js` + +This updates `package.json`'s `@authorizerdev/authorizer-js` dependency to the local `3.5.0-rc.0` build (same mechanism used for every prior local-SDK bump in this codebase). + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: clean (confirms the new SDK type is visible; nothing consumes it yet). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): bump @authorizerdev/authorizer-js to 3.5.0-rc.0 (passkey MFA)" +``` + +--- + +### Task 4: Route the passkey-verify offer from `AuthorizerBasicAuthLogin` to `AuthorizerVerifyOtp` + +**Files:** +- Modify: `src/types/index.ts:75-80` (`OtpDataType`) +- Modify: `src/components/AuthorizerBasicAuthLogin.tsx:121-133,252-265` + +**Interfaces:** +- Consumes: `res.should_offer_webauthn_mfa_verify` from Task 3's linked SDK. +- Produces: `OtpDataType.offer_webauthn_verify?: boolean`, threaded through to `AuthorizerVerifyOtp`'s new `offerWebauthnVerify` prop (consumed by Task 5). + +- [ ] **Step 1: Add the field to `OtpDataType`** + +Edit `src/types/index.ts`, in `OtpDataType` (lines 75-80), add after `is_totp?: boolean;`: + +```typescript +export type OtpDataType = { + is_screen_visible: boolean; + email?: string; + phone_number?: string; + is_totp?: boolean; + offer_webauthn_verify?: boolean; +}; +``` + +- [ ] **Step 2: Recognize the new flag in the login branch** + +Edit `src/components/AuthorizerBasicAuthLogin.tsx`, replace the second `if` block inside the `res && !res.access_token` guard (lines 121-133): + +```typescript + if ( + res.should_show_email_otp_screen || + res.should_show_mobile_otp_screen || + res.should_show_totp_screen || + res.should_offer_webauthn_mfa_verify + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res.should_show_totp_screen || false, + offer_webauthn_verify: res.should_offer_webauthn_mfa_verify || false, + }); + return; + } +``` + +- [ ] **Step 3: Pass it through to `AuthorizerVerifyOtp`** + +Edit `src/components/AuthorizerBasicAuthLogin.tsx`, in the `otpData.is_screen_visible` render block (lines 252-265): + +```typescript + if (otpData.is_screen_visible) { + return ( + + ); + } +``` + +- [ ] **Step 4: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: an error that `offerWebauthnVerify` is not a known prop on `AuthorizerVerifyOtp` — that's Task 5's job. This is expected and confirms the wiring compiles as far as it can before Task 5. + +- [ ] **Step 5: Commit** + +```bash +git add src/types/index.ts src/components/AuthorizerBasicAuthLogin.tsx +git commit -m "feat(mfa): thread should_offer_webauthn_mfa_verify to the OTP screen" +``` + +--- + +### Task 5: Offer "verify with your passkey" in `AuthorizerVerifyOtp` + +**Files:** +- Modify: `src/components/AuthorizerVerifyOtp.tsx` + +**Interfaces:** +- Consumes: `offerWebauthnVerify` prop from Task 4, `authorizerRef.loginWithPasskey(email)` (already shipped SDK method), `isWebauthnSupported()` and `IconPasskey` (already shipped/exported — `@authorizerdev/authorizer-js` and `../icons/mfa` respectively, both already used by `AuthorizerPasskeyLogin.tsx`/`AuthorizerMFASetup.tsx`). +- Produces: nothing new — `res` flows to `onLogin`/`setAuthData` exactly like the existing code-entry `onSubmit` does. + +- [ ] **Step 1: Add imports and the new prop** + +Edit `src/components/AuthorizerVerifyOtp.tsx`, add to the imports at the top (after line 2): + +```typescript +import { VerifyOTPRequest, isWebauthnSupported } from '@authorizerdev/authorizer-js'; +import '../styles/default.css'; + +import { ButtonAppearance, MessageType, Views } from '../constants'; +import { useAuthorizer } from '../contexts/AuthorizerContext'; +import { StyledButton, StyledFooter, StyledLink } from '../styledComponents'; +import { Message } from './Message'; +import { TotpDataType } from '../types'; +import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner'; +import { IconPasskey } from '../icons/mfa'; +``` + +Add `offerWebauthnVerify?: boolean;` to the component's prop type (line 25-32): + +```typescript +export const AuthorizerVerifyOtp: FC<{ + setView?: (v: Views) => void; + onLogin?: (data: any) => void; + email?: string; + phone_number?: string; + urlProps?: Record; + is_totp?: boolean; + offerWebauthnVerify?: boolean; +}> = ({ setView, onLogin, email, phone_number, urlProps, is_totp, offerWebauthnVerify }) => { +``` + +- [ ] **Step 2: Add passkey-verify state and handler** + +Inside the component body, after the existing `useEffect` that checks `email`/`phone_number` (after line 50), add: + +```typescript + const [webauthnError, setWebauthnError] = useState(``); + const [webauthnLoading, setWebauthnLoading] = useState(false); + const passkeySupported = isWebauthnSupported(); + + // A cancelled ceremony surfaces as NotAllowedError/AbortError (same + // browser behavior AuthorizerPasskeyLogin already handles) - dismiss + // silently and let the user fall back to the code form when one exists. + const isUserDismissed = (e?: { code?: string }): boolean => + e?.code === `NotAllowedError` || e?.code === `AbortError`; + + const onVerifyWithPasskey = async () => { + setWebauthnError(``); + try { + setWebauthnLoading(true); + const { data: res, errors } = await authorizerRef.loginWithPasskey(email); + if (errors && errors.length) { + if (!isUserDismissed(errors[0])) { + setWebauthnError(errors[0]?.message || ``); + } + return; + } + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } + } catch (err) { + if (!isUserDismissed(err as { code?: string })) { + setWebauthnError((err as Error).message); + } + } finally { + setWebauthnLoading(false); + } + }; +``` + +- [ ] **Step 3: Render the passkey option and hide the code form when it's the only method** + +Edit the final return block (lines 191-272). Replace the intro paragraph and form (lines 207-241) plus the `StyledFooter`'s resend-link condition: + +```typescript + const showCodeForm = !(offerWebauthnVerify && !is_totp); + + return ( + <> + {successMessage && ( + + )} + {error && ( + + )} + {webauthnError && ( + setWebauthnError(``)} + /> + )} + {offerWebauthnVerify && passkeySupported && ( + <> +

    + Verify with your passkey +

    + + + + {webauthnLoading ? `Waiting for passkey ...` : `Verify with a passkey`} + + +
    + + )} + {showCodeForm && ( + <> + {offerWebauthnVerify && passkeySupported && ( +

    + Or enter a code instead +

    + )} +

    + Please enter the OTP sent to your email or phone number or authenticator +

    +
    +
    +
    + + onInputChange('otp', e.target.value)} + disabled={isLockedOut} + /> + {errorData.otp && ( +
    {errorData.otp}
    + )} + {is_totp && ( + + )} +
    +
    + + {loading ? `Processing ...` : `Submit`} + +
    + + )} + {setView && ( + + {!is_totp && + !offerWebauthnVerify && + (sendingOtp ? ( +
    Sending ...
    + ) : ( + + Resend OTP + + ))} + {config.is_sign_up_enabled && ( +
    + Don't have an account?{' '} + setView(Views.Signup)}> + Sign Up + +
    + )} +
    + )} + + ); +}; +``` + +(The `!is_totp && !offerWebauthnVerify` guard on the Resend OTP link prevents it from appearing in the passkey-only case — resending a one-time code makes no sense there, and previously `!is_totp` alone would have incorrectly shown it.) + +- [ ] **Step 4: Typecheck and build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json && npm run build` +Expected: clean. `dist/index.d.ts` regenerated with `AuthorizerVerifyOtp`'s new `offerWebauthnVerify` prop. + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react/example && npx tsc --noEmit -p tsconfig.json` +Expected: clean. + +- [ ] **Step 5: Manual local verification** + +With a recipe server running (`--enable-mfa`, `--enable-totp-login`, `--enforce-mfa=false`) and a test user who has registered a passkey (via the settings-page `AuthorizerMFASetup` "Passkey" tile, or the primary passkey-login registration flow — either satisfies the factor per this plan's scoping decision): + +1. Log out, log back in with email + password. +2. Expected: no forced TOTP code screen if the user has no verified TOTP; instead, a "Verify with a passkey" button appears. Clicking it and completing the browser ceremony logs the user in. +3. For a user with both TOTP and a passkey registered: expect both the passkey button and the code-entry form to render, either one completing login. +4. Cancel the passkey ceremony (Escape/cancel dialog): expect no error banner when a code form is also available (dual-enrolled case) — the button simply becomes clickable again. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/AuthorizerVerifyOtp.tsx +git commit -m "feat(mfa): offer passkey verification alongside the OTP code form" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Backend field + gate wiring (Task 1) ✓, SDK field (Task 2) ✓, frontend wiring through both components (Tasks 3-5) ✓. `mfaGateBlockEnroll` and delete-your-only-factor guard are spec-deferred, correctly absent from this plan. +- **Type consistency:** `ShouldOfferWebauthnMfaVerify` (Go, gqlgen-generated) → `should_offer_webauthn_mfa_verify` (GraphQL/JSON) → `should_offer_webauthn_mfa_verify` (TS `AuthResponse`) → `offer_webauthn_verify` (TS `OtpDataType`, deliberately shorter — internal state, not wire format) → `offerWebauthnVerify` (component prop) — traced end to end, no mismatches. +- **No placeholders:** every step has complete, compilable code. diff --git a/docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md b/docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md new file mode 100644 index 0000000..d38e71c --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md b/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md new file mode 100644 index 0000000..ed08f74 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md @@ -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. diff --git a/example/src/index.tsx b/example/src/index.tsx index 82c2fc4..e8b5989 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -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); - }} > diff --git a/example/src/pages/dashboard.tsx b/example/src/pages/dashboard.tsx index 0910114..ee3850b 100644 --- a/example/src/pages/dashboard.tsx +++ b/example/src/pages/dashboard.tsx @@ -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(() => { + 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 ( + { + await authorizerRef.skipMfaSetup(); + dismissMfaOffer(); + }} + /> + ); + } return (
    diff --git a/example/src/pages/login.tsx b/example/src/pages/login.tsx index 9c0bde9..7780139 100644 --- a/example/src/pages/login.tsx +++ b/example/src/pages/login.tsx @@ -7,14 +7,17 @@ const Login: React.FC = () => {

    Welcome to Authorizer


    { - 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, + }) + ); + } }} /> diff --git a/package-lock.json b/package-lock.json index dc80ace..41b4ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.2.0-rc.0", "license": "MIT", "dependencies": { - "@authorizerdev/authorizer-js": "^3.3.0-rc.1", + "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" }, @@ -52,6 +52,140 @@ "react": ">=16" } }, + "../../../../authorizer-js": { + "name": "@authorizerdev/authorizer-js", + "version": "3.3.0-rc.1", + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.1.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.27.3", + "@swc/core": "^1.15.24", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "bumpp": "^9.11.1", + "eslint": "^8.57.1", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.5.2", + "testcontainers": "^10.28.0", + "ts-jest": "^29.4.9", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/authorizerdev" + } + }, + "../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate": { + "name": "@authorizerdev/authorizer-js", + "version": "3.6.0-rc.0", + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.1.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.27.3", + "@swc/core": "^1.15.24", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "bumpp": "^9.11.1", + "eslint": "^8.57.1", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.5.2", + "testcontainers": "^10.28.0", + "ts-jest": "^29.4.9", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/authorizerdev" + } + }, + "../../../../authorizer-js/.worktrees/feat/optional-mfa-setup": { + "name": "@authorizerdev/authorizer-js", + "version": "3.4.0-rc.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.1.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.27.3", + "@swc/core": "^1.15.24", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "bumpp": "^9.11.1", + "eslint": "^8.57.1", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.5.2", + "testcontainers": "^10.28.0", + "ts-jest": "^29.4.9", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/authorizerdev" + } + }, + "../../../../authorizer-js/.worktrees/feat/passkey-mfa": { + "name": "@authorizerdev/authorizer-js", + "version": "3.5.0-rc.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.1.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.27.3", + "@swc/core": "^1.15.24", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "bumpp": "^9.11.1", + "eslint": "^8.57.1", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.5.2", + "testcontainers": "^10.28.0", + "ts-jest": "^29.4.9", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/authorizerdev" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -73,19 +207,8 @@ } }, "node_modules/@authorizerdev/authorizer-js": { - "version": "3.3.0-rc.1", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0-rc.1.tgz", - "integrity": "sha512-L6S2BzHIP7cPa4nD3FT2RxRiQM5w6jauCNtEX+5t+8sUr4DrrcZON2MhYhVSuBT8+8/Y9TcKO2ZamZwuDX0pVg==", - "license": "MIT", - "dependencies": { - "cross-fetch": "^4.1.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/authorizerdev" - } + "resolved": "../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate", + "link": true }, "node_modules/@babel/code-frame": { "version": "7.29.0", @@ -4387,15 +4510,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/cross-fetch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", - "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -7434,26 +7548,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -9147,12 +9241,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -10136,12 +10224,6 @@ "node": ">=10.13.0" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, "node_modules/webpack": { "version": "5.105.4", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", @@ -10253,16 +10335,6 @@ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT" }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", diff --git a/package.json b/package.json index 3aea20a..43f8798 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "typescript": "^5.7.2" }, "dependencies": { - "@authorizerdev/authorizer-js": "^3.3.0-rc.1", + "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" } diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index 1a38654..b8ef7d0 100644 --- a/src/components/AuthorizerBasicAuthLogin.tsx +++ b/src/components/AuthorizerBasicAuthLogin.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import { AuthToken, LoginRequest } from '@authorizerdev/authorizer-js'; import validator from 'validator'; const { isEmail, isMobilePhone } = validator; @@ -98,37 +98,41 @@ export const AuthorizerBasicAuthLogin: FC<{ setError(errors[0].message); return; } - // if totp is enabled for the first time show totp screen with scanner - if ( - res && - res.should_show_totp_screen && - res.authenticator_scanner_image && - res.authenticator_secret && - res.authenticator_recovery_codes - ) { - setTotpData({ - is_screen_visible: true, - email: data.email || ``, - phone_number: data.phone_number || ``, - authenticator_scanner_image: res.authenticator_scanner_image, - authenticator_secret: res.authenticator_secret, - authenticator_recovery_codes: res.authenticator_recovery_codes, - }); - return; - } - if ( - res && - (res?.should_show_email_otp_screen || - res?.should_show_mobile_otp_screen || - res?.should_show_totp_screen) - ) { - setOtpData({ - is_screen_visible: true, - email: data.email || ``, - phone_number: data.phone_number || ``, - is_totp: res?.should_show_totp_screen || false, - }); - return; + // res.access_token is only absent when MFA is enforced/blocking (see + // resolveMFAGate in the backend) — in every other case, including the + // optional-setup-offer case, the user is already logged in. + if (res && !res.access_token) { + if ( + res.should_show_totp_screen && + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ) { + setTotpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + authenticator_scanner_image: res.authenticator_scanner_image, + authenticator_secret: res.authenticator_secret, + authenticator_recovery_codes: res.authenticator_recovery_codes, + }); + return; + } + if ( + res.should_show_email_otp_screen || + res.should_show_mobile_otp_screen || + res.should_show_totp_screen || + res.should_offer_webauthn_mfa_verify + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res.should_show_totp_screen || false, + offer_webauthn_verify: res.should_offer_webauthn_mfa_verify || false, + }); + return; + } } if (res) { @@ -157,28 +161,29 @@ export const AuthorizerBasicAuthLogin: FC<{ useEffect(() => { if (formData.email_or_phone_number === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Email OR Phone Number is required', - }); + })); } else if ( + formData.email_or_phone_number !== null && !isEmail(formData.email_or_phone_number || '') && !isMobilePhone(formData.email_or_phone_number || '') ) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Invalid Email OR Phone Number', - }); + })); } else { - setErrorData({ ...errorData, email_or_phone_number: null }); + setErrorData(prev => ({ ...prev, email_or_phone_number: null })); } }, [formData.email_or_phone_number]); useEffect(() => { if (formData.password === '') { - setErrorData({ ...errorData, password: 'Password is required' }); + setErrorData(prev => ({ ...prev, password: 'Password is required' })); } else { - setErrorData({ ...errorData, password: null }); + setErrorData(prev => ({ ...prev, password: null })); } }, [formData.password]); @@ -186,14 +191,23 @@ export const AuthorizerBasicAuthLogin: FC<{ // user's device support it, discoverable passkeys are offered inline in the // email/username field's autofill dropdown. Best-effort and silent - the // explicit "Sign in with a passkey" button remains the primary path, and any - // unsupported/cancelled/aborted ceremony is ignored. Started once on mount - // and aborted on unmount. - useEffect(() => { - let active = true; + // unsupported/cancelled/aborted ceremony is ignored. + // Started on the email/phone field's first focus rather than on mount: some + // browser/platform authenticator combinations (e.g. Chrome + macOS Touch ID) + // don't wait for the field to actually be focused before surfacing a native + // passkey prompt, which showed up as an unprompted popup on every page load. + // Deferring to focus keeps the ceremony tied to a real user interaction. + const passkeyAutofillActive = useRef(false); + + const startPasskeyAutofill = () => { + if (passkeyAutofillActive.current) { + return; + } + passkeyAutofillActive.current = true; authorizerRef .loginWithPasskeyAutofill() .then(({ data, errors }) => { - if (!active || (errors && errors.length) || !data) { + if (!passkeyAutofillActive.current || (errors && errors.length) || !data) { return; } setAuthData({ @@ -209,8 +223,11 @@ export const AuthorizerBasicAuthLogin: FC<{ .catch(() => { // Autofill is best-effort; ignore unsupported/cancelled ceremonies. }); + }; + + useEffect(() => { return () => { - active = false; + passkeyAutofillActive.current = false; authorizerRef.cancelPasskeyAutofill(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -243,6 +260,7 @@ export const AuthorizerBasicAuthLogin: FC<{ email: otpData.email || ``, phone_number: otpData.phone_number || ``, is_totp: otpData.is_totp || false, + offerWebauthnVerify: otpData.offer_webauthn_verify || false, }} urlProps={urlProps} /> @@ -274,12 +292,13 @@ export const AuthorizerBasicAuthLogin: FC<{ type="text" // Enables WebAuthn passkey autofill: browsers offer discoverable // passkeys inline in this field's autofill dropdown. Paired with - // the loginWithPasskeyAutofill() ceremony started on mount below. + // the loginWithPasskeyAutofill() ceremony started on focus below. autoComplete="username webauthn" value={formData.email_or_phone_number || ''} onChange={e => onInputChange('email_or_phone_number', e.target.value) } + onFocus={startPasskeyAutofill} /> {errorData.email_or_phone_number && (
    diff --git a/src/components/AuthorizerMFASetup.tsx b/src/components/AuthorizerMFASetup.tsx index 20c3361..eecfda2 100644 --- a/src/components/AuthorizerMFASetup.tsx +++ b/src/components/AuthorizerMFASetup.tsx @@ -65,11 +65,16 @@ export const AuthorizerMFASetup: FC<{ // Fired when a method is chosen that this component can't complete on its // own (email/SMS OTP, or TOTP without an enrolment payload). onSetupMethod?: (method: MfaMethod) => void; + // Fired when the user explicitly declines to set up MFA right now. Absent + // when MFA is organization-enforced — the host app must not render this + // component with onSkip set in that case (check config before rendering). + onSkip?: () => void; heading?: string; }> = ({ availableMfaMethods, totpEnrollment, onSetupMethod, + onSkip, heading = 'Add a second step to sign in', }) => { const [selected, setSelected] = useState(null); @@ -213,6 +218,16 @@ export const AuthorizerMFASetup: FC<{ ))}
)} + {onSkip && ( + + )} ); }; diff --git a/src/components/AuthorizerPasskeyLogin.tsx b/src/components/AuthorizerPasskeyLogin.tsx index bb29d1e..ee94ef5 100644 --- a/src/components/AuthorizerPasskeyLogin.tsx +++ b/src/components/AuthorizerPasskeyLogin.tsx @@ -47,7 +47,13 @@ export const AuthorizerPasskeyLogin: FC<{ const [loading, setLoading] = useState(false); const { setAuthData, config, authorizerRef } = useAuthorizer(); - if (!isWebauthnSupported()) { + // When the org enforces MFA, passkey must never be offered as a + // standalone primary-login path - it would let a user skip the org's + // two-factor requirement entirely. The server refuses this independently + // (webauthn_login_verify checks EnforceMFA itself), but the button + // shouldn't invite the attempt in the first place: authenticator methods + // belong after a first factor has identified the user, not before. + if (!isWebauthnSupported() || config.is_mfa_enforced) { return null; } @@ -94,6 +100,16 @@ export const AuthorizerPasskeyLogin: FC<{ } return; } + if (res && !res.access_token) { + // Reachable only if this button somehow rendered despite the + // is_mfa_enforced guard above (e.g. a brief pre-config-load + // window) - the server is the real boundary and just refused to + // issue a token. Don't treat this as a successful login. + setError( + `Additional verification is required. Please sign in with your email and password instead.` + ); + return; + } if (res) { setAuthData({ user: res.user || null, diff --git a/src/components/AuthorizerRoot.tsx b/src/components/AuthorizerRoot.tsx index 7c2e9c8..d06fc86 100644 --- a/src/components/AuthorizerRoot.tsx +++ b/src/components/AuthorizerRoot.tsx @@ -4,13 +4,14 @@ import { AuthToken } from '@authorizerdev/authorizer-js'; import { AuthorizerBasicAuthLogin } from './AuthorizerBasicAuthLogin'; import { useAuthorizer } from '../contexts/AuthorizerContext'; import { StyledWrapper } from '../styledComponents'; -import { Views } from '../constants'; +import { Views, MessageType } from '../constants'; import { AuthorizerSignup } from './AuthorizerSignup'; import type { FormFieldsOverrides } from './AuthorizerSignup'; import { AuthorizerForgotPassword } from './AuthorizerForgotPassword'; import { AuthorizerSocialLogin } from './AuthorizerSocialLogin'; import { AuthorizerPasskeyLogin } from './AuthorizerPasskeyLogin'; import { AuthorizerMagicLinkLogin } from './AuthorizerMagicLinkLogin'; +import { Message } from './Message'; import { createRandomString } from '../utils/common'; import { hasWindow } from '../utils/window'; @@ -32,7 +33,7 @@ export const AuthorizerRoot: FC<{ signupFieldsOverrides }) => { const [view, setView] = useState(Views.Login); - const { config } = useAuthorizer(); + const { config, configLoadError } = useAuthorizer(); const searchParams = new URLSearchParams( hasWindow() ? window.location.search : `` ); @@ -60,7 +61,15 @@ export const AuthorizerRoot: FC<{ urlProps.redirect_uri = urlProps.redirectURL; return ( - + {configLoadError && ( + + )} + {view === Views.Login && ( + + )} {view === Views.Login && } {view === Views.Login && (config.is_basic_authentication_enabled || diff --git a/src/components/AuthorizerSignup.tsx b/src/components/AuthorizerSignup.tsx index ee00475..d45444d 100644 --- a/src/components/AuthorizerSignup.tsx +++ b/src/components/AuthorizerSignup.tsx @@ -168,10 +168,13 @@ export const AuthorizerSignup: FC<{ ) { return; } - if ((formData.given_name || '').trim() === '') { - setErrorData({ ...errorData, given_name: 'First Name is required' }); + if ( + formData.given_name !== null && + (formData.given_name || '').trim() === '' + ) { + setErrorData(prev => ({ ...prev, given_name: 'First Name is required' })); } else { - setErrorData({ ...errorData, given_name: null }); + setErrorData(prev => ({ ...prev, given_name: null })); } }, [formData.given_name]); @@ -182,65 +185,69 @@ export const AuthorizerSignup: FC<{ ) { return; } - if ((formData.family_name || '').trim() === '') { - setErrorData({ ...errorData, family_name: 'Last Name is required' }); + if ( + formData.family_name !== null && + (formData.family_name || '').trim() === '' + ) { + setErrorData(prev => ({ ...prev, family_name: 'Last Name is required' })); } else { - setErrorData({ ...errorData, family_name: null }); + setErrorData(prev => ({ ...prev, family_name: null })); } }, [formData.family_name]); useEffect(() => { if (formData.email_or_phone_number === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Email OR Phone Number is required', - }); + })); } else if ( + formData.email_or_phone_number !== null && !isEmail(formData.email_or_phone_number || '') && !isMobilePhone(formData.email_or_phone_number || '') ) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Invalid Email OR Phone Number', - }); + })); } else { - setErrorData({ ...errorData, email_or_phone_number: null }); + setErrorData(prev => ({ ...prev, email_or_phone_number: null })); } }, [formData.email_or_phone_number]); useEffect(() => { if (formData.password === '') { - setErrorData({ ...errorData, password: 'Password is required' }); + setErrorData(prev => ({ ...prev, password: 'Password is required' })); } else { - setErrorData({ ...errorData, password: null }); + setErrorData(prev => ({ ...prev, password: null })); } }, [formData.password]); useEffect(() => { if (formData.confirmPassword === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, confirmPassword: 'Confirm password is required', - }); + })); } else { - setErrorData({ ...errorData, confirmPassword: null }); + setErrorData(prev => ({ ...prev, confirmPassword: null })); } }, [formData.confirmPassword]); useEffect(() => { if (formData.password && formData.confirmPassword) { if (formData.confirmPassword !== formData.password) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, password: `Password and confirm passwords don't match`, confirmPassword: `Password and confirm passwords don't match`, - }); + })); } else { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, password: null, confirmPassword: null, - }); + })); } } }, [formData.password, formData.confirmPassword]); diff --git a/src/components/AuthorizerVerifyOtp.tsx b/src/components/AuthorizerVerifyOtp.tsx index 4a6c5ae..4ed050d 100644 --- a/src/components/AuthorizerVerifyOtp.tsx +++ b/src/components/AuthorizerVerifyOtp.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react'; -import { VerifyOTPRequest } from '@authorizerdev/authorizer-js'; +import { VerifyOTPRequest, isWebauthnSupported } from '@authorizerdev/authorizer-js'; import '../styles/default.css'; import { ButtonAppearance, MessageType, Views } from '../constants'; @@ -8,6 +8,7 @@ import { StyledButton, StyledFooter, StyledLink } from '../styledComponents'; import { Message } from './Message'; import { TotpDataType } from '../types'; import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner'; +import { IconPasskey } from '../icons/mfa'; interface InputDataType { otp: string | null; @@ -29,7 +30,8 @@ export const AuthorizerVerifyOtp: FC<{ phone_number?: string; urlProps?: Record; is_totp?: boolean; -}> = ({ setView, onLogin, email, phone_number, urlProps, is_totp }) => { + offerWebauthnVerify?: boolean; +}> = ({ setView, onLogin, email, phone_number, urlProps, is_totp, offerWebauthnVerify }) => { const [error, setError] = useState(``); const [successMessage, setSuccessMessage] = useState(``); const [loading, setLoading] = useState(false); @@ -49,6 +51,48 @@ export const AuthorizerVerifyOtp: FC<{ } }, []); + const [webauthnError, setWebauthnError] = useState(``); + const [webauthnLoading, setWebauthnLoading] = useState(false); + const passkeySupported = isWebauthnSupported(); + + // A cancelled ceremony surfaces as NotAllowedError/AbortError (same + // browser behavior AuthorizerPasskeyLogin already handles) - dismiss + // silently and let the user fall back to the code form when one exists. + const isUserDismissed = (e?: { code?: string }): boolean => + e?.code === `NotAllowedError` || e?.code === `AbortError`; + + const onVerifyWithPasskey = async () => { + setWebauthnError(``); + try { + setWebauthnLoading(true); + const { data: res, errors } = await authorizerRef.loginWithPasskey(email); + if (errors && errors.length) { + if (!isUserDismissed(errors[0])) { + setWebauthnError(errors[0]?.message || ``); + } + return; + } + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } + } catch (err) { + if (!isUserDismissed(err as { code?: string })) { + setWebauthnError((err as Error).message); + } + } finally { + setWebauthnLoading(false); + } + }; + const onInputChange = async (field: string, value: string) => { setFormData({ ...formData, [field]: value }); }; @@ -188,6 +232,11 @@ export const AuthorizerVerifyOtp: FC<{ ); } + const showCodeForm = !(offerWebauthnVerify && passkeySupported && !is_totp); + // Passkey is the user's only MFA factor but this browser can't do WebAuthn: + // no TOTP, no OTP was ever dispatched, so the code form below is a dead end. + const passkeyOnlyUnsupported = !!offerWebauthnVerify && !passkeySupported && !is_totp; + return ( <> {successMessage && ( @@ -204,53 +253,105 @@ export const AuthorizerVerifyOtp: FC<{ onClose={isLockedOut ? undefined : onErrorClose} /> )} -

- Please enter the OTP sent to your email or phone number or authenticator -

-
-
-
- - onInputChange('otp', e.target.value)} - disabled={isLockedOut} - /> - {errorData.otp && ( -
{errorData.otp}
- )} - {is_totp && ( - setWebauthnError(``)} + /> + )} + {offerWebauthnVerify && passkeySupported && ( + <> +

+ Verify with your passkey +

+ + + > + + {webauthnLoading ? `Waiting for passkey ...` : `Verify with a passkey`} + + +
+ + )} + {passkeyOnlyUnsupported && ( + + )} + {showCodeForm && ( + <> + {offerWebauthnVerify && passkeySupported && ( +

+ Or enter a code instead +

)} -
-
- - {loading ? `Processing ...` : `Submit`} - -
+

+ Please enter the OTP sent to your email or phone number or authenticator +

+
+
+
+ + onInputChange('otp', e.target.value)} + disabled={isLockedOut} + /> + {errorData.otp && ( +
{errorData.otp}
+ )} + {is_totp && ( + + )} +
+
+ + {loading ? `Processing ...` : `Submit`} + +
+ + )} {setView && ( {!is_totp && + !(offerWebauthnVerify && passkeySupported) && (sendingOtp ? (
Sending ...
) : ( diff --git a/src/contexts/AuthorizerContext.tsx b/src/contexts/AuthorizerContext.tsx index 612f4f1..459979e 100644 --- a/src/contexts/AuthorizerContext.tsx +++ b/src/contexts/AuthorizerContext.tsx @@ -39,7 +39,9 @@ const AuthorizerContext = createContext({ is_multi_factor_auth_enabled: false, is_mobile_basic_authentication_enabled: false, is_phone_verification_enabled: false, + is_mfa_enforced: false, }, + configLoadError: null, user: null, token: null, loading: false, @@ -91,6 +93,7 @@ let initialState: AuthorizerState = { user: null, token: null, loading: true, + configLoadError: null, config: { authorizerURL: '', redirectURL: '/', @@ -112,6 +115,7 @@ let initialState: AuthorizerState = { is_multi_factor_auth_enabled: false, is_mobile_basic_authentication_enabled: false, is_phone_verification_enabled: false, + is_mfa_enforced: false, }, }; @@ -159,6 +163,13 @@ export const AuthorizerProvider: FC<{ const getToken = async () => { const { data: metaRes, errors: metaResErrors } = await authorizer.getMetaData(); + const configLoadError = + metaResErrors && metaResErrors.length ? metaResErrors[0].message : null; + if (configLoadError) { + console.error( + `authorizer-react: failed to load config from ${state.config.authorizerURL}/graphql - ${configLoadError}. Login methods that depend on this config (basic auth, signup, social login, magic link) will not render until it succeeds.` + ); + } try { if (metaResErrors && metaResErrors.length) { throw new Error(metaResErrors[0].message); @@ -178,6 +189,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -206,6 +218,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -221,6 +234,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -303,6 +317,7 @@ export const AuthorizerProvider: FC<{ token: null, loading: false, config: state.config, + configLoadError: state.configLoadError, }; dispatch({ type: AuthorizerProviderActionType.SET_AUTH_DATA, diff --git a/src/types/index.ts b/src/types/index.ts index b8dfd78..5a05bf0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,7 @@ export type AuthorizerConfig = { is_multi_factor_auth_enabled: boolean; is_mobile_basic_authentication_enabled: boolean; is_phone_verification_enabled: boolean; + is_mfa_enforced: boolean; }; export type AuthorizerState = { @@ -29,6 +30,7 @@ export type AuthorizerState = { token: AuthToken | null; loading: boolean; config: AuthorizerConfig; + configLoadError?: string | null; }; export type AuthorizerProviderAction = { @@ -58,10 +60,12 @@ export type AuthorizerContextPropsType = { is_multi_factor_auth_enabled: boolean; is_mobile_basic_authentication_enabled: boolean; is_phone_verification_enabled: boolean; + is_mfa_enforced: boolean; }; user: null | User; token: null | AuthToken; loading: boolean; + configLoadError?: string | null; logout: () => Promise; setLoading: (data: boolean) => void; setUser: (data: null | User) => void; @@ -75,6 +79,7 @@ export type OtpDataType = { email?: string; phone_number?: string; is_totp?: boolean; + offer_webauthn_verify?: boolean; }; export type TotpDataType = {