Skip to content

feat(mfa): optional MFA setup, passkey as a second factor, and close the EnforceMFA passkey bypass#685

Merged
lakhansamani merged 11 commits into
mainfrom
feat/enforce-mfa-passkey-gate
Jul 14, 2026
Merged

feat(mfa): optional MFA setup, passkey as a second factor, and close the EnforceMFA passkey bypass#685
lakhansamani merged 11 commits into
mainfrom
feat/enforce-mfa-passkey-gate

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Summary

Three layered pieces of MFA work, landed together:

  • Optional MFA with skip: when MFA is available but not org-enforced, a user who hasn't set it up gets logged in immediately and is offered (not forced into) setup, with a Skip action that's remembered. --enforce-mfa keeps working exactly as before — no token until MFA is complete, never skippable.
  • Passkey as a second factor: a user with a registered passkey can verify with it instead of entering a TOTP code, reusing the existing scoped webauthn_login_options/webauthn_login_verify flow and MFA-session-cookie mechanism rather than adding new mutations.
  • Close the EnforceMFA passkey bypass: "Sign in with a passkey" (primary, passwordless login) previously issued a token completely unconditionally, with no EnforceMFA check at all — letting a user skip password + MFA verification entirely. webauthn_login_verify now reuses the same TOTP-verify/enrollment gate the password path already has when MFA is enforced for that user.

Test plan

  • go build ./... and go vet ./... clean
  • TestResolveMFAGate — 9 subtests, pure decision-table coverage
  • TestLoginMFAGateTokenWithholding — 7 subtests covering all gate outcomes through the real login path, including TOTP-only, passkey-only, and dual-enrolled users
  • TestWebauthnLoginVerifyEnforceMFA — 5 subtests covering EnforceMFA on/off, TOTP verified/not-enrolled, and TOTP-disabled-server-wide
  • TestMeta — reflects is_mfa_enforced
  • skip_mfa_setup mutation integration tests, including the auth-before-EnforceMFA ordering fix
  • Full internal/integration_tests + internal/service suites pass with zero regressions

login.go's TOTP branch switches on resolveMFAGate's 5-way decision.
mfaGateBlockVerify/mfaGateBlockEnroll must never reach the AccessToken
code path, but no test exercised the switch wiring itself (only the
pure decision function in mfa_gate_test.go). Add
TestLoginMFAGateTokenWithholding, one subtest per gate outcome, driven
through the real service.Provider.Login via GraphQLProvider.Login.
Verified each block case actually fails the test when its `return` is
removed, then reverted the drill.
Authenticated users can now decline the optional MFA setup prompt
shown at login. Server re-checks EnforceMFA before touching the
caller token, so a client can never forge this call to bypass
org-enforced MFA.

Covers happy path (sets HasSkippedMFASetupAt, quiets next login) and
the EnforceMFA rejection path (FailedPrecondition even with a valid,
freshly-verified TOTP token) via integration test.
An unauthenticated caller got FailedPrecondition when EnforceMFA was
true but Unauthenticated when false, leaking org-wide MFA enforcement
via response code alone with zero credentials required. Check
authentication first; EnforceMFA is still re-checked before any state
mutation, so HasSkippedMFASetupAt is never set while it's true.

Add coverage for a fully credential-less caller under both EnforceMFA
states, proving the reorder closed the leak.
WebauthnLoginVerify issued tokens unconditionally, ignoring EnforceMFA
entirely — a user could always bypass password + MFA verification just
by using their passkey instead. Reuse the same TOTP-verify/enrollment
gate login.go's password path already has, when EnforceMFA is on for a
user who has MFA individually enabled.
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