diff --git a/.env.example b/.env.example index 640a9ea3..0753252d 100644 --- a/.env.example +++ b/.env.example @@ -10,13 +10,6 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-or-secret-key # App base URL (local dev) NEXT_PUBLIC_APP_URL=http://localhost:3000 -# Shared hosted white-label deployments only: exact comma-separated hostnames -# that are registered on this deployment. No wildcards. Invite and auth links -# use a listed request/browser host; every other host falls back to -# NEXT_PUBLIC_APP_URL. Also add each listed host's /auth/callback and /invite/* -# URLs to the Supabase Auth redirect allowlist before deploying it. -# NEXT_PUBLIC_WHITELABEL_DOMAINS=portal.brand-one.example,books.brand-two.example - # Secret for authenticating cron/scheduled requests. # Any non-empty random string for local dev: openssl rand -hex 16 CRON_SECRET=generate-a-random-secret diff --git a/DECISIONS.md b/DECISIONS.md index 85c81639..a1401959 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1636,6 +1636,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen. [2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable". [2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler. +[2026-09-07] Auth-link hosts resolve against the brands table only; NEXT_PUBLIC_WHITELABEL_DOMAINS removed (supersedes 2026-08-18): the env var was a second copy of brands.domain compiled into the browser, so every brand needed four registrations (row, env var, GoTrue allowlist, redeploy) and two partners shipped with it stale (canonical-branded reset mails). Password reset moved to POST /api/auth/password-reset so the server resolves the host; invite, email change and signup share the same resolver, which also trusts this deployment's own VERCEL_URL/VERCEL_BRANCH_URL so previews keep working. A drift check between the copies was rejected: it would be a fifth thing to maintain. GoTrue's redirect allowlist stays as the backstop; hosted carries the wildcards https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/** there (config, not code; GoTrue matches the full URL with query, and * stops at . and /) so only bring-your-own-domain partners need a manual entry. A failed brands lookup refuses with 503 (BrandLookupFailedError) instead of a canonical fallback: a canonical link is a wrong-brand mail for a white-label user, which is the bug this replaces. [2026-09-07] Draft stamp moved to the page margin (absolute + fixed) instead of the reporter's position:fixed corner badge: the 40pt top margin is the only place that is guaranteed empty on every page, and the stamp must not overlap the header title on the right. [2026-09-07] Hyphenation disabled per Text node in the invoice template, not via a global Font.registerHyphenationCallback: the global hook would also change line breaking in årsredovisning, payslips and every report PDF; that is a separate decision. [2026-09-07] Invoice PDF word wrapping keeps a word whole whenever an ink-width estimate says it fits its column (ordinary Swedish compounds of 17 to 25 characters always do); only a wider token (URL, e-mail, reference) gets break points, after separators and where the column is full, with react-pdf's hyphen at the break (a break after "/" prints "/-"; unavoidable in textkit, accepted over a dropped token). A flat character cap was tried first and rejected: it split ordinary compounds at arbitrary positions. Free-text rows and notes are kept on one page only while a rendered-line estimate (chunks counted, 12 lines max, safe for any font) says they fit; past that they may split, because a non-splittable block taller than a page is clipped silently. diff --git a/app/(auth)/login/__tests__/password-reset-redirect.test.ts b/app/(auth)/login/__tests__/password-reset-redirect.test.ts index 869afd0d..06edaf03 100644 --- a/app/(auth)/login/__tests__/password-reset-redirect.test.ts +++ b/app/(auth)/login/__tests__/password-reset-redirect.test.ts @@ -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') }) }) diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx index f7d70f7a..de187e77 100644 --- a/app/(auth)/login/login-client.tsx +++ b/app/(auth)/login/login-client.tsx @@ -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, diff --git a/app/api/account/email/__tests__/route.test.ts b/app/api/account/email/__tests__/route.test.ts index 4e548bf5..f8be8026 100644 --- a/app/api/account/email/__tests__/route.test.ts +++ b/app/api/account/email/__tests__/route.test.ts @@ -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: { diff --git a/app/api/account/email/route.ts b/app/api/account/email/route.ts index 06215ab9..ef8e453c 100644 --- a/app/api/account/email/route.ts +++ b/app/api/account/email/route.ts @@ -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) diff --git a/app/api/auth/password-reset/__tests__/route.test.ts b/app/api/auth/password-reset/__tests__/route.test.ts new file mode 100644 index 00000000..12b53c71 --- /dev/null +++ b/app/api/auth/password-reset/__tests__/route.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { parseJsonResponse } from '@/tests/helpers' + +const resetPasswordForEmailMock = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(async () => ({ + auth: { resetPasswordForEmail: resetPasswordForEmailMock }, + })), +})) + +const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/branding/resolve', () => ({ + resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args), +})) + +import { POST } from '../route' + +function makeRequest(body: unknown, headers: Record = {}): Request { + return new Request('https://internal/api/auth/password-reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }) +} + +const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL + +beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' + resetPasswordForEmailMock.mockResolvedValue({ data: {}, error: null }) + resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({ + brand: host === 'app.testbrand.example' ? { 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 +}) + +describe('POST /api/auth/password-reset', () => { + it('400s on invalid body', async () => { + const res = await POST(makeRequest({ email: 'not-an-email' })) + expect(res.status).toBe(400) + expect(resetPasswordForEmailMock).not.toHaveBeenCalled() + }) + + it('sends the recovery callback on a registered brand host', async () => { + const res = await POST( + makeRequest( + { email: ' Kund@Example.COM ', captchaToken: 'tok' }, + { host: 'internal', 'x-forwarded-host': 'app.testbrand.example' }, + ), + ) + const { body: json } = await parseJsonResponse<{ data: { status: string } }>(res) + + expect(res.status).toBe(200) + expect(json.data.status).toBe('sent') + expect(resetPasswordForEmailMock).toHaveBeenCalledWith('kund@example.com', { + redirectTo: 'https://app.testbrand.example/auth/callback?next=/reset-password', + captchaToken: 'tok', + }) + }) + + it('falls back to the canonical callback for an unregistered host', async () => { + await POST(makeRequest({ email: 'kund@example.com' }, { host: 'attacker.test' })) + + expect(resetPasswordForEmailMock).toHaveBeenCalledWith('kund@example.com', { + redirectTo: 'https://app.accounted.test/auth/callback?next=/reset-password', + }) + }) + + it('503s (fail safe) when the brand lookup errors, without calling GoTrue', async () => { + resolveBrandResultByHostMock.mockResolvedValue({ brand: null, lookupFailed: true }) + + const res = await POST( + makeRequest({ email: 'kund@example.com' }, { host: 'app.testbrand.example' }), + ) + const { body: json } = await parseJsonResponse<{ error: { code: string } }>(res) + + expect(res.status).toBe(503) + expect(json.error.code).toBe('brand_lookup_failed') + expect(resetPasswordForEmailMock).not.toHaveBeenCalled() + }) + + it('maps a GoTrue error to the canonical envelope with its status', async () => { + resetPasswordForEmailMock.mockResolvedValue({ + data: null, + error: { code: 'over_email_send_rate_limit', message: 'rate limit', status: 429 }, + }) + + const res = await POST(makeRequest({ email: 'kund@example.com' }, { host: 'app.accounted.test' })) + const { body: json } = await parseJsonResponse<{ error: { code: string; message: string } }>(res) + + expect(res.status).toBe(429) + expect(json.error.code).toBe('over_email_send_rate_limit') + expect(json.error.message).toBeTruthy() + }) +}) diff --git a/app/api/auth/password-reset/route.ts b/app/api/auth/password-reset/route.ts new file mode 100644 index 00000000..19302c6f --- /dev/null +++ b/app/api/auth/password-reset/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { createClient } from '@/lib/supabase/server' +import { validateBody } from '@/lib/api/validate' +import { + BrandLookupFailedError, + buildPasswordResetRedirectTo, + requestHost, +} from '@/lib/domains/trusted-app-origin' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { createLogger } from '@/lib/logger' + +const log = createLogger('auth-password-reset') + +/** + * POST /api/auth/password-reset: request a password recovery mail. + * + * Moved server-side so the recovery callback is resolved against the brands + * table (lib/domains/trusted-app-origin.ts) instead of a domain list compiled + * into the browser bundle. The login page used to call + * supabase.auth.resetPasswordForEmail directly with a redirectTo it validated + * against NEXT_PUBLIC_WHITELABEL_DOMAINS; every new brand then needed that + * env var updated and a redeploy, and when that was forgotten the mail went + * out canonical-branded to the canonical host. Here the request host decides: + * a registered brand host gets its own callback (and, through the Send Email + * hook, its own brand), everything else gets the canonical one. + * + * Anonymous by design: a user asking for a reset has no session, so no + * withRouteContext / requireAuth. Abuse is bounded exactly as the direct + * GoTrue call was: the forwarded Turnstile token (verified by GoTrue) plus + * GoTrue's own recovery rate limits. A signed-in user may also call this + * (the login page is reachable while signed in); the cookie-backed client + * carries their session and GoTrue behaves the same either way. + * + * The response never says whether the address exists: GoTrue answers 200 for + * unknown addresses and this route passes that through unchanged. + */ + +const PasswordResetSchema = z.object({ + email: z.string().trim().toLowerCase().max(320).pipe(z.string().email()), + captchaToken: z.string().max(4096).nullish(), +}) + +export async function POST(request: Request) { + const validation = await validateBody(request, PasswordResetSchema) + if (!validation.success) return validation.response + const { email, captchaToken } = validation.data + + let redirectTo: string + try { + redirectTo = await buildPasswordResetRedirectTo(requestHost(request)) + } catch (err) { + if (!(err instanceof BrandLookupFailedError)) throw err + // Transient brands-table error: fail safe like /api/auth/signup. A + // canonical fallback here would mail a white-label user a wrong-brand + // link; 503 tells the client to retry instead. + return NextResponse.json( + { + error: { + code: 'brand_lookup_failed', + message: 'Tillfälligt fel. Försök igen om en stund.', + message_en: 'Temporary error. Please try again shortly.', + }, + }, + { status: 503 }, + ) + } + + const supabase = await createClient() + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo, + ...(captchaToken ? { captchaToken } : {}), + }) + + if (error) { + log.warn('resetPasswordForEmail rejected', { status: error.status, code: error.code }) + // Same envelope as /api/auth/signup: the login page feeds it to + // classifyAuthError (keyed on code and HTTP status) and localizes the + // display message through getErrorMessage. + return NextResponse.json( + { + error: { + code: error.code ?? 'auth_error', + message: getErrorMessage(error, { context: 'auth', locale: 'sv' }), + message_en: getErrorMessage(error, { context: 'auth', locale: 'en' }), + }, + }, + { status: error.status && error.status >= 400 ? error.status : 400 }, + ) + } + + return NextResponse.json({ data: { status: 'sent' } }) +} diff --git a/app/api/auth/signup/__tests__/route.test.ts b/app/api/auth/signup/__tests__/route.test.ts index ef0007f0..b4bfb9b9 100644 --- a/app/api/auth/signup/__tests__/route.test.ts +++ b/app/api/auth/signup/__tests__/route.test.ts @@ -16,6 +16,11 @@ vi.mock('@/lib/auth/brand-signup-gate', async (importOriginal) => { } }) +const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/branding/resolve', () => ({ + resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args), +})) + import { POST } from '../route' function makeRequest( @@ -33,6 +38,13 @@ const validBody = { email: 'kund@example.com', password: 'Str0ng!Pass' } beforeEach(() => { vi.clearAllMocks() + process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.se' + // app.testbrand.example is a registered brand host; app.accounted.se is + // the canonical host and never consults the registry. + resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({ + brand: host === 'app.testbrand.example' ? { domain: host } : null, + lookupFailed: false, + })) gateMock.mockResolvedValue({ allowed: true, brand: null, via: 'no_brand' }) signUpMock.mockResolvedValue({ data: { user: { identities: [{ id: 'i1' }] }, session: null }, diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index f2e72eeb..5d2e1b77 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -7,6 +7,7 @@ import { readInviteTokenFromCookieHeader, } from '@/lib/auth/brand-signup-gate' import { safeReturnTo } from '@/lib/auth/safe-return-to' +import { resolveTrustedAppOrigin } from '@/lib/domains/trusted-app-origin' import { getErrorMessage } from '@/lib/errors/get-error-message' import { createLogger } from '@/lib/logger' @@ -81,11 +82,14 @@ export async function POST(request: Request) { } // Confirmation links must land back on the ORIGINATING host (WL-05 brand - // mail resolves its brand from this URL), so build the callback from the - // forwarded host rather than request.url, which can be an internal origin - // behind the proxy. - const proto = request.headers.get('x-forwarded-proto') ?? 'https' - const confirmationCallback = new URL(`${proto}://${host}/auth/callback`) + // mail resolves its brand from this URL). The host is resolved through the + // same registry as every other auth link (canonical, this deployment's + // own Vercel hosts, or a registered brand domain); anything else falls + // back to the canonical origin rather than following the raw header. + const confirmationCallback = new URL( + '/auth/callback', + await resolveTrustedAppOrigin(host), + ) const nextPath = safeReturnTo(validation.data.next ?? null, '/') if (nextPath !== '/') confirmationCallback.searchParams.set('next', nextPath) diff --git a/app/api/billing/__tests__/checkout.test.ts b/app/api/billing/__tests__/checkout.test.ts index 7eefca16..8bfd89f2 100644 --- a/app/api/billing/__tests__/checkout.test.ts +++ b/app/api/billing/__tests__/checkout.test.ts @@ -46,14 +46,23 @@ import { POST } from '../checkout/route' const routeParams = { params: Promise.resolve({}) } +// The trusted-origin resolver reads the brands table; pin one registered +// brand host so the return-URL tests exercise the real resolver logic. +const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/branding/resolve', () => ({ + resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args), +})) + const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL -const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS beforeEach(() => { vi.clearAllMocks() reset() process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' - delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({ + brand: host === 'portal.brand.test' ? { domain: host } : null, + lookupFailed: false, + })) guardSandboxMock.mockResolvedValue(null) requireAuthMock.mockResolvedValue({ user: { id: 'user-1', email: 'u@example.com', is_anonymous: false }, @@ -65,8 +74,6 @@ beforeEach(() => { afterEach(() => { if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl - if (originalWhiteLabelDomains === undefined) delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS - else process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains }) describe('POST /api/billing/checkout', () => { @@ -276,7 +283,6 @@ describe('POST /api/billing/checkout', () => { }) it('returns to a registered white-label host when checkout starts there', async () => { - process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' const { status } = await parseJsonResponse( await checkoutFrom('https://portal.brand.test/api/billing/checkout'), @@ -292,7 +298,6 @@ describe('POST /api/billing/checkout', () => { }) it('falls back to the canonical app for an unregistered or spoofed host', async () => { - process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' const { status } = await parseJsonResponse( await checkoutFrom('https://portal.brand.test.attacker.test/api/billing/checkout'), diff --git a/app/api/billing/__tests__/portal.test.ts b/app/api/billing/__tests__/portal.test.ts index 3082e395..133fb3fd 100644 --- a/app/api/billing/__tests__/portal.test.ts +++ b/app/api/billing/__tests__/portal.test.ts @@ -39,14 +39,23 @@ import { POST } from '../portal/route' const routeParams = { params: Promise.resolve({}) } +// The trusted-origin resolver reads the brands table; pin one registered +// brand host so the return-URL tests exercise the real resolver logic. +const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/branding/resolve', () => ({ + resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args), +})) + const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL -const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS beforeEach(() => { vi.clearAllMocks() reset() process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' - delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({ + brand: host === 'portal.brand.test' ? { domain: host } : null, + lookupFailed: false, + })) guardSandboxMock.mockResolvedValue(null) requireAuthMock.mockResolvedValue({ user: { id: 'user-1', is_anonymous: false }, supabase: {}, error: null }) }) @@ -54,8 +63,6 @@ beforeEach(() => { afterEach(() => { if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl - if (originalWhiteLabelDomains === undefined) delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS - else process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains }) describe('POST /api/billing/portal', () => { @@ -151,7 +158,6 @@ describe('POST /api/billing/portal', () => { }) it('comes back to a registered white-label host when opened there', async () => { - process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' const { status } = await parseJsonResponse( await portalFrom('https://portal.brand.test/api/billing/portal'), @@ -164,7 +170,6 @@ describe('POST /api/billing/portal', () => { }) it('falls back to the canonical app for an unregistered or spoofed host', async () => { - process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' const { status } = await parseJsonResponse( await portalFrom('https://portal.brand.test.attacker.test/api/billing/portal'), diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index 3c3c589d..83e91703 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -120,10 +120,10 @@ export const POST = withRouteContext('billing.checkout', async (request, ctx) => // Return the user to the host they started on. Sessions are per domain, so // sending a white-label user back to the canonical app would land them on a // foreign-branded login with no session. The origin is resolved against the - // registered host allowlist; an unknown or spoofed host falls back to the + // brands table; an unknown or spoofed host falls back to the // canonical app URL. The paths stay fixed: never accept a caller-supplied // return URL here. - const appOrigin = resolveRequestAppOrigin(request) + const appOrigin = await resolveRequestAppOrigin(request) const session = await stripe.checkout.sessions.create({ mode: 'subscription', customer: customerId, diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts index 3fcc97f4..dc493ef6 100644 --- a/app/api/billing/portal/route.ts +++ b/app/api/billing/portal/route.ts @@ -48,7 +48,7 @@ export const POST = withRouteContext('billing.portal', async (request, ctx) => { // Same host the user started on (see billing/checkout): a registered // white-label host stays on its brand, anything else returns to the // canonical app. The path is fixed. - const appOrigin = resolveRequestAppOrigin(request) + const appOrigin = await resolveRequestAppOrigin(request) const portal = await getStripe().billingPortal.sessions.create({ customer: customerId, return_url: `${appOrigin}/settings/billing`, diff --git a/app/api/company/members/invite/__tests__/route.test.ts b/app/api/company/members/invite/__tests__/route.test.ts index 98cd0609..f7d8de82 100644 --- a/app/api/company/members/invite/__tests__/route.test.ts +++ b/app/api/company/members/invite/__tests__/route.test.ts @@ -60,6 +60,13 @@ const brandSenderMock = vi.hoisted(() => ({ })) vi.mock('@/lib/email/brand-sender', () => brandSenderMock) +// The trusted-origin resolver reads the brands table; pin one registered +// brand host so the invite link tests exercise the real resolver logic. +const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/branding/resolve', () => ({ + resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args), +})) + const generateInviteEmailHtmlMock = vi.hoisted(() => vi.fn((data: { inviteUrl: string }) => `

${data.inviteUrl}

`), ) @@ -81,14 +88,16 @@ function post(body: unknown, url = '/api/company/members/invite') { } const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL -const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS beforeEach(() => { vi.clearAllMocks() reset() delete process.env.AUTH_SIGNUPS_DISABLED process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' - delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({ + brand: host === 'portal.brand.test' ? { domain: host } : null, + lookupFailed: false, + })) requireAuthMock.mockResolvedValue({ user: { id: 'user-1', email: 'owner@example.com' }, supabase: {}, @@ -113,12 +122,6 @@ afterEach(() => { delete process.env.AUTH_SIGNUPS_DISABLED if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl - - if (originalWhiteLabelDomains === undefined) { - delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS - } else { - process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains - } }) describe('POST /api/company/members/invite', () => { @@ -331,8 +334,27 @@ describe('POST /api/company/members/invite', () => { consoleWarnSpy.mockRestore() }) - it('uses a registered white-label request host in the invitation email', async () => { - process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + it('503s (retryable) when the brand lookup fails instead of mailing a canonical link', async () => { + resolveBrandResultByHostMock.mockResolvedValue({ brand: null, lookupFailed: true }) + enqueue({ data: { role: 'owner' } }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: { name: 'Acme AB' } }) + enqueue({ data: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await post( + { email: 'client@example.com' }, + 'https://portal.brand.test/api/company/members/invite', + ), + ) + + expect(status).toBe(503) + expect(body.error.code).toBe('TRANSIENT_ERROR') + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('uses a registered brand request host in the invitation email', async () => { enqueue({ data: { role: 'owner' } }) enqueue({ data: [] }) enqueue({ data: null }) @@ -355,7 +377,6 @@ describe('POST /api/company/members/invite', () => { }) it('falls back to the canonical app for an untrusted spoofed request host', async () => { - process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' enqueue({ data: { role: 'owner' } }) enqueue({ data: [] }) enqueue({ data: null }) @@ -444,9 +465,8 @@ describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning', expect(body.data.email_sent).toBe(true) }) - it('uses a registered white-label request host for the GoTrue invite redirect', async () => { + it('uses a registered brand request host for the GoTrue invite redirect', async () => { process.env.AUTH_SIGNUPS_DISABLED = 'true' - process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' enqueue({ data: { role: 'owner' } }) enqueue({ data: [] }) enqueue({ data: null }) diff --git a/app/api/company/members/invite/route.ts b/app/api/company/members/invite/route.ts index 09a702e1..0e7aeb2c 100644 --- a/app/api/company/members/invite/route.ts +++ b/app/api/company/members/invite/route.ts @@ -142,10 +142,10 @@ export const POST = withRouteContext( const expiresAt = getInviteExpiry() // The request host is used only when it is the canonical app host or an - // exact registered white-label domain. A spoofed Host header falls back to - // NEXT_PUBLIC_APP_URL, so neither the email nor GoTrue gets an open - // redirect target. - const appOrigin = resolveRequestAppOrigin(request) + // exact registered brand domain (brands table). A spoofed Host header + // falls back to NEXT_PUBLIC_APP_URL, so neither the email nor GoTrue gets + // an open redirect target. + const appOrigin = await resolveRequestAppOrigin(request) // Self-hosted installations that turn public signup off in GoTrue // (disable_signup) set AUTH_SIGNUPS_DISABLED=true to mirror that config: diff --git a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts index ba9d34f0..f7ba225c 100644 --- a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts +++ b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts @@ -104,6 +104,20 @@ vi.mock('@/lib/cash-accounts/service', () => ({ vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') +// 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 { GET } from '../route' import { eventBus } from '@/lib/events/bus' @@ -242,11 +256,10 @@ describe('GET /api/extensions/enable-banking/callback', () => { const BRAND_ROW = { ...PENDING_ROW, oauth_origin: 'https://books.partner.example' } beforeEach(() => { - vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example') }) afterEach(() => { - vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '') + registerBrandHost('books.partner.example') }) it('sends an anonymous white-label user to their own brand login, not the canonical one', async () => { @@ -300,7 +313,7 @@ describe('GET /api/extensions/enable-banking/callback', () => { }) it('collapses a recorded origin that is not a registered host to the canonical one', async () => { - vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '') + registerBrandHost(null) const chain = mockChain({ data: BRAND_ROW, error: null }) mockFrom.mockReturnValue(chain) mockGetUser.mockResolvedValue({ data: { user: null }, error: null }) @@ -1796,7 +1809,6 @@ describe('GET /api/extensions/enable-banking/callback', () => { }) it('returns a bank denial to the recorded brand origin so the banner is seen where the session is', async () => { - vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example') mockFrom.mockImplementation(() => mockChain({ data: { @@ -1812,7 +1824,6 @@ describe('GET /api/extensions/enable-banking/callback', () => { ) const response = await GET(makeRequest({ error: 'access_denied', state: 'pending-state' })) - vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '') expect(response.status).toBe(307) const location = new URL(response.headers.get('location') || '') diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index e3c698ef..398b6e4b 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -228,7 +228,7 @@ export async function GET(request: Request) { // visible where their session is (a white-label user has none on // the canonical host and would be bounced to its login instead). return NextResponse.redirect( - `${resolveTrustedAppOrigin(pendingConn.oauth_origin)}/settings/banking?${params.toString()}` + `${await resolveTrustedAppOrigin(pendingConn.oauth_origin, { onLookupFailure: 'canonical' })}/settings/banking?${params.toString()}` ) } } catch (cleanupError) { @@ -294,9 +294,13 @@ export async function GET(request: Request) { // redirect, including the login bounce that re-runs this callback with the // same code + state, goes to the recorded initiating origin: their brand // host already holds the session, so its login page forwards straight back - // here and the callback completes with cookies. Allowlist-validated; an - // unregistered or missing origin collapses to the canonical host. - const returnOrigin = resolveTrustedAppOrigin(pendingConnection.oauth_origin) + // here and the callback completes with cookies. Validated against the + // brands table; an unregistered or missing origin collapses to the + // canonical host, and so does a failed lookup (no token rides in this + // redirect, and a 500 mid-callback would strand the user). + const returnOrigin = await resolveTrustedAppOrigin(pendingConnection.oauth_origin, { + onLookupFailure: 'canonical', + }) const initiator = await requireFlowInitiator(request, pendingConnection.user_id, { flow: 'enable-banking.callback', diff --git a/docs/WHITELABEL.md b/docs/WHITELABEL.md index 96d6ae1c..85ca9950 100644 --- a/docs/WHITELABEL.md +++ b/docs/WHITELABEL.md @@ -41,7 +41,6 @@ All branding can be set via env vars. Public ones use `NEXT_PUBLIC_BRANDING_*` ( | `BRANDING_SECURITY_EMAIL` | `securityEmail` | `security@arcim.io` | | `NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM` | `authEmailFrom`: From address Supabase Auth sends verification / reset emails from. Used to pre-populate the `from:` query on the "open in Gmail" button after signup. Set to whatever you configured in your Supabase Auth SMTP. | `noreply@gnubok.se` | | `NEXT_PUBLIC_APP_URL` | `appUrl` | `https://app.gnubok.se` | -| `NEXT_PUBLIC_WHITELABEL_DOMAINS` | Exact comma-separated hostnames served by the same hosted deployment. No wildcards. Invite and auth redirects use a listed host and otherwise fall back to `NEXT_PUBLIC_APP_URL`. Enable Banking consent callbacks started from a listed host return to it; an unlisted host is sent to the canonical login instead. | `` | | `NEXT_PUBLIC_BRANDING_LOGO_PATH` | `logoPath` | `/gnubokiceon-removebg-preview.png` | | `NEXT_PUBLIC_BRANDING_FAVICON_PATH` | `faviconPath` | `/favicon.ico` | | `NEXT_PUBLIC_BRANDING_APPLE_ICON_PATH` | `appleTouchIconPath` | `/icons/icon-192.png` | @@ -102,7 +101,7 @@ A few things that look brand-related but are configured elsewhere: - **DNS / domain**: point `app.your-brand.se` at your Vercel deployment. - **OAuth redirect allowlist for MCP**: `lib/auth/oauth-allowlist.ts` has built-in entries for Claude (`claude.ai/api/*`, `claude.com/api/*`), ChatGPT, Grok, Cursor and localhost; anything else is registered per user under Settings > API & MCP > OAuth clients. Your domain is the OAuth issuer, not a redirect target: no change needed unless you're integrating with new MCP clients. - **iCal feed PRODID** (`lib/calendar/ics-generator.ts`): defaults to `erp-base.se`, callers may pass their domain. -- **`NEXT_PUBLIC_APP_URL`**: used as the OAuth issuer and safe auth-link fallback. For a dedicated one-brand deployment, set this to your domain (e.g. `https://app.your-brand.se`). For a shared hosted deployment, keep the canonical main app URL here and register additional hosts through `NEXT_PUBLIC_WHITELABEL_DOMAINS`. +- **`NEXT_PUBLIC_APP_URL`**: used as the OAuth issuer and safe auth-link fallback. For a dedicated one-brand deployment, set this to your domain (e.g. `https://app.your-brand.se`). For a shared hosted deployment, keep the canonical main app URL here; additional hosts are the `brands.domain` rows (see below). - **Skatteverket submission identity**: `extensions/general/skatteverket/lib/api-client.ts` does not set a custom `User-Agent`; submissions go out with the Node/Vercel runtime default. If your deployment needs to identify itself to Skatteverket under a different brand, that's a future enhancement (env var + header), not something the current branding service covers. ## Shared hosted deployment with custom domains @@ -113,17 +112,16 @@ Accounted's hosted product serves its customers from the `accounted.se` zone: `a Because the rule is stated as "this namespace belongs to the production project", a newly launched `.accounted.se` host is protected as soon as it resolves. There is no list to remember to update. The first version of the guard did the opposite: it enumerated seven approved hostnames, and on 2026-08-26 it failed open on `improveone.accounted.se`, a customer host nobody had added, which a feature-branch preview served from the staging project for hours. Vercel preview domains (`*.vercel.app`) and local development names stay out of scope. A host inside the namespace that is deliberately not production has to be excluded explicitly in `lib/domains/production-white-label-backend.ts`, in the same change that creates it. -Two kinds of host are not derivable from the namespace, so they are still classified by hand in `CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS` (`lib/domains/production-white-label-backend.ts`): Accounted's legacy canonical host `app.gnubok.se`, and a customer that brings its own domain (step 1 below). Add those as part of the same reviewed rollout. The set also still lists the `accounted.se` hosts the namespace rule already covers: there they are a checked-in inventory the tests pin host by host, not what makes those hosts protected. Do not derive the set from `NEXT_PUBLIC_WHITELABEL_DOMAINS`: that variable is an auth callback allowlist, not an authoritative production inventory, and it can also contain demo, pilot, or self-hosted domains. +Two kinds of host are not derivable from the namespace, so they are still classified by hand in `CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS` (`lib/domains/production-white-label-backend.ts`): Accounted's legacy canonical host `app.gnubok.se`, and a customer that brings its own domain (step 1 below). Add those as part of the same reviewed rollout. The set also still lists the `accounted.se` hosts the namespace rule already covers: there they are a checked-in inventory the tests pin host by host, not what makes those hosts protected. Do not derive the set from the `brands` table: that is the auth-link registry, not an authoritative production inventory, and it can also hold demo, pilot, or staging-only brands. The guard contains a misrouted deployment. It does not classify domains outside the hosted namespace, prove cross-tenant isolation, or replace the operational work of placing customer environments under production ownership and controls. Its `alert: true` flag also pages nobody on its own: middleware never registers the observability sink, so the alerting rule is configured on the hosting side and matches `operation=white_label_backend_guard` in the emitted log line. 1. Register the exact custom hostname on the hosting deployment and finish its DNS verification. -2. Add that hostname to the comma-separated `NEXT_PUBLIC_WHITELABEL_DOMAINS` value. Entries are exact hostnames such as `portal.partner.se`; wildcard entries are ignored. -3. Add `https://portal.partner.se/auth/callback` and `https://portal.partner.se/invite/*` to the Supabase Auth Redirect URLs allowlist. Keep the canonical `NEXT_PUBLIC_APP_URL` callback there too. -4. Redeploy after changing the environment variable. It is public build-time configuration because the browser must validate password-reset callbacks before calling GoTrue. -5. Test a new-user invitation, an existing-user invitation, and a password reset from the custom domain. +2. Create the brand row with that hostname as `brands.domain` (exact hostname such as `portal.partner.se`, no wildcards). This row is the only application-side registry: password reset, invite, email change and signup links, Stripe billing return URLs and Enable Banking consent callbacks all resolve the request host against it (`lib/domains/trusted-app-origin.ts`), so no environment variable and no redeploy is involved. An Enable Banking consent started from an unregistered host returns to the canonical login instead. +3. Make sure GoTrue accepts the callback. GoTrue matches allowlist entries against the FULL `redirect_to`, query string included, and a single `*` stops at `.` and `/`, so an entry must end in `**` to accept `/auth/callback?next=/reset-password` or `?flow=email_change`. Hosts under `accounted.se` are covered by the two wildcard entries `https://*.accounted.se/auth/callback**` and `https://*.accounted.se/invite/**` in the Supabase Auth Redirect URLs allowlist. A partner that brings its own domain needs `https://portal.partner.se/auth/callback**` and `https://portal.partner.se/invite/**` added there explicitly. Keep the canonical `NEXT_PUBLIC_APP_URL` callback there too. A host missing from this allowlist is the one remaining way to get a canonical-branded mail: GoTrue silently replaces an unlisted `redirect_to` with the Site URL before the Send Email hook runs. +4. Test a new-user invitation, an existing-user invitation, a password reset (signed out and signed in), and an email change from the custom domain, and check the landing domain of each real mail. -The request `Host` header and `window.location.origin` are inputs, not trust anchors. Accounted uses them only when the hostname exactly matches the canonical app host or a configured white-label hostname. Unknown or spoofed hosts fall back to `NEXT_PUBLIC_APP_URL`, so they cannot become invite links or GoTrue redirect targets. +The request `Host` header is an input, not a trust anchor. Accounted uses it only when the hostname exactly matches the canonical app host, one of the deployment's own Vercel hostnames, or a registered `brands.domain`. Unknown or spoofed hosts fall back to `NEXT_PUBLIC_APP_URL`, so they cannot become invite links or GoTrue redirect targets. ## Staying in sync with upstream diff --git a/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts b/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts index 8ffccd65..3d8aa13e 100644 --- a/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts +++ b/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts @@ -24,6 +24,12 @@ vi.mock('../lib/api-client', async (importOriginal) => { } }) +// The connect handler records the initiating origin through the brands-table +// resolver; no brand host is registered in these tests. +vi.mock('@/lib/branding/resolve', () => ({ + resolveBrandResultByHost: vi.fn(async () => ({ brand: null, lookupFailed: false })), +})) + import { enableBankingExtension } from '../index' import { requireCapability } from '@/lib/entitlements/has-capability' import type { ExtensionContext } from '@/lib/extensions/types' diff --git a/extensions/general/enable-banking/__tests__/session-expired.test.ts b/extensions/general/enable-banking/__tests__/session-expired.test.ts index 432c4922..1c31aaf1 100644 --- a/extensions/general/enable-banking/__tests__/session-expired.test.ts +++ b/extensions/general/enable-banking/__tests__/session-expired.test.ts @@ -18,6 +18,20 @@ vi.mock('@/lib/entitlements/has-capability', () => ({ requireCapability: vi.fn().mockResolvedValue(null), })) +// 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 { isSessionExpiredResponse, SessionExpiredError, @@ -267,7 +281,9 @@ describe('POST /connect (enable-banking): reconnect in place', () => { oauth_state: expect.any(String), // The host the renewal was started from, so the callback can return // there (a white-label user's session exists only on their brand host). - oauth_origin: getCanonicalAppOrigin(), + // A local canonical trusts other local hosts as-is, so the request's + // own http://localhost is recorded rather than the :3000 canonical. + oauth_origin: 'http://localhost', status: 'expired', error_message: null, }) @@ -394,7 +410,6 @@ describe('POST /connect (enable-banking): psu_type persistence', () => { }) it('records the initiating brand origin on a fresh connect so the callback can return there', async () => { - vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example') stubAuth() const insertSpy = vi.fn() const ctx = makeContext({ id: 'conn-new', entity_type: 'aktiebolag' }, vi.fn(), insertSpy) @@ -406,7 +421,6 @@ describe('POST /connect (enable-banking): psu_type persistence', () => { }) const res = await connectRoute.handler(req, ctx) - vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '') expect(res.status).toBe(200) expect(insertSpy.mock.calls[0][0]).toMatchObject({ oauth_origin: 'https://books.partner.example', diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 5cb0df28..3fcae2cd 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -523,9 +523,13 @@ export const enableBankingExtension: Extension = { // The host the user started from. Their session lives only there // (cookies are per host) while redirectUrl stays the canonical // callback registered with Enable Banking, so the callback reads - // this back to return the browser home. Allowlist-validated: an - // unregistered Host header collapses to the canonical origin. - const oauthOrigin = resolveRequestAppOrigin(request) + // this back to return the browser home. Validated against the + // brands table: an unregistered Host header collapses to the + // canonical origin, as does a failed lookup (a wrong return host + // costs one bounce; a failed connect start costs the whole flow). + const oauthOrigin = await resolveRequestAppOrigin(request, { + onLookupFailure: 'canonical', + }) // Generate cryptographic state token for CSRF protection const oauthState = crypto.randomUUID() diff --git a/lib/auth/__tests__/oauth-flow-binding.test.ts b/lib/auth/__tests__/oauth-flow-binding.test.ts index 0c7237d3..693c7feb 100644 --- a/lib/auth/__tests__/oauth-flow-binding.test.ts +++ b/lib/auth/__tests__/oauth-flow-binding.test.ts @@ -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') diff --git a/lib/auth/__tests__/turnstile.test.ts b/lib/auth/__tests__/turnstile.test.ts index d5493772..20e5efe4 100644 --- a/lib/auth/__tests__/turnstile.test.ts +++ b/lib/auth/__tests__/turnstile.test.ts @@ -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"') diff --git a/lib/auth/oauth-flow-binding.ts b/lib/auth/oauth-flow-binding.ts index 3118b412..2e64d691 100644 --- a/lib/auth/oauth-flow-binding.ts +++ b/lib/auth/oauth-flow-binding.ts @@ -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 { 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), } } diff --git a/lib/domains/__tests__/trusted-app-origin.test.ts b/lib/domains/__tests__/trusted-app-origin.test.ts index 574536a8..34b7a309 100644 --- a/lib/domains/__tests__/trusted-app-origin.test.ts +++ b/lib/domains/__tests__/trusted-app-origin.test.ts @@ -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', ) }) diff --git a/lib/domains/production-white-label-backend.ts b/lib/domains/production-white-label-backend.ts index efe174ac..30926d10 100644 --- a/lib/domains/production-white-label-backend.ts +++ b/lib/domains/production-white-label-backend.ts @@ -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', diff --git a/lib/domains/trusted-app-origin.ts b/lib/domains/trusted-app-origin.ts index 080232db..62a2bef5 100644 --- a/lib/domains/trusted-app-origin.ts +++ b/lib/domains/trusted-app-origin.ts @@ -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 { - 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 { + const hosts = new Set() + 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 { 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 { + 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 { + return `${await resolveTrustedAppOrigin(host)}/auth/callback?next=/reset-password` } diff --git a/proxy.test.ts b/proxy.test.ts index 4e799074..45993368 100644 --- a/proxy.test.ts +++ b/proxy.test.ts @@ -201,8 +201,7 @@ describe('production white-label proxy guard', () => { expect(updateSessionMock).not.toHaveBeenCalled() }) - it('does not treat the callback allowlist as a production classification', async () => { - vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'demo.partner-brand.se') + it('does not treat an unclassified custom domain as production', async () => { const request = new NextRequest('https://demo.partner-brand.se/login') expect((await proxy(request)).status).toBe(204)