fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)

* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS

Password reset, invite, email change and signup links now resolve the
request host against brands.domain server-side. The env var was a second
copy of that registry compiled into the browser; every new brand needed
the row, the env var, the GoTrue allowlist and a redeploy, and two
partners shipped with the env var stale, so their reset mails went out
canonical-branded to the canonical host.

- New POST /api/auth/password-reset: the login page no longer calls
  GoTrue directly, so the browser carries no domain list.
- lib/domains/trusted-app-origin.ts is async and registry-backed; it
  also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so
  previews keep sending links to themselves.
- Signup shares the same resolver instead of following the raw host.
- Docs and .env.example describe the single registry; GoTrue keeps the
  redirect allowlist as backstop (hosted: *.accounted.se wildcard).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): await the async origin resolver in the billing routes merged from main

PR #2370 added resolveRequestAppOrigin callers in billing/checkout and
billing/portal after this branch made the resolver async. Await them and
move their tests from the removed env var to the brands mock; update the
login source-assert test to the server-routed reset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs

Skeptic and CI findings on #2376, one pass:

- A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR,
  503, retryable) instead of falling back to the canonical origin: a
  canonical link is the wrong-brand mail this PR removes. Password reset
  and email change answer 503 themselves; withRouteContext routes map the
  code.
- A local canonical (dev) trusts other local hosts and ports on the same
  scheme, so lane servers on 3001-3003 confirm signups on themselves.
- GoTrue matches the full redirect_to including the query and `*` stops
  at `.` and `/`: docs and decision line now prescribe
  https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**.
- The Turnstile contract test asserts the server-routed reset forwards
  the captcha token (it still asserted the removed browser call).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-07 15:12:22 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 7a30f623ba
commit d29a5bda14
29 changed files with 713 additions and 186 deletions
@@ -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: