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
+23 -12
View File
@@ -4,6 +4,20 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
// The trusted-origin resolver reads the brands table; books.partner.example
// is the one registered brand host in these tests.
const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args),
}))
function registerBrandHost(host: string | null) {
resolveBrandResultByHostMock.mockImplementation(async (candidate: string) => ({
brand: host !== null && candidate === host ? { domain: host } : null,
lookupFailed: false,
}))
}
registerBrandHost('books.partner.example')
import { createClient } from '@/lib/supabase/server'
import {
requireFlowInitiator,
@@ -90,7 +104,6 @@ describe('requireFlowInitiator', () => {
// Provider redirect URIs are pinned to the canonical host while sessions
// are per host: a white-label user reaches the callback signed out and
// must be sent to THEIR brand login, where the session already exists.
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example')
sessionWith(null)
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1', {
@@ -143,10 +156,10 @@ describe('buildLoginRedirect', () => {
vi.unstubAllEnvs()
})
it('falls back to the request origin when NEXT_PUBLIC_APP_URL is unset', () => {
it('falls back to the request origin when NEXT_PUBLIC_APP_URL is unset', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
const response = buildLoginRedirect(
const response = await buildLoginRedirect(
new Request('http://localhost:3000/api/extensions/woocommerce/return?success=1&user_id=abc'),
)
@@ -156,36 +169,34 @@ describe('buildLoginRedirect', () => {
)
})
it('uses a recorded origin only when it is the canonical host or a registered white-label host', () => {
it('uses a recorded origin only when it is the canonical host or a registered white-label host', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.se')
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example')
const request = new Request('https://app.example.se/api/extensions/stripe/callback?code=1&state=2')
const brand = buildLoginRedirect(request, 'https://books.partner.example')
const brand = await buildLoginRedirect(request, 'https://books.partner.example')
expect(new URL(brand.headers.get('location') ?? '').origin).toBe('https://books.partner.example')
// A stored value that is no longer (or never was) registered must not
// become a redirect target: the allowlist is the only authority.
const unknown = buildLoginRedirect(request, 'https://evil.example')
const unknown = await buildLoginRedirect(request, 'https://evil.example')
expect(new URL(unknown.headers.get('location') ?? '').origin).toBe('https://app.example.se')
const canonical = buildLoginRedirect(request, 'https://app.example.se')
const canonical = await buildLoginRedirect(request, 'https://app.example.se')
expect(new URL(canonical.headers.get('location') ?? '').origin).toBe('https://app.example.se')
})
it('keeps a callback that arrived on a registered brand host on that host', () => {
it('keeps a callback that arrived on a registered brand host on that host', async () => {
// NEXT_PUBLIC_APP_URL used to win over the request origin here, dragging
// a brand-domain callback to the canonical login. The allowlisted request
// host is the fallback now; an unregistered host still collapses.
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.se')
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example')
const onBrand = buildLoginRedirect(
const onBrand = await buildLoginRedirect(
new Request('https://books.partner.example/api/extensions/stripe/callback?code=1&state=2'),
)
expect(new URL(onBrand.headers.get('location') ?? '').origin).toBe('https://books.partner.example')
const onUnknown = buildLoginRedirect(
const onUnknown = await buildLoginRedirect(
new Request('https://evil.example/api/extensions/stripe/callback?code=1&state=2'),
)
expect(new URL(onUnknown.headers.get('location') ?? '').origin).toBe('https://app.example.se')
+7 -1
View File
@@ -73,9 +73,15 @@ describe('Turnstile integration contract', () => {
expect(login).toMatch(
/signInWithPassword\([\s\S]*?options: captchaTokenOptions\(passwordCaptchaToken\)/,
)
// The reset flow moved server-side (brands-table host resolution,
// 2026-09-07): the captcha token must travel to
// POST /api/auth/password-reset, and that route must forward it into
// the GoTrue resetPasswordForEmail call.
expect(login).toMatch(
/resetPasswordForEmail\([\s\S]*?captchaTokenOptions\(resetCaptchaToken\)/,
/fetch\('\/api\/auth\/password-reset'[\s\S]*?captchaTokenOptions\(resetCaptchaToken\)/,
)
const resetRoute = readRepoFile('app/api/auth/password-reset/route.ts')
expect(resetRoute).toMatch(/resetPasswordForEmail\([\s\S]*?captchaToken/)
expect(login).toContain('action="accounted_login"')
expect(login).toContain('action="accounted_password_reset"')
+9 -4
View File
@@ -90,12 +90,17 @@ export function redactUserId(id: string | null | undefined): string {
* never dragged to the canonical login), or the request origin itself on a
* self-hosted deployment with no NEXT_PUBLIC_APP_URL.
*/
export function buildLoginRedirect(request: Request, returnOrigin?: string | null): Response {
export async function buildLoginRedirect(
request: Request,
returnOrigin?: string | null,
): Promise<Response> {
const current = new URL(request.url)
// A login bounce carries no token, so a failed brands lookup degrades to
// the canonical host rather than failing the callback.
const appOrigin = returnOrigin
? resolveTrustedAppOrigin(returnOrigin)
? await resolveTrustedAppOrigin(returnOrigin, { onLookupFailure: 'canonical' })
: process.env.NEXT_PUBLIC_APP_URL
? resolveRequestAppOrigin(request)
? await resolveRequestAppOrigin(request, { onLookupFailure: 'canonical' })
: current.origin
const next = `${current.pathname}${current.search}`
const login = new URL('/login', appOrigin)
@@ -135,7 +140,7 @@ export async function requireFlowInitiator(
return {
ok: false,
reason: 'no_session',
response: buildLoginRedirect(request, options.returnOrigin),
response: await buildLoginRedirect(request, options.returnOrigin),
}
}
+124 -50
View File
@@ -1,62 +1,129 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args),
}))
import {
BrandLookupFailedError,
buildPasswordResetRedirectTo,
getCanonicalAppOrigin,
requestHost,
resolveRequestAppOrigin,
resolveTrustedAppOrigin,
} from '../trusted-app-origin'
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
const ORIGINAL_WHITELABEL_DOMAINS = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
const REGISTERED = new Set(['portal.brand.test', 'books.partner.test'])
const ORIGINAL_ENV = {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
VERCEL_URL: process.env.VERCEL_URL,
VERCEL_BRANCH_URL: process.env.VERCEL_BRANCH_URL,
}
function restoreEnv() {
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
describe('trusted application origins', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
delete process.env.VERCEL_URL
delete process.env.VERCEL_BRANCH_URL
resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({
brand: REGISTERED.has(host) ? { domain: host } : null,
lookupFailed: false,
}))
})
afterEach(() => {
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
afterEach(restoreEnv)
if (ORIGINAL_WHITELABEL_DOMAINS === undefined) {
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
} else {
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = ORIGINAL_WHITELABEL_DOMAINS
}
})
it('uses an exact registered white-label host over HTTPS', () => {
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test, books.partner.test'
expect(resolveTrustedAppOrigin('https://portal.brand.test')).toBe(
it('uses an exact registered brand host over HTTPS', async () => {
expect(await resolveTrustedAppOrigin('https://portal.brand.test')).toBe(
'https://portal.brand.test',
)
expect(resolveTrustedAppOrigin('PORTAL.BRAND.TEST.')).toBe(
expect(await resolveTrustedAppOrigin('PORTAL.BRAND.TEST.')).toBe(
'https://portal.brand.test',
)
expect(resolveBrandResultByHostMock).toHaveBeenCalledWith('portal.brand.test')
})
it('rejects spoofed, credential, wildcard, and non-default-port hosts', () => {
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test,*.wildcard.test'
it('rejects spoofed, credential, suffix, and non-default-port hosts', async () => {
for (const candidate of [
'https://portal.brand.test.attacker.test',
'https://portal.brand.test@attacker.test',
'https://child.wildcard.test',
'https://child.portal.brand.test',
'https://portal.brand.test:444',
]) {
expect(resolveTrustedAppOrigin(candidate), candidate).toBe(
expect(await resolveTrustedAppOrigin(candidate), candidate).toBe(
'https://app.accounted.test',
)
}
})
it('falls back to the canonical origin when the request host is not registered', () => {
expect(resolveTrustedAppOrigin('https://unregistered.test')).toBe(
it('falls back to the canonical origin when the host is not registered', async () => {
expect(await resolveTrustedAppOrigin('https://unregistered.test')).toBe(
'https://app.accounted.test',
)
expect(await resolveTrustedAppOrigin(null)).toBe('https://app.accounted.test')
})
it('does not consult the registry for the canonical host itself', async () => {
expect(await resolveTrustedAppOrigin('app.accounted.test')).toBe(
'https://app.accounted.test',
)
expect(resolveBrandResultByHostMock).not.toHaveBeenCalled()
})
it('refuses with BrandLookupFailedError when the brands lookup fails', async () => {
resolveBrandResultByHostMock.mockResolvedValue({ brand: null, lookupFailed: true })
await expect(resolveTrustedAppOrigin('https://portal.brand.test')).rejects.toBeInstanceOf(
BrandLookupFailedError,
)
await expect(resolveTrustedAppOrigin('https://portal.brand.test')).rejects.toMatchObject({
code: 'TRANSIENT_ERROR',
status: 503,
})
// The canonical host never consults the registry, so it is unaffected.
expect(await resolveTrustedAppOrigin('app.accounted.test')).toBe('https://app.accounted.test')
// Redirect-only callers opt into the canonical fallback explicitly.
expect(
await resolveTrustedAppOrigin('https://portal.brand.test', { onLookupFailure: 'canonical' }),
).toBe('https://app.accounted.test')
})
it('lets a local canonical trust other local hosts and ports on the same scheme', async () => {
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
expect(await resolveTrustedAppOrigin('localhost:3001')).toBe('http://localhost:3001')
expect(await resolveTrustedAppOrigin('http://127.0.0.1:3000')).toBe('http://127.0.0.1:3000')
expect(await resolveTrustedAppOrigin('lane.localhost:3002')).toBe('http://lane.localhost:3002')
expect(resolveBrandResultByHostMock).not.toHaveBeenCalled()
// A hosted canonical grants nothing to local hosts.
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
expect(await resolveTrustedAppOrigin('localhost:3001')).toBe('https://app.accounted.test')
})
it("trusts this deployment's own Vercel hostnames, but no other *.vercel.app", async () => {
process.env.VERCEL_URL = 'erp-base-abc123-team.vercel.app'
process.env.VERCEL_BRANCH_URL = 'erp-base-git-feature-team.vercel.app'
expect(await resolveTrustedAppOrigin('erp-base-abc123-team.vercel.app')).toBe(
'https://erp-base-abc123-team.vercel.app',
)
expect(await resolveTrustedAppOrigin('https://erp-base-git-feature-team.vercel.app')).toBe(
'https://erp-base-git-feature-team.vercel.app',
)
expect(await resolveTrustedAppOrigin('https://someone-else.vercel.app')).toBe(
'https://app.accounted.test',
)
expect(resolveTrustedAppOrigin(null)).toBe('https://app.accounted.test')
})
it('normalises the canonical URL to its origin and has a local safe fallback', () => {
@@ -67,46 +134,53 @@ describe('trusted application origins', () => {
expect(getCanonicalAppOrigin()).toBe('http://localhost:3000')
})
it('validates the request URL and ignores a spoofed forwarded host', () => {
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
it('reads the forwarded host first, then Host, then the request URL', () => {
expect(
requestHost(
new Request('https://internal/api/x', {
headers: { host: 'internal', 'x-forwarded-host': 'portal.brand.test' },
}),
),
).toBe('portal.brand.test')
expect(
requestHost(new Request('https://internal/api/x', { headers: { host: 'books.partner.test' } })),
).toBe('books.partner.test')
expect(requestHost(new Request('https://portal.brand.test/api/x'))).toBe('portal.brand.test')
})
const trusted = new Request('https://portal.brand.test/api/company/members/invite', {
headers: { 'x-forwarded-host': 'attacker.test' },
})
const spoofed = new Request('https://attacker.test/api/company/members/invite', {
it('resolves a request through the registry and ignores an unregistered forwarded host', async () => {
const registered = new Request('https://internal/api/company/members/invite', {
headers: { 'x-forwarded-host': 'portal.brand.test' },
})
const spoofed = new Request('https://portal.brand.test/api/company/members/invite', {
headers: { 'x-forwarded-host': 'attacker.test' },
})
expect(resolveRequestAppOrigin(trusted)).toBe('https://portal.brand.test')
expect(resolveRequestAppOrigin(spoofed)).toBe('https://app.accounted.test')
expect(await resolveRequestAppOrigin(registered)).toBe('https://portal.brand.test')
expect(await resolveRequestAppOrigin(spoofed)).toBe('https://app.accounted.test')
})
})
describe('password reset callback', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({
brand: REGISTERED.has(host) ? { domain: host } : null,
lookupFailed: false,
}))
})
afterEach(() => {
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
afterEach(restoreEnv)
if (ORIGINAL_WHITELABEL_DOMAINS === undefined) {
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
} else {
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = ORIGINAL_WHITELABEL_DOMAINS
}
})
it('keeps a registered brand callback on the brand domain', () => {
expect(buildPasswordResetRedirectTo('https://portal.brand.test')).toBe(
it('keeps a registered brand callback on the brand domain', async () => {
expect(await buildPasswordResetRedirectTo('portal.brand.test')).toBe(
'https://portal.brand.test/auth/callback?next=/reset-password',
)
})
it('uses the allowlisted canonical callback for an unknown browser origin', () => {
expect(buildPasswordResetRedirectTo('https://attacker.test')).toBe(
it('uses the canonical callback for an unknown host', async () => {
expect(await buildPasswordResetRedirectTo('attacker.test')).toBe(
'https://app.accounted.test/auth/callback?next=/reset-password',
)
})
@@ -27,8 +27,9 @@ const PRODUCTION_SUPABASE_HOST = 'pwxtzglxptnnvjrpixpg.supabase.co'
// (docs/WHITELABEL.md step 1).
//
// This is an owner-approved production classification, not an auth callback
// allowlist. Do not derive it from NEXT_PUBLIC_WHITELABEL_DOMAINS, which can
// also contain demo, pilot, or self-hosted domains.
// registry. Do not derive it from the brands table (the registry auth links
// resolve against, lib/domains/trusted-app-origin.ts), which can also hold
// demo, pilot, or staging-only brands.
const CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS = new Set([
'acount.accounted.se',
'amnas.accounted.se',
+154 -32
View File
@@ -1,5 +1,66 @@
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
@@ -44,17 +105,22 @@ function parseHost(value: string | null | undefined): ParsedHost | null {
}
}
function registeredWhiteLabelHosts(): Set<string> {
const configured = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
if (!configured) return new Set()
const hosts = configured
.split(',')
.map((value) => parseHost(value))
.filter((value): value is ParsedHost => value !== null && value.port === '')
.map(({ hostname }) => hostname)
return new Set(hosts)
/**
* 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
}
/**
@@ -69,45 +135,101 @@ export function getCanonicalAppOrigin(): string {
}
/**
* Resolve a browser origin or request host to an application origin.
* Resolve a request host (or browser origin) to an application origin.
*
* The canonical app host is always trusted. Additional hosts must be exact
* entries in NEXT_PUBLIC_WHITELABEL_DOMAINS. Wildcards and suffix matching are
* intentionally unsupported: auth links may never follow an attacker-chosen
* Host header. Registered white-label domains are always upgraded to HTTPS.
* 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 function resolveTrustedAppOrigin(candidate: string | null | undefined): string {
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 === normalizeHostname(canonical.hostname)) {
if (parsed.hostname === canonicalHostname && parsed.port === canonical.port) {
return canonicalOrigin
}
if (!registeredWhiteLabelHosts().has(parsed.hostname)) {
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}` : ''}`
}
// A non-default port is not a registered hosted domain, even when its
// hostname matches. URL normalisation represents :443 as an empty 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
return `https://${parsed.hostname}`
}
if (deploymentOwnHostnames().has(parsed.hostname)) {
return `https://${parsed.hostname}`
}
/** Resolve an API request to a trusted application origin. */
export function resolveRequestAppOrigin(request: Request): string {
const requestOrigin = parseHttpOrigin(request.url)?.origin
return resolveTrustedAppOrigin(requestOrigin)
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)}`
}
/**
* Build a GoTrue password recovery callback on a registered application host.
* Unknown browser origins fall back to the canonical application URL.
* 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 buildPasswordResetRedirectTo(browserOrigin: string): string {
return `${resolveTrustedAppOrigin(browserOrigin)}/auth/callback?next=/reset-password`
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`
}