fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)

* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS

Password reset, invite, email change and signup links now resolve the
request host against brands.domain server-side. The env var was a second
copy of that registry compiled into the browser; every new brand needed
the row, the env var, the GoTrue allowlist and a redeploy, and two
partners shipped with the env var stale, so their reset mails went out
canonical-branded to the canonical host.

- New POST /api/auth/password-reset: the login page no longer calls
  GoTrue directly, so the browser carries no domain list.
- lib/domains/trusted-app-origin.ts is async and registry-backed; it
  also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so
  previews keep sending links to themselves.
- Signup shares the same resolver instead of following the raw host.
- Docs and .env.example describe the single registry; GoTrue keeps the
  redirect allowlist as backstop (hosted: *.accounted.se wildcard).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): await the async origin resolver in the billing routes merged from main

PR #2370 added resolveRequestAppOrigin callers in billing/checkout and
billing/portal after this branch made the resolver async. Await them and
move their tests from the removed env var to the brands mock; update the
login source-assert test to the server-routed reset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs

Skeptic and CI findings on #2376, one pass:

- A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR,
  503, retryable) instead of falling back to the canonical origin: a
  canonical link is the wrong-brand mail this PR removes. Password reset
  and email change answer 503 themselves; withRouteContext routes map the
  code.
- A local canonical (dev) trusts other local hosts and ports on the same
  scheme, so lane servers on 3001-3003 confirm signups on themselves.
- GoTrue matches the full redirect_to including the query and `*` stops
  at `.` and `/`: docs and decision line now prescribe
  https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**.
- The Turnstile contract test asserts the server-routed reset forwards
  the captcha token (it still asserted the removed browser call).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-07 15:12:22 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 7a30f623ba
commit d29a5bda14
29 changed files with 713 additions and 186 deletions
@@ -8,10 +8,13 @@ const SOURCE = readFileSync(
)
describe('password reset redirect wiring', () => {
it('routes the browser origin through the trusted app-origin resolver', () => {
expect(SOURCE).toContain('buildPasswordResetRedirectTo(window.location.origin)')
expect(SOURCE).not.toContain(
'`${window.location.origin}/auth/callback?next=/reset-password`',
)
it('requests the reset through the server route, never with a browser-built callback', () => {
// POST /api/auth/password-reset resolves the recovery callback against
// the brands table from the request host. The browser must not call
// GoTrue directly with a redirectTo of its own: that is what needed a
// compiled-in domain list and a redeploy per brand.
expect(SOURCE).toContain("fetch('/api/auth/password-reset'")
expect(SOURCE).not.toContain('resetPasswordForEmail(')
expect(SOURCE).not.toContain('/auth/callback?next=/reset-password')
})
})
+20 -5
View File
@@ -34,7 +34,6 @@ import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { buildPasswordResetRedirectTo } from '@/lib/domains/trusted-app-origin'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { OAuthButton } from '@/components/auth/OAuthButton'
import {
@@ -415,12 +414,28 @@ export function LoginClient({
const emailValue = (formData.get('email') as string) || email
try {
const { error } = await supabase.auth.resetPasswordForEmail(emailValue, {
redirectTo: buildPasswordResetRedirectTo(window.location.origin),
...captchaTokenOptions(resetCaptchaToken),
// Server-side reset (POST /api/auth/password-reset): the route resolves
// the recovery callback against the brands table from the request
// host, so the browser carries no domain list and a new brand needs no
// redeploy. GoTrue call, captcha and rate limits are unchanged.
const res = await fetch('/api/auth/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: emailValue,
captchaToken: captchaTokenOptions(resetCaptchaToken).captchaToken ?? null,
}),
})
if (error) {
if (!res.ok) {
const json = await res.json().catch(() => ({}))
const error = {
code: json?.error?.code,
message:
(errorLocale === 'en' ? json?.error?.message_en : json?.error?.message) ??
json?.error?.message,
status: res.status,
}
const kind = classifyAuthError(error)
setFormError({
kind,