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
@@ -7,6 +7,10 @@ vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/branding/resolve', () => ({
|
||||
resolveBrandResultByHost: vi.fn(async () => ({ brand: null, lookupFailed: false })),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
function mockUserClient(opts: {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin'
|
||||
import {
|
||||
BrandLookupFailedError,
|
||||
resolveRequestAppOrigin,
|
||||
} from '@/lib/domains/trusted-app-origin'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -87,17 +90,30 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Trusted-origin resolution, not request.url: behind a proxy request.url
|
||||
// can be an internal origin (dead confirmation links on self-hosted), and
|
||||
// auth links may never follow an attacker-chosen host. Registered
|
||||
// white-label hosts pass through so the mail carries the right brand.
|
||||
// Trusted-origin resolution against the brands table, not request.url:
|
||||
// behind a proxy request.url can be an internal origin (dead confirmation
|
||||
// links on self-hosted), and auth links may never follow an
|
||||
// attacker-chosen host. Registered brand hosts pass through so the mail
|
||||
// carries the right brand.
|
||||
//
|
||||
// flow=email_change marks the callback so the stock GoTrue links (verified
|
||||
// on the GoTrue host, returned here via redirect_to with ?message=, ?error=
|
||||
// or ?code= instead of a token_hash) land on the email-change status page
|
||||
// rather than the silent login bounce. The Send Email hook preserves this
|
||||
// query on its token_hash links, so both link styles share the marker.
|
||||
const origin = resolveRequestAppOrigin(request)
|
||||
let origin: string
|
||||
try {
|
||||
origin = await resolveRequestAppOrigin(request)
|
||||
} catch (err) {
|
||||
if (!(err instanceof BrandLookupFailedError)) throw err
|
||||
// Refuse rather than send a wrong-brand confirmation pair; nothing has
|
||||
// been claimed or sent yet, so a retry is clean.
|
||||
log.warn('email change refused: brand lookup failed', { userId: user.id })
|
||||
return NextResponse.json(
|
||||
{ error: 'Tillfälligt fel. Försök igen om en stund.' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
// Cross-instance gate (migration 20260903083000). The pending-state read
|
||||
// above is not atomic: two concurrent requests (two tabs, a retried fetch)
|
||||
|
||||
Reference in New Issue
Block a user