feat(auth): add WorkOS two-factor authentication with backup codes - #1024
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
4 Skipped Deployments
|
- Fold the security actions onto a generic action framework (ActionFailure, runAction, validateActionInput) and delete the security-only error/result types and rate-limit copy - Replace the signed recovery token with a short-lived httpOnly cookie set when a challenge is issued; drop the URL/prop plumbing - Spend a backup code only after the WorkOS factors are deleted so a failed removal never burns it - Issue backup codes server-side on the first successful TOTP sign-in (new "enrolled" result) instead of trusting a client enrollment flag - Share the pending-step state machine between login and signup via useAuthFlow + AuthPendingStep; forms take a step and report results - Model the settings enrollment as scanning/verified, gate passkey actions through one step-up wrapper, parse widgets responses with zod - Split the dev playground and keep its session in sync with the flow 🤖 Generated with [Claude Code](https://claude.com/claude-code)
WorkOS only offers passkey sign-in through its hosted AuthKit UI, so the custom login cannot own that flow. Ship TOTP two-factor with backup codes only and drop everything passkey-related: - Login: passkey button, redirect action, and "passkey" auth method - Settings: passkeys section, email step-up dialog, elevated-access cookie - Server: WebAuthn client, Widgets API client, passkey and challenge actions, their schemas, rate limit, and analytics events - UI package: AuthPasskeyButton, PasskeysSettings, StepUpVerification - Design system: passkeys section and step-up demo; playground trimmed Also tighten the remaining contracts: props that were optional only for the passkey variants are required now, the backup-code path is wired for signup as well, and unused error codes are gone. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Resolve the settings section conflicts by keeping both the new Security section and main's Appearance section, and renumber the backup-codes migration from 0084 to 0090 behind main's migrations. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
React Doctor found 8 new issues in 4 files · 8 warnings · score 76 / 100 (Needs work) · 12 fixed · vs 8 warnings
Reviewed by React Doctor for commit |
🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ialog, drop factor names
…behind the enrollment dialog
There was a problem hiding this comment.
5 issues found across 92 files
Confidence score: 2/5
apps/dashboard/src/constants/security.tsandapps/dashboard/src/utils/ratelimit.ts: MFA throttling can be bypassed or misapplied because the browser-wide identity cookie is overwritten across accounts and the account limit remains IP-scoped, allowing fresh windows from different IPs. Bind limits to a stable account/challenge bucket and avoid relying on a shared browser cookie for account identity.apps/dashboard/src/lib/auth/backup-codes.ts:consumeBackupCodeinvalidates a recovery code before WorkOS factor operations complete, so transient failures can permanently remove a usable fallback and leave the user unable to recover. Consume the code only after all security mutations succeed.apps/dashboard/src/lib/auth/mfa-actions.ts: The social enrollment handoff is consumed before rate limiting or factor creation succeeds, causing retryable sign-in flows to be lost on throttling or transient WorkOS failures. Check the limiter first and consume the handoff only after successful enrollment.apps/dashboard/src/components/auth/login-content.tsx: The pending social challenge cookie remains after successful MFA, so revisiting the MFA login URL can reopen an already-consumed challenge until expiry. Clear the pending challenge flow whenverifyMfaCodeActioncompletes successfully.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/dashboard/src/utils/ratelimit.ts">
<violation number="1" location="apps/dashboard/src/utils/ratelimit.ts:228">
P2: The account-scoped MFA limit is still IP-scoped, so an attacker can obtain a fresh five-attempt window from each IP. Use a stable account/challenge bucket separately from any IP-based bucket.</violation>
</file>
<file name="apps/dashboard/src/constants/security.ts">
<violation number="1" location="apps/dashboard/src/constants/security.ts:9">
P1: This browser-wide cookie can mis-bind MFA rate limiting: starting another account's flow overwrites the identity used by `verifyMfaCodeAction`, allowing fresh challenges for the original account to evade its per-account guess limit. Bind the rate-limit identity to the submitted MFA flow, or reject any cookie/challenge mismatch.</violation>
</file>
<file name="apps/dashboard/src/components/auth/login-content.tsx">
<violation number="1" location="apps/dashboard/src/components/auth/login-content.tsx:18">
P2: The social challenge cookie is never cleared after successful MFA, so revisiting the `/login?mfa=…` URL reopens an already-consumed challenge until expiry. Clear the pending challenge flow when `verifyMfaCodeAction` completes successfully.</violation>
</file>
<file name="apps/dashboard/src/lib/auth/backup-codes.ts">
<violation number="1" location="apps/dashboard/src/lib/auth/backup-codes.ts:87">
P1: `consumeBackupCode` burns a code before WorkOS factor operations. Move consumption after successful factor removal and other security mutations so transient WorkOS failures do not permanently invalidate the user’s fallback code.</violation>
</file>
<file name="apps/dashboard/src/lib/auth/mfa-actions.ts">
<violation number="1" location="apps/dashboard/src/lib/auth/mfa-actions.ts:272">
P2: This consumes the social enrollment handoff before rate limiting or factor creation succeeds, so rate limits and transient WorkOS failures discard a retryable sign-in flow. Check the limiter first and consume the handoff only with a retry-safe enrollment completion.</violation>
</file>
Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.
Re-trigger cubic
| * mid-MFA, so backup-code recovery and per-account rate limits can be bound | ||
| * to it without the client ever handling the user's identity. | ||
| */ | ||
| export const MFA_ATTEMPT_COOKIE = "notra_mfa_attempt"; |
There was a problem hiding this comment.
P1: This browser-wide cookie can mis-bind MFA rate limiting: starting another account's flow overwrites the identity used by verifyMfaCodeAction, allowing fresh challenges for the original account to evade its per-account guess limit. Bind the rate-limit identity to the submitted MFA flow, or reject any cookie/challenge mismatch.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/constants/security.ts, line 9:
<comment>This browser-wide cookie can mis-bind MFA rate limiting: starting another account's flow overwrites the identity used by `verifyMfaCodeAction`, allowing fresh challenges for the original account to evade its per-account guess limit. Bind the rate-limit identity to the submitted MFA flow, or reject any cookie/challenge mismatch.</comment>
<file context>
@@ -0,0 +1,37 @@
+ * mid-MFA, so backup-code recovery and per-account rate limits can be bound
+ * to it without the client ever handling the user's identity.
+ */
+export const MFA_ATTEMPT_COOKIE = "notra_mfa_attempt";
+/**
+ * Carries the pending WorkOS credentials from the social callback to the
</file context>
| ): Promise<boolean> { | ||
| const rows = await db | ||
| .update(userBackupCodes) | ||
| .set({ usedAt: new Date() }) |
There was a problem hiding this comment.
P1: consumeBackupCode burns a code before WorkOS factor operations. Move consumption after successful factor removal and other security mutations so transient WorkOS failures do not permanently invalidate the user’s fallback code.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/lib/auth/backup-codes.ts, line 87:
<comment>`consumeBackupCode` burns a code before WorkOS factor operations. Move consumption after successful factor removal and other security mutations so transient WorkOS failures do not permanently invalidate the user’s fallback code.</comment>
<file context>
@@ -0,0 +1,97 @@
+): Promise<boolean> {
+ const rows = await db
+ .update(userBackupCodes)
+ .set({ usedAt: new Date() })
+ .where(
+ and(
</file context>
| POSTHOG_EVENTS.MFA_VERIFIED | ||
| ); | ||
| yield* Effect.promise(clearMfaAttemptCookie); | ||
| yield* Effect.promise(clearAllPendingMfaFlows); |
There was a problem hiding this comment.
Completing one MFA challenge calls clearAllPendingMfaFlows, which deletes every pending MFA handoff cookie in the browser. If another social MFA flow is open in a second tab—or password MFA completes while a social handoff is pending—that unrelated flow loses its credentials and cannot continue. Clear only the handoff associated with the completed authentication attempt.
| const value = | ||
| typeof next === "function" ? next(backupCodesRef.current) : next; | ||
| backupCodesRef.current = value; | ||
| updateBackupCodes(value); |
There was a problem hiding this comment.
updateBackupCodes calls itself instead of the React state setter. Resetting the playground, issuing or regenerating codes, redeeming recovery, or removing MFA therefore recurses until a stack overflow and leaves the auth-flow playground unusable.
| updateBackupCodes(value); | |
| setBackupCodes(value); |
There was a problem hiding this comment.
1 existing issue remains and no new issues found across 18 files (changes from recent commits).
Confidence score: 3/5
- In
apps/dashboard/src/lib/auth/backup-codes.ts, the error-handling path can restore a backup code after WorkOS has already applied the factor change, allowing the single-use code to authorize another change; ensure rollback does not make the code reusable when the external operation may have succeeded.
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 43 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| ok: false as const, | ||
| message: CONFIRM_ERROR_FALLBACK, | ||
| })); | ||
| setIsPending(false); |
There was a problem hiding this comment.
React Doctor · react-doctor/no-loading-flag-reset-outside-finally (warning)
This resets a loading/busy flag only on the success path: if the awaited call rejects the reset never runs and the flag stays stuck truthy (a spinner that never stops, a button disabled forever). Move the reset into a finally block, or mirror it on every catch, so it clears on rejection too.
Fix → A trailing setLoading(false) after an await never runs if the awaited call rejects, so the flag stays stuck truthy; reset it in a finally block (or mirror the reset on every catch) so it clears on both paths.
Description
Adds two-factor authentication (TOTP) with backup codes on top of WorkOS AuthKit, fully inside our own login and settings UI. Passkeys were explored on this branch and removed again: WorkOS only offers passkey sign-in through its hosted AuthKit page, which does not fit the custom login.
Sign-in
mfa_challenge/mfa_enrollmentresults: a 6-digit challenge step, or an inline enrollment step (QR code, manual setup key andotpauth://URI, verification) when an organization requires MFA.user_backup_codes, SHA-256 hashed, single use). Redeeming one removes the WorkOS TOTP factors and signs the user in again with the password still in the form. The code is only spent after the factors are gone, so a WorkOS failure never burns it./loginwith the pending challenge; the user is identified for recovery via a short-lived httpOnly cookie instead of a client-visible token.Settings → Security (new section)
Shared components (
packages/ui, also in the design system under#auth-mfa)InputOTPon Base UI'sOTPField(no new dependency),TotpCodeInput,MfaChallengeForm,MfaEnrollmentForm,TotpEnrollmentPanel,BackupCodesPanel,TwoFactorSettings,StepTransition.useAuthFlow+AuthPendingStepown the pending-step state for login and signup; forms take astepand report results viaonResult.Server
lib/actions/{errors,run-action,validate-input}.ts(ActionFailurewith optionalcode,runAction,ActionResultas a discriminated union); organization actions moved onto it.lib/auth/mfa.ts,backup-codes.ts,short-lived-cookie.ts,security-actions.ts.Dev tooling
/design-system/auth-flow: dev-only playground with an in-browser stand-in for WorkOS (real RFC 6238 TOTP codes, live authenticator widget, event log).scripts/create-dev-auth-account.ts(idempotent,--reset-mfa) andscripts/mfa-smoke.ts(enroll → verify → sign in → challenge →authenticateWithTotp).Migration
0084_user_backup_codesadds theuser_backup_codestable.Requires
authenticateWithPasswordnever returnsmfa_challenge(verified with the smoke script against the test environment).Screenshot/Recording (if applicable)
See the design system section
Auth · Two-factor(/design-system#auth-mfa) and the playground at/design-system/auth-flow.Checklist
🤖 Generated with Claude Code