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