Files
MattssonandClaude Fable 5.1 d29a5bda14 fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS

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

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

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

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

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

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

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

Skeptic and CI findings on #2376, one pass:

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:12:22 +02:00

102 lines
3.7 KiB
TypeScript

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()
})
})