Files
accounted/lib/domains/trusted-app-origin.ts
MattssonandClaude Fable 5.1 d29a5bda14 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>
2026-09-07 15:12:22 +02:00

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`
}