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
parent 7a30f623ba
commit d29a5bda14
29 changed files with 713 additions and 186 deletions
-7
View File
@@ -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
+1
View File
@@ -1636,6 +1636,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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')
})
})
+20 -5
View File
@@ -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,
@@ -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: {
+22 -6
View File
@@ -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)
@@ -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<string, string> = {}): 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()
})
})
+93
View File
@@ -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' } })
}
@@ -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 },
+9 -5
View File
@@ -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)
+11 -6
View File
@@ -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'),
+11 -6
View File
@@ -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'),
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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`,
@@ -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 }) => `<p>${data.inviteUrl}</p>`),
)
@@ -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 })
+4 -4
View File
@@ -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:
@@ -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') || '')
@@ -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',
+6 -8
View File
@@ -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 `<brand>.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
@@ -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'
@@ -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',
+7 -3
View File
@@ -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()
+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`
}
+1 -2
View File
@@ -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)