* 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>
236 lines
8.8 KiB
TypeScript
236 lines
8.8 KiB
TypeScript
import 'server-only'
|
|
|
|
import { resolveBrandResultByHost } from '@/lib/branding/resolve'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
// Which application origin an auth link (password reset, invite, email
|
|
// change, signup confirmation) may point at.
|
|
//
|
|
// The brands table is the ONLY registry of white-label hosts. Until
|
|
// 2026-09-07 this module kept a second copy in NEXT_PUBLIC_WHITELABEL_DOMAINS,
|
|
// a comma-separated env var compiled into the browser bundle so the login
|
|
// page could validate the reset callback before calling GoTrue. Every new
|
|
// brand then had to be added to the brands row, the env var, the Supabase
|
|
// redirect allowlist AND a redeploy; two partners shipped with the env var
|
|
// stale, and their reset mails went out canonical-branded to the canonical
|
|
// host. A registry that has to be remembered is a registry that drifts, so
|
|
// the copy is gone: the routes resolve the request host against the brands
|
|
// table server-side, and the browser no longer carries a domain list.
|
|
//
|
|
// The request Host header is an input, never a trust anchor. A host is used
|
|
// only when it is exactly the canonical app host, exactly one of this
|
|
// deployment's own Vercel hostnames, or exactly a registered brand domain.
|
|
// Everything else falls back to NEXT_PUBLIC_APP_URL, so a spoofed header
|
|
// can at most select another host Accounted already serves. GoTrue's own
|
|
// redirect allowlist remains the backstop behind all of this: it matches
|
|
// the FULL redirect_to including the query, with `*` stopping at `.` and
|
|
// `/`, so hosted carries `https://*.accounted.se/auth/callback**` and
|
|
// `https://*.accounted.se/invite/**` (docs/WHITELABEL.md).
|
|
|
|
const log = createLogger('trusted-app-origin')
|
|
|
|
const LOCAL_APP_ORIGIN = 'http://localhost:3000'
|
|
|
|
// Development-only hostnames. When the canonical app URL itself is local,
|
|
// any of these is the same developer machine, so a lane dev server on
|
|
// localhost:3001 keeps receiving its own auth links instead of the port
|
|
// 3000 canonical. Production never has a local canonical, so this branch
|
|
// is unreachable there.
|
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
|
|
|
function isLocalHostname(hostname: string): boolean {
|
|
return LOCAL_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost')
|
|
}
|
|
|
|
/**
|
|
* The brands table could not be read, so the request host cannot be
|
|
* classified. Thrown instead of silently answering with the canonical
|
|
* origin: for a white-label user that answer is a wrong-brand mail whose
|
|
* recovery session lands on a foreign domain, the exact failure this
|
|
* registry exists to prevent. The code maps to the TRANSIENT_ERROR entry
|
|
* (503, retryable) in withRouteContext routes; anonymous routes answer 503
|
|
* themselves, mirroring the signup gate's fail-safe branch.
|
|
*/
|
|
export class BrandLookupFailedError extends Error {
|
|
readonly code = 'TRANSIENT_ERROR'
|
|
readonly status = 503
|
|
|
|
constructor(readonly host: string) {
|
|
super(`brand lookup failed for host ${host}`)
|
|
this.name = 'BrandLookupFailedError'
|
|
}
|
|
}
|
|
|
|
interface ParsedHost {
|
|
hostname: string
|
|
port: string
|
|
}
|
|
|
|
function normalizeHostname(hostname: string): string {
|
|
return hostname.toLowerCase().replace(/\.$/, '')
|
|
}
|
|
|
|
function parseHttpOrigin(value: string | undefined): URL | null {
|
|
if (!value) return null
|
|
|
|
try {
|
|
const url = new URL(value)
|
|
if (!['http:', 'https:'].includes(url.protocol)) return null
|
|
if (url.username || url.password) return null
|
|
return url
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function parseHost(value: string | null | undefined): ParsedHost | null {
|
|
if (!value) return null
|
|
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return null
|
|
|
|
try {
|
|
const url = parseHttpOrigin(
|
|
trimmed.includes('://') ? trimmed : `https://${trimmed}`,
|
|
)
|
|
if (!url) return null
|
|
if (url.pathname !== '/' || url.search || url.hash) return null
|
|
|
|
return {
|
|
hostname: normalizeHostname(url.hostname),
|
|
port: url.port,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The hostnames Vercel assigns to THIS deployment (preview URL and branch
|
|
* alias). Derived from the platform, never enumerated: a preview build
|
|
* answering its own *.vercel.app host may send auth links back to itself,
|
|
* which is what lets signup and reset be tested on a preview at all. Any
|
|
* other *.vercel.app host is somebody else's deployment and stays untrusted.
|
|
* Production deployments serve the canonical and brand hosts, which resolve
|
|
* before this check, so it does not widen anything there.
|
|
*/
|
|
function deploymentOwnHostnames(): Set<string> {
|
|
const hosts = new Set<string>()
|
|
for (const value of [process.env.VERCEL_URL, process.env.VERCEL_BRANCH_URL]) {
|
|
const parsed = parseHost(value)
|
|
if (parsed && parsed.port === '') hosts.add(parsed.hostname)
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
/**
|
|
* Return the configured canonical application origin.
|
|
*
|
|
* Paths, queries, and fragments in NEXT_PUBLIC_APP_URL are deliberately
|
|
* discarded so callers cannot accidentally append auth paths below them.
|
|
*/
|
|
export function getCanonicalAppOrigin(): string {
|
|
const configured = parseHttpOrigin(process.env.NEXT_PUBLIC_APP_URL)
|
|
return configured?.origin ?? LOCAL_APP_ORIGIN
|
|
}
|
|
|
|
/**
|
|
* Resolve a request host (or browser origin) to an application origin.
|
|
*
|
|
* The canonical app host is always trusted. Any other host must be exactly
|
|
* one of this deployment's own Vercel hostnames or exactly a registered
|
|
* brands.domain. Wildcards, suffixes and non-default ports are never
|
|
* accepted. Registered hosts are always upgraded to HTTPS.
|
|
*
|
|
* Throws BrandLookupFailedError when the brands table cannot be read, so
|
|
* the caller refuses (503, retry) rather than sending a wrong-brand link.
|
|
* Callers that only pick a browser redirect target (no token travels in
|
|
* the URL: OAuth return hops, login bounces) pass
|
|
* `onLookupFailure: 'canonical'` and degrade to the canonical host instead;
|
|
* a 500 in the middle of a provider callback would strand the user.
|
|
*/
|
|
export interface ResolveOriginOptions {
|
|
onLookupFailure?: 'throw' | 'canonical'
|
|
}
|
|
|
|
export async function resolveTrustedAppOrigin(
|
|
candidate: string | null | undefined,
|
|
options: ResolveOriginOptions = {},
|
|
): Promise<string> {
|
|
const canonicalOrigin = getCanonicalAppOrigin()
|
|
const canonical = new URL(canonicalOrigin)
|
|
const canonicalHostname = normalizeHostname(canonical.hostname)
|
|
const parsed = parseHost(candidate)
|
|
|
|
if (!parsed) return canonicalOrigin
|
|
|
|
if (parsed.hostname === canonicalHostname && parsed.port === canonical.port) {
|
|
return canonicalOrigin
|
|
}
|
|
|
|
// Local development: a local canonical trusts every local host and port
|
|
// on the same scheme (lane servers on 3001-3003 confirm on themselves).
|
|
if (isLocalHostname(canonicalHostname) && isLocalHostname(parsed.hostname)) {
|
|
return `${canonical.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`
|
|
}
|
|
|
|
if (parsed.hostname === canonicalHostname) return canonicalOrigin
|
|
|
|
// A non-default port is not a hosted domain, even when its hostname
|
|
// matches. URL normalisation represents :443 as an empty port.
|
|
if (parsed.port !== '') return canonicalOrigin
|
|
|
|
if (deploymentOwnHostnames().has(parsed.hostname)) {
|
|
return `https://${parsed.hostname}`
|
|
}
|
|
|
|
const { brand, lookupFailed } = await resolveBrandResultByHost(parsed.hostname)
|
|
if (lookupFailed) {
|
|
if (options.onLookupFailure === 'canonical') {
|
|
log.warn('brand lookup failed; redirect falls back to the canonical origin', {
|
|
host: parsed.hostname,
|
|
})
|
|
return canonicalOrigin
|
|
}
|
|
log.warn('brand lookup failed; refusing to build an auth link for this host', {
|
|
host: parsed.hostname,
|
|
})
|
|
throw new BrandLookupFailedError(parsed.hostname)
|
|
}
|
|
if (!brand) return canonicalOrigin
|
|
|
|
return `https://${normalizeHostname(brand.domain)}`
|
|
}
|
|
|
|
/**
|
|
* The host a request was addressed to, as seen by the public edge: the
|
|
* forwarded host set by Vercel or the reverse proxy, else the Host header.
|
|
* request.url is deliberately not used: behind a proxy it can be an
|
|
* internal origin, which used to yield canonical links on self-hosted
|
|
* multi-brand installations.
|
|
*/
|
|
export function requestHost(request: Request): string | null {
|
|
const forwarded =
|
|
request.headers.get('x-forwarded-host') ?? request.headers.get('host')
|
|
if (forwarded) return forwarded
|
|
return parseHttpOrigin(request.url)?.host ?? null
|
|
}
|
|
|
|
/** Resolve an API request to a trusted application origin. */
|
|
export async function resolveRequestAppOrigin(
|
|
request: Request,
|
|
options: ResolveOriginOptions = {},
|
|
): Promise<string> {
|
|
return resolveTrustedAppOrigin(requestHost(request), options)
|
|
}
|
|
|
|
/**
|
|
* Build a GoTrue password recovery callback on a registered application
|
|
* host. Unknown hosts fall back to the canonical application URL.
|
|
*/
|
|
export async function buildPasswordResetRedirectTo(
|
|
host: string | null | undefined,
|
|
): Promise<string> {
|
|
return `${await resolveTrustedAppOrigin(host)}/auth/callback?next=/reset-password`
|
|
}
|