Skip to content

feat: SPA auth flows: PKCE callbacks, password recovery, TOTP MFA and step-up - #101

Open
JOY (JOY) wants to merge 4 commits into
ccr-2d7e26ff-ad5z1yfrom
feat/spa-auth-flows
Open

JOY (JOY) wants to merge 4 commits into
ccr-2d7e26ff-ad5z1yfrom
feat/spa-auth-flows

Conversation

@JOY

@JOY JOY (JOY) commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #32

Stacked on #91 (the Playwright harness this PR's tests run in), so the base is #91's branch and the diff shows only #32. Merge #91 first; GitHub then retargets this PR to main. After member invites (#125, #132) and crove-extract landed, the PR was rebased and squashed into one commit. A second commit covers the inbound address rotation, which #142 made R3.

Bug fixed on the way

The bundled Supabase Auth built email links as http://localhost:8080/verify?.... The GOTRUE_MAILER_URLPATHS_* defaults are resolved against API_EXTERNAL_URL's origin and drop /auth/v1, and the SPA served those links as unknown routes. So sign-up confirmation and password recovery did not work on self-host once AUTH_AUTOCONFIRM=false. docker-compose.yml now sets the four URL paths to /auth/v1/verify.

What changes

SPA (apps/app)

  • auth-js uses the PKCE flow. settleCallback() runs once at startup, before the router reads the URL. It:
    • explains a failed link by its known error code only (a link's error_description is attacker-written text);
    • explains a link opened in another browser;
    • sends a recovery link to /reset-password, even if Supabase Auth redirected elsewhere;
    • removes the one-time code and the error parameters from the URL and history.
  • /reset-password: new password screen. A user with a verified factor enters a code first, so a recovery link alone does not get past MFA.
  • Settings: two-factor card (TOTP) with QR code and setup key, verify and disable. Abandoned setups are cleaned up.
  • /mfa: after a password sign-in, a user with a verified factor enters the code. Every data route redirects there until the session is aal2. This guard is in the SPA only; the kernel side is Kernel: refuse aal1 tokens from users who have a verified MFA factor #126.
  • Step-up (policy P11, feat: enforce session status and fresh MFA per ADR-0028 (self-host) #90): a prompt appears whenever one of these answers STEP_UP_REQUIRED: approving, inviting a member (feat: member invites with consent #125), changing a member's role or removing a member (feat(kernel): change member roles and remove members #115), and rotating the inbound address (R3 since fix(kernel): rate-limit inbound address rotation and require step-up #142). The prompt:
    • names the pending action;
    • asks for a fresh code, or points to enabling two-factor;
    • repeats the refused request exactly as sent, so an approval prompted on one revision gets STALE_REVISION if someone saved another meanwhile;
    • can be cancelled. Cancelling a removal or a rotation clears its error, so a code entered later never runs it.

Compose: AUTH_STEP_UP now defaults to required, as #90 planned once these screens existed. off is still accepted on a local URL only. A local user therefore has to enable two-factor before the first approval or membership change, and the app walks them through it.

e2e: the stack runs with email confirmation on, set in e2e/compose.e2e.yml. That file also raises the email rate limit for repeated local runs, and gives orgs an inbound address (INBOUND_DOMAIN; nothing delivers to it). Tests read links from Mailpit and compute TOTP codes (RFC 6238, e2e/lib/totp.ts).

  • auth.spec.ts:
    • Confirmation: the callback signs in, reopening the used link shows the error, and a forged link error shows only the generic message.
    • Password recovery.
    • TOTP:
      • enrollment, with a wrong code refused;
      • the sign-in challenge at aal1, with data routes blocked, then aal2 after the code;
      • recovery for an MFA user asks for the code first.
    • Tokens: an expired access token is refreshed on reload, and sign-out revokes the refresh token (400 on reuse).
    • Managing members, across two browsers:
      1. The owner's invite asks for two-factor first and works after enabling it.
      2. The member accepts the link.
      3. The owner promotes them to owner.
      4. The new co-owner, with no second factor yet, meets the prompt on a role change, enables two-factor and removes the first owner.
    • Inbound address: rotating without a second factor meets the prompt. After enabling two-factor, the rotation gives a new address.
  • inbox.spec.ts: signs up through the confirmation email. Approving meets the step-up prompt (which names the approval), then the user enables two-factor and approves. The audit rows show the denied attempt (STEP_UP_REQUIRED, aal1) and the approval (aal2, mfaAt set).

Docs: plan (login section, T2, step-up line, Decision 6 spike marked pass), the Vietnamese page, and the README configuration row.

Tests

Not in this PR

🤖 Generated with Claude Code

https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
crove Ready Ready Preview Oct 16, 2026 1:43am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 98bdc02b-6311-426d-ac15-a69b9d3ec27f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements multi-factor authentication (MFA) using TOTP, integrating it into the sign-in, password recovery, and R3 approval flows. It also updates the configuration for Supabase Auth and adds comprehensive E2E tests. The review feedback highlights several areas for improvement, including adding defensive null checks to prevent runtime errors in the MFA components, refining state management in the authentication page to handle React's StrictMode correctly, and improving error handling for promise rejections in the password reset flow.

Comment thread apps/app/src/components/two-factor.tsx Outdated
Comment thread apps/app/src/components/two-factor.tsx
Comment thread apps/app/src/components/two-factor.tsx
Comment thread apps/app/src/routes/auth.tsx
Comment thread apps/app/src/routes/reset-password.tsx

Copy link
Copy Markdown
Contributor Author

Security review (session)

The Security review (agent) job still needs ANTHROPIC_API_KEY (#95), so this session ran its own independent review. It covered feat/spa-auth-flows against #91's branch at b4ec0de. The fixes are in 38c4cd6.

Verdict: approve with nits. Nothing blocking. The findings below are fixed in this PR or moved to an issue.

Fixed in 38c4cd6

  • Step-up retry used page state, not the refused request. The approval's step-up prompt rebuilt the request from the page when the code was entered. If someone saved a new revision while the approver was in their authenticator app, the retry would approve a revision the approver never clicked Approve on, and STALE_REVISION could not catch it. The prompt now repeats the refused request (approve.variables, same idempotency key), so that case gets STALE_REVISION. Adding a member also retries the email and role that were sent.
  • Prompt did not say what would run. It now names the pending action ("Đang chờ: …") and has a cancel button that drops it.
  • Link error text shown as is. auth-js fills error.message from a link's error_description, so /login?error_description=Call+… showed attacker-written text in the official alert. React escapes it, so this was phishing text, not XSS. Only mapped error codes are explained now; anything else gets a fixed message. A new e2e assertion covers this.
  • One-time code left in the URL. The code and error parameters are now removed from the URL and history after startup.
  • Docs overstated the guard. The plan now says the aal2 guard for users with a verified factor lives in the SPA only.

Follow-up issue (kernel, outside this PR)

  • Kernel: refuse aal1 tokens from users who have a verified MFA factor #126: the kernel accepts aal1 tokens from users who have a verified factor for everything except R3. Someone with the password or a recovery link can call /api directly for reads and R1/R2 commands. This predates this PR. GoTrue already requires aal2 for password or email changes and for factor enrollment and removal.

Checked and fine

  • redirectTo and emailRedirectTo are fixed same-origin paths. GOTRUE_URI_ALLOW_LIST is ${PUBLIC_URL}/**. No URL is built from query parameters.
  • Recovery cannot bypass MFA. GoTrue v2.197.0 requires aal2 to update the password when MFA is on (internal/api/user.go), and /reset-password asks for the code first.
  • The TOTP secret and QR code live only in React state, as a data-URI image allowed by the CSP. Codes only travel in POST bodies.
  • Email links come from API_EXTERNAL_URL. The /auth/v1 proxy drops Host and X-Forwarded-Host, so GOTRUE_MAILER_URLPATHS_* cannot be hijacked through headers.
  • Step-up is checked before the idempotency key is claimed, so a refused request stores no key. Cancelling a removal resets it.
  • AUTH_STEP_UP=required is stricter than before. off is refused unless PUBLIC_URL is local (config.ts).
  • All error text renders as React children. The e2e files contain only example.com addresses and test passwords.

Re-validated locally: lint, typecheck, unit 106 passed (5 skipped), docs:check, and all 6 Playwright tests on a fresh stack with compose.e2e.yml.


Generated by Claude Code

@JOY

Copy link
Copy Markdown
Contributor Author

Heads-up from #113: kernel.inbound.rotate becomes R3 in #142 (stacked on #141), so the settings page's "Đổi địa chỉ email nhận hóa đơn" action will answer STEP_UP_REQUIRED without a fresh second factor. When this PR wires StepUpPrompt into approvals and member changes, please include the rotation confirmation (RotateInboundAddress in apps/app/src/routes/settings.tsx) and rerun it with the same idempotency key it already keeps.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor Author

Done in b31537b. RotateInboundAddress now handles STEP_UP_REQUIRED with the same StepUpPrompt as the member controls (enroll={false}, since the two-factor card is on the same page). The prompt names the action, "đổi địa chỉ email nhận hóa đơn". After a fresh code it repeats rotate.mutate({ idempotencyKey }) with the key the component already keeps until success. Cancelling the confirmation resets the mutation, so a code entered later rotates nothing.

It is not covered by e2e yet. Rotation is still R2 on main, and the compose stack sets no INBOUND_DOMAIN, so the control is not rendered there. Lint, typecheck and the app build pass. Whichever of #101 and #142 merges second will need a small rebase in settings.tsx, because both touch the confirmation.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Rebased onto #91, which is now on main at #186 and carries the ported #189 fix for the duplicate migration version. Head is 397f579, squashed again. Adaptations to what landed on main meanwhile:

Local, on a fresh compose stack with compose.e2e.yml, all 7 Playwright tests pass. Lint, typecheck, unit tests, docs:check and the app build pass too. The images use main's digest-pinned bases; this container used cached tags because Docker Hub rate-limited the pull.


Generated by Claude Code

…18)

Adds the e2e workspace package and an e2e workflow that builds the image,
starts docker compose on fresh volumes and runs Playwright through MVP exit
criterion 1: sign up, create the first org, upload the sample e-invoice, wait
for the proposal, approve it, check the vendor, document and entry lists and
the Excel export, then read the vendor, purchase document, balanced journal
entry and audit rows from the database as the superuser.

The ci job installs the e2e package too, so pnpm typecheck covers it, and
pnpm lint now includes e2e/. Plan T9 and exit criterion 4 updated.

Closes #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
… browser language

#206 (#47) picks the SPA language from the saved choice, then navigator.languages,
and Playwright's Chromium reports en-US, so the Inbox test would see the English
screens. Pinning the context locale to vi-VN keeps the test on the default locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
… step-up (#32)

Rebased onto main with member invites, owner confirmations (#119), the
sessions screen (#147, #178) and MFA_REQUIRED (#126), so the earlier
commits of this PR are squashed into one.

- auth-js uses PKCE. settleCallback() runs before the router: it explains
  failed links by known error codes only (a link's error_description is
  attacker-written), notes a link opened in another browser, sends
  recovery links to /reset-password, and removes the one-time code and
  error parameters from the URL and history.
- /reset-password asks for the TOTP code first when the user has a
  factor; /mfa is the sign-in challenge, and every data route waits for
  aal2. MFA_REQUIRED from the kernel (#126) now sends the user to /mfa
  instead of showing an error; /mfa signs out through the shared sign-out
  that revokes the Crove session first (#178).
- Settings: two-factor card (TOTP enroll, verify, disable).
- Step-up (P11): approving, inviting, changing a role or removing a member
  (inside #119's confirmation) and rotating the inbound address show a
  prompt on STEP_UP_REQUIRED that names the pending action, asks for a
  fresh code or points to enabling two-factor, and repeats the refused
  request as sent (an approval prompted on one revision gets
  STALE_REVISION if someone saved another meanwhile).
- Compose: GOTRUE_MAILER_URLPATHS_* include the /auth/v1 prefix (email
  links were broken on self-host), and AUTH_STEP_UP defaults to required.
- e2e with email confirmation on (compose.e2e.yml, Mailpit, RFC 6238
  TOTP, an inbound address for the rotation): confirmation and reused
  links, a forged link error, recovery, TOTP enrollment and the sign-in
  challenge, refresh and sign-out, the invite, owner confirmation, role
  change and removal step-up flows across two browsers, the rotation
  step-up, and the Inbox approval with its denied and aal2 audit rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
#217 limits email-sending auth requests to 5 per client address per hour and one
per recipient a minute, in the proxy. The e2e tests sign up and recover from the
runner's single address, often right after mailing the same recipient, so the
stack raises both limits and Supabase Auth's deployment-wide budget, keeping the
10-client ratio the kernel enforces at start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ

Copy link
Copy Markdown
Contributor Author

Rebased on main at #218 (c135cc0), on top of #91. Two changes from main needed more than a conflict fix:

Local, with #191 on top: lint, typecheck, docs check and unit tests (375) pass, and the full Playwright suite on the compose stack passes 10/10 (the 6 auth tests here, #91's Inbox test and #191's 3).


Generated by Claude Code

This branch was successfully deployed

1 active deployment
Preview — c74cf7a3 Deployed Oct 16, 2026 by vercel[bot]
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.

2 participants