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:
co-authored by
Claude Fable 5.1
parent
7a30f623ba
commit
d29a5bda14
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user