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
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user