Skip to content

fix(auth): verify TOTP session-lessly in dual-factor gate#1394

Merged
eskp merged 2 commits into
stagingfrom
fix/oauth-mfa-verify-totp-session-less
May 28, 2026
Merged

fix(auth): verify TOTP session-lessly in dual-factor gate#1394
eskp merged 2 commits into
stagingfrom
fix/oauth-mfa-verify-totp-session-less

Conversation

@eskp
Copy link
Copy Markdown

@eskp eskp commented May 28, 2026

Summary

  • OAuth-with-TOTP sign-in has been broken in production since 2026-05-26 22:17 UTC. requireDualFactor called auth.api.verifyTOTP, which requires either an active Better Auth session or the TWO_FACTOR_COOKIE_NAME cookie. The OAuth-MFA finalize path runs after interceptOauthCallback has destroyed the session and leaves only pending_oauth_mfa, so verifyTOTP threw on every request and every OAuth user with TOTP enrolled got "Invalid authenticator code" regardless of the code they submitted.
  • The fix swaps auth.api.verifyTOTP for a direct read of two_factor.secret plus verifyUserTotp() — the same session-less primitive that strict-signin already uses in production at app/api/auth/strict-signin/route.ts:187. Same RFC 6238 window, same constant-time compare, no behavior change for the 15 callers that already have a session.
  • Adds tests/unit/dual-factor.test.ts with the regression case (valid TOTP + email OTP without a session => ok: true) plus negative cases. The new test would have failed under the broken code path: importing @/lib/auth in a unit-test context pulled in the full Better Auth runtime and threw without a session.

Root cause

The fix-pack PR that landed on 2026-05-25 shipped two halves that contradict each other: the OAuth callback now destroys the Better Auth session before routing to the MFA finalize endpoint, but the dual-factor gate behind that endpoint still verifies TOTP through a code path that requires a session. The bug is identical in shape to a previous strict-signin issue that was already resolved by moving to verifyUserTotp; this PR brings the OAuth-MFA finalize path in line with that pattern.

Impact

17 users have TOTP enrolled in production. Any of them attempting OAuth sign-in over the last ~33 hours hit the bug. Affected users were unblockable except by switching to the email/password sign-in path.

Test plan

  • pnpm type-check passes
  • pnpm test:unit — 5380 pass / 1 skipped, 0 regressions
  • New unit suite at tests/unit/dual-factor.test.ts — 5 tests, all passing
  • npx @biomejs/biome check on changed files: no new diagnostics
  • Post-deploy: re-run the synthetic test from the diagnosis session (cookie-forge pending_oauth_mfa for a TOTP-enrolled user, POST /api/auth/oauth-mfa-finalize with a correct TOTP and a wrong email OTP). Expected: code: "email_code_invalid" (TOTP now passes, email OTP fails) instead of the pre-fix code: "mfa_code_invalid".
  • Post-deploy: confirm one affected user can complete OAuth + TOTP sign-in.

Out of scope (follow-ups)

  • Consolidate MFA verify primitives: extract a shared verifyMfaPair(userId, totpCode, emailOtp, action) helper used by both strict-signin and oauth-mfa-finalize. The original fix-pack shipped two divergent TOTP-verify code paths and they drifted; one was session-required (broke for 33h), one was session-less. P2/Medium.
  • Add an integration test that exercises the full OAuth -> /verify-mfa -> /api/auth/oauth-mfa-finalize flow against a real session-required path (not mocked). The unit test in this PR catches the import-level explosion, but an integration test would catch this class of bug earlier when the contributor only has unit-test coverage in mind.

requireDualFactor called auth.api.verifyTOTP, which requires either an
active Better Auth session or the TWO_FACTOR_COOKIE_NAME cookie. The
OAuth-MFA finalize path runs after interceptOauthCallback has
destroyed the session and leaves only the pending_oauth_mfa cookie, so
verifyTOTP threw for every request and every OAuth-with-TOTP sign-in
failed with "Invalid authenticator code" regardless of the code
submitted. Live in prod since 2026-05-26 22:17 UTC (KEEP-619 PR #1355).

Replace the verifyTOTP call with a direct read of two_factor.secret
plus verifyUserTotp() — the same session-less primitive that
strict-signin already uses in production. Same RFC 6238 window, same
constant-time compare, no behavior change for the 15 callers that
already have a session.

Test: tests/unit/dual-factor.test.ts covers the regression case
(valid TOTP + email OTP without a session ⇒ ok=true) plus
mfa_code_invalid on wrong code, mfa_code_invalid on missing two_factor
row (don't leak enrollment state), email_code_invalid on missing
verifications row, and factors_required on missing codes.

Refs KEEP-619.
Three small follow-ups from the multi-agent review on PR #1394:

- Refresh the requireDualFactor docstring; it still claimed TOTP was
  verified via Better Auth's verifyTOTP endpoint after the fix moved
  that off to verifyUserTotp.
- Assert resetDualFactor is called on the happy path. Without this,
  the rate-limit counter could silently stop clearing on success and
  the suite would still go green.
- The "no two_factor row" test was passing a live TOTP code that was
  never reached; replace with "000000" so the test name and the input
  match what is actually being asserted.
@eskp eskp merged commit 71770c9 into staging May 28, 2026
42 checks passed
@eskp eskp deleted the fix/oauth-mfa-verify-totp-session-less branch May 28, 2026 09:32
@github-actions
Copy link
Copy Markdown

🧹 PR Environment Cleaned Up

The PR environment has been successfully deleted.

Deleted Resources:

  • Namespace: pr-1394
  • All Helm releases (Keeperhub, Scheduler, Event services)
  • PostgreSQL Database (including data)
  • LocalStack, Redis
  • All associated secrets and configs

All resources have been cleaned up and will no longer incur costs.

@github-actions
Copy link
Copy Markdown

ℹ️ No PR Environment to Clean Up

No PR environment was found for this PR. This is expected if:

  • The PR never had the deploy-pr-environment label
  • The environment was already cleaned up
  • The deployment never completed successfully

@eskp eskp mentioned this pull request May 28, 2026
4 tasks
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