Skip to content

feat(mfa): behavior redesign — withheld-token first-time setup, WebAuthn/Email/SMS as MFA factors, lockout + admin recovery#686

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

feat(mfa): behavior redesign — withheld-token first-time setup, WebAuthn/Email/SMS as MFA factors, lockout + admin recovery#686
lakhansamani wants to merge 20 commits into
mainfrom
feat/enforce-mfa-passkey-gate

Conversation

@lakhansamani

@lakhansamani lakhansamani commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Redesigns Authorizer's MFA behavior on top of the just-merged EnforceMFA/passkey-gate work (#685):

  • Token withholding: a first-time optional-MFA offer no longer issues a token immediately then offers setup — the token is withheld until the user completes a method or explicitly skips, matching how enforced MFA already worked. Applies uniformly to password login, passkey login, signup, and OAuth/social login.
  • WebAuthn/passkey as a disableable MFA factor (--disable-webauthn-mfa), folded into the EnableMFA derivation alongside TOTP/Email-OTP/SMS-OTP.
  • Email/SMS OTP as real, individually-enrollable MFA methods — new email_otp_mfa_setup/sms_otp_mfa_setup mutations, with verify_otp now marking enrollment complete. Previously-existing login-time OTP branches (which fired for any optional-MFA user with a phone/email, no enrollment check at all) now require a verified enrollment first.
  • effectiveMFAEnabled: IsMultiFactorAuthEnabled is no longer written as an inferred/persisted default at signup or login — it's either an explicit user/admin choice or computed live from server config, every call.
  • skip_mfa_setup reworked from bearer-token to MFA-session-cookie authentication (no bearer token exists yet under the withheld-token model).
  • User-initiated lockout (lock_mfa, refused when a verified Email/SMS OTP fallback exists) and admin recovery (reset_mfa on the existing _update_user mutation — clears all MFA state and deletes every enrolled factor across all 7 storage backends).
  • OAuth/social login gated identically to password login (Google, GitHub, etc.) — verified against Auth0's actual documented behavior rather than assumed; enterprise SSO (SAML) and the routine session-refresh endpoint are untouched.

Design doc: docs/superpowers/specs/2026-07-14-mfa-behavior-redesign-design.md (local only, gitignored in this repo).
Plan: docs/superpowers/plans/2026-07-14-mfa-behavior-redesign-backend.md.

Process

Built via subagent-driven development: 8 plan tasks, each implemented and independently reviewed (task-scoped spec + quality gate); a final whole-branch review across all 9 commits (opus) found no Critical issues but 2 Important findings, both fixed and re-reviewed:

  • WebAuthn-only enforced MFA silently bypassed password Login()/SignUp() when TOTP was disabled — closed by broadening the gate to run whenever any MFA method is available, not just when TOTP specifically is.
  • Email/SMS OTP were offered on the first-time setup screen but structurally uncompletable there (the enrollment mutations required a bearer token that doesn't exist yet in the withheld state) — closed by adding a cookie-authenticated fallback, proven end-to-end (withheld offer → cookie-authenticated setup → verify → token issued).

5 additional Minor findings (OAuth method-hint parity, defensive event-ordering, a weakened test assertion, missing SMS coverage, a small refactor) were also fixed and re-reviewed.

Known follow-up (not blocking)

Verified-MFA-factor detection (authenticatorVerified) doesn't yet fold in Email/SMS-OTP for webauthn.go (any login channel) or for login.go in the cross-channel case (e.g. email-primary login with SMS-OTP enrolled as the MFA method) — an enrolled user in these cases is routed to re-enroll instead of verify. Confirmed non-security-bypassing (the token is still withheld either way); worth a follow-up ticket to fold Email/SMS-OTP into every authenticatorVerified computation site, matching what the OAuth gate now does.

Test plan

  • go build ./..., go vet ./... clean
  • internal/service, internal/integration_tests, internal/http_handlers, internal/config, internal/graph, internal/cookie — all passing
  • Every task + every fix independently reviewed against its own diff, with security-critical tasks (token withholding, lockout, OAuth gate, the two Important-finding fixes) reviewed on a stronger model with independent re-tracing rather than trusting the implementer's own account
  • Manual/browser verification of the real OAuth provider round-trip (no HTTP-level OAuth test double exists in this codebase; the gate's decision logic is covered by TestEvaluateMFAGateForOAuth, but the actual /oauth_callback/:provider redirect behavior with a live Google/GitHub login has not been driven end-to-end)
  • Frontend (authorizer-js, authorizer-react) not yet updated — this PR is backend-only

Update: proto/gRPC/REST parity

The redesign above shipped GraphQL-only. Added as follow-up commits on this same branch:

  • Extended AuthResponse, Meta, User proto messages and admin's UpdateUserRequest with the fields the GraphQL layer already had (should_offer_*_mfa_*, is_mfa_enforced, has_skipped_mfa_setup_at, mfa_locked_at, reset_mfa).
  • Added 4 new RPCs (SkipMfaSetup, LockMfa, EmailOtpMfaSetup, SmsOtpMfaSetup) with REST routes auto-generated via the existing gRPC-gateway, mirroring VerifyOtp's exact pattern — pure transport wiring, zero internal/service changes.
  • Also fixed Meta.is_webauthn_enabled, which predated --disable-webauthn-mfa and was hardcoded true.
  • End-to-end gRPC tests for all 4 new RPCs, both auth modes where applicable (bearer token and MFA-session-cookie).
  • Known gap noticed, not fixed (pre-existing, unrelated to this PR): AuthResponse.should_offer_mfa_setup (the deprecated, unqualified field) has no proto counterpart either.

signup.go only set IsMultiFactorAuthEnabled when EnforceMFA was on or
the caller passed it explicitly - a bare signup on a server with MFA
available but not enforced left the flag unset, so resolveMFAGate's
first condition (userMFAEnabled) was always false and the optional-
setup-with-skip offer never triggered for anyone.

Now defaults to true whenever EnableMFA is true (TOTP, or email/SMS
OTP with its provider configured) and the caller didn't explicitly
opt in or out. EnforceMFA and an explicit caller-supplied value both
still take precedence, matching existing behavior.
signup.go's default only applies at account creation, so users who
signed up before it shipped (or before MFA was configured at all)
were permanently stuck with IsMultiFactorAuthEnabled unset - the
offer-with-skip flow silently never triggered for them, forever.

Added ensureMFADefaultSet, called from both login.go's password path
and webauthn.go's passkey primary-login path, right before either one
reads the flag: if it was never explicitly set and MFA is available
server-wide, default it to true and persist it on that login. Same
end state as a fresh signup, triggered by the user's own next login
instead of a bulk migration across every DB backend. An explicit
false (opt-out) and EnforceMFA's own forcing are both untouched.
Reverts 7fb42b6 and ce5914e: signup no longer writes a default
IsMultiFactorAuthEnabled, and login/webauthn no longer backfill one on
existing users. Effective MFA state is now always computed from live
config, never persisted as an inferred default.
…h resolveMFAGate

mfaGateOfferSetup renamed to mfaGateOfferAll and moved into the
token-withhold group, matching mfaGateBlockVerify/mfaGateBlockEnroll.
WebauthnLoginVerify no longer hand-rolls its own EnforceMFA-only check
-- passkey primary login now goes through the same 5-way gate password
login does, so an optional-MFA user isn't silently exempted from the
first-time setup offer just because they logged in with a passkey.

Adds should_offer_webauthn_mfa_setup to AuthResponse (schema regen) --
needed for the offer response, since ShouldOfferEmailOtpMfaSetup/
ShouldOfferSmsOtpMfaSetup are Task 6's job but WebAuthn's own flag
isn't. should_offer_mfa_setup is deprecated in place (still read by
skip_mfa_setup_test.go) rather than deleted, since it's never written
anymore once PendingTOTPOffer is gone.

Also fixes a crash: webauthn.go's offer case unconditionally generated
a TOTP enrollment, unlike its sibling block cases which guard on
EnableTOTPLogin -- panicked on nil AuthenticatorProvider whenever TOTP
login was disabled server-wide.
skip_mfa_setup now authenticates via the MFA session cookie plus
email/phone_number (same pattern as verify_otp), not a bearer token --
none exists yet when the offer screen withholds it. Issues the
previously-withheld access token on success.
Adds MFALockedAt, lock_mfa (MFA-session-cookie-authenticated, refused
when a verified OTP fallback exists), and reset_mfa on the existing
_update_user admin mutation -- clears lock/opt-in/skip state and
deletes all enrolled authenticators/passkeys, landing the user back
on first-time setup on next login.
Adds email_otp_mfa_setup/sms_otp_mfa_setup (bearer-token authenticated,
send a code and create an unverified Authenticator row) and marks
that row verified on a successful verify_otp. Retrofits login.go's
previously-ungated email/SMS-OTP-as-MFA branches to require a
verified enrollment, not just config+contact-info presence -- closing
the gap where every optional-MFA user with a phone number was
silently OTP-challenged without ever choosing that method.
A brand-new signup with MFA available now has its token withheld and
is offered the first-time setup screen, matching login/passkey --
previously SignUp always issued a token immediately regardless of MFA.

Guarded behind EnableMFA && EnableTOTPLogin, mirroring login.go's own
guard around the same resolveMFAGate/generateTOTPEnrollment call:
AuthenticatorProvider is nil whenever EnableTOTPLogin is off, so
calling generateTOTPEnrollment unconditionally would panic on a
webauthn-only-enforced-MFA signup.

Also fixes three pre-existing tests whose fixtures assumed SignUp
never creates MFA artifacts and always returns AuthResponse.User:
- mfa_gate_login_test.go: addVerifiedAuthenticator now upserts instead
  of relying on AddAuthenticator's create-only-if-absent semantics,
  since signUpUser's own SignUp call (cfg.EnableMFA=true) already
  leaves an unverified TOTP row behind via the new gate.
- mfa_service_availability_test.go, admin_reset_mfa_test.go: look the
  signed-up user up by email instead of reading SignUpResponse.User,
  which the withheld-token gate path doesn't set (matching login.go's
  own mfaGateOfferAll/BlockEnroll responses).
Consumer social logins (Google/GitHub/etc.) now go through the same
resolveMFAGate decision as password/passkey login before the browser
session cookie is set -- matches verified Auth0 behavior (MFA policy
applies on top of social connections, not exempted). Enterprise SSO
(saml_sp.go) and Session()'s routine token refresh are untouched.
login.go and signup.go only ran resolveMFAGate when isTOTPLoginEnabled
was also true, so a WebAuthn-only enforced-MFA server (EnableTOTPLogin
off) skipped the gate entirely and issued tokens unconditionally to
unenrolled users -- no offer, no enforcement. WebauthnLoginVerify was
already correctly gated on such a server (Task 3), which is what
exposed the gap.

Broaden both guards to isMFAEnabled / EnableMFA alone, matching
webauthn.go's pattern: call resolveMFAGate unconditionally and
condition only the TOTP-specific parts of the response (enrollment
generation, TOTP screen/fields) on EnableTOTPLogin. WebAuthn/email/SMS
offer flags are now set from config in mfaGateBlockEnroll too, not
just mfaGateOfferAll, so a WebAuthn-only server has something
actionable to offer.
EmailOTPMFASetup/SMSOTPMFASetup required a bearer token, but a user in
the token-withheld first-time MFA offer has none yet -- only the MFA
session cookie. Add a resolveOTPSetupCaller helper that tries the
bearer token first (unchanged) and falls back to the cookie +
email/phone_number pattern already used by SkipMFASetup/LockMFA.

Schema gains an optional OtpMfaSetupRequest (email/phone_number,
mirrors SkipMfaSetupRequest minus state) on both mutations.

Also confirmed webauthn_registration_options/verify have the same
bearer-token-only gap -- out of scope here, left for a follow-up.
…, M3)

authenticatorVerified in oauth_mfa_gate.go only checked TOTP+WebAuthn, so a
user whose only enrolled factor was a verified email/SMS OTP authenticator
got routed to mfaGateOfferAll (re-offered setup) instead of mfaGateBlockVerify
(asked to verify what they already have). Fold email/SMS OTP verification
into the same computation login.go already uses for its own enrollment
checks, and list email_otp/sms_otp in the mfa_methods hint on both the
verify and offer/enroll branches.

Also tighten mfaGateBlockVerify's test assertion: Contains(...,"totp") never
actually distinguished it from the offer/enroll branches. Enable every other
configured MFA method in that subtest and assert mfa_methods is exactly
"totp" -- the only value the verify branch (lists what's verified) can
produce vs. what an offer/enroll branch (lists everything configured) would.
SetState wrote the code/authToken bridge entry before EvaluateMFAGateForOAuth
ran. Not exploitable as-is (code is never disclosed to the browser on a
withheld redirect, so the entry just self-expires unreachable), but there's
no reason to write it before we know the login actually proceeds. Move the
write to after withheld=false; derivation of code/codeChallenge/nonce/
authorizeRedirectURI is unchanged, and code/nonce are still needed earlier
to build params for the non-withheld response.
TestSMSOTPMFASetupViaMfaSessionCookie stopped after the setup leg (asserted
the unverified Authenticator row was created, never verified, never checked
a token was issued). Extend it to close the same withheld-login ->
cookie-authenticated-setup -> verify_otp -> token-issued chain the
email-OTP twin already proves.
…M4b)

login.go's local generateOTP closure, resend_otp.go's local generateOTP
closure, and otp_mfa_setup.go's generateAndStoreOTP method all did the same
thing: generate an OTP, HMAC it, UpsertOTP, restore plaintext on the
returned struct. Verified this before consolidating. All three call sites
(login's email/phone-verification and TOTP-alternative branches, resend,
and OTP MFA setup) now call the one generateAndStoreOTP method. No behavior
change -- the closures' internal error logging was redundant, every call
site already logs on error.
Was hardcoded true from before --disable-webauthn-mfa existed, so an
operator disabling WebAuthn as an MFA factor still saw it advertised
as available via the meta query.
GraphQL-only MFA redesign (skip_mfa_setup, lock_mfa, email/sms_otp_mfa_setup,
plus AuthResponse/Meta/User MFA fields, UpdateUserRequest.reset_mfa) had no
REST/gRPC surface. Mirrors VerifyOtp's pattern exactly: public=true RPCs,
service layer resolves the caller itself (MFA session cookie and/or bearer
token) rather than the auth interceptor.

- types.proto: AuthResponse +4 should_offer_* fields, Meta +is_mfa_enforced,
  User +has_skipped_mfa_setup_at/+mfa_locked_at
- admin.proto: UpdateUserRequest +reset_mfa
- authorizer.proto: SkipMfaSetup, LockMfa, EmailOtpMfaSetup, SmsOtpMfaSetup
  RPCs + request/response messages
- gen/: regenerated (buf.build BSR token in this env is expired; generated
  locally with version-pinned protoc-gen-go/-go-grpc/-grpc-gateway/-openapiv2
  matching the committed buf.gen.yaml's remote plugin versions, diff verified
  scoped to only the touched proto files)
- handlers: 4 new gRPC handlers, Meta/UpdateUser/projectUser/
  projectAuthResponse updated to carry the new fields through
- tests: end-to-end gRPC coverage for SkipMfaSetup, LockMfa, and
  UpdateUser.reset_mfa
974cc85 added these gRPC handlers with only go build/go vet as
coverage. Port the two auth-mode scenarios already proven at the
GraphQL layer (otp_mfa_setup_test.go) onto the gRPC transport: MFA
session cookie + email/phone_number fallback, and ordinary bearer
token, plus the unauthenticated-caller rejection case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant