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:
co-authored by
Claude Fable 5.1
parent
7a30f623ba
commit
d29a5bda14
@@ -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')
|
||||
|
||||
@@ -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"')
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user