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
@@ -104,6 +104,20 @@ vi.mock('@/lib/cash-accounts/service', () => ({
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000')
// 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 { GET } from '../route'
import { eventBus } from '@/lib/events/bus'
@@ -242,11 +256,10 @@ describe('GET /api/extensions/enable-banking/callback', () => {
const BRAND_ROW = { ...PENDING_ROW, oauth_origin: 'https://books.partner.example' }
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example')
})
afterEach(() => {
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '')
registerBrandHost('books.partner.example')
})
it('sends an anonymous white-label user to their own brand login, not the canonical one', async () => {
@@ -300,7 +313,7 @@ describe('GET /api/extensions/enable-banking/callback', () => {
})
it('collapses a recorded origin that is not a registered host to the canonical one', async () => {
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '')
registerBrandHost(null)
const chain = mockChain({ data: BRAND_ROW, error: null })
mockFrom.mockReturnValue(chain)
mockGetUser.mockResolvedValue({ data: { user: null }, error: null })
@@ -1796,7 +1809,6 @@ describe('GET /api/extensions/enable-banking/callback', () => {
})
it('returns a bank denial to the recorded brand origin so the banner is seen where the session is', async () => {
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example')
mockFrom.mockImplementation(() =>
mockChain({
data: {
@@ -1812,7 +1824,6 @@ describe('GET /api/extensions/enable-banking/callback', () => {
)
const response = await GET(makeRequest({ error: 'access_denied', state: 'pending-state' }))
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '')
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') || '')
@@ -228,7 +228,7 @@ export async function GET(request: Request) {
// visible where their session is (a white-label user has none on
// the canonical host and would be bounced to its login instead).
return NextResponse.redirect(
`${resolveTrustedAppOrigin(pendingConn.oauth_origin)}/settings/banking?${params.toString()}`
`${await resolveTrustedAppOrigin(pendingConn.oauth_origin, { onLookupFailure: 'canonical' })}/settings/banking?${params.toString()}`
)
}
} catch (cleanupError) {
@@ -294,9 +294,13 @@ export async function GET(request: Request) {
// redirect, including the login bounce that re-runs this callback with the
// same code + state, goes to the recorded initiating origin: their brand
// host already holds the session, so its login page forwards straight back
// here and the callback completes with cookies. Allowlist-validated; an
// unregistered or missing origin collapses to the canonical host.
const returnOrigin = resolveTrustedAppOrigin(pendingConnection.oauth_origin)
// here and the callback completes with cookies. Validated against the
// brands table; an unregistered or missing origin collapses to the
// canonical host, and so does a failed lookup (no token rides in this
// redirect, and a 500 mid-callback would strand the user).
const returnOrigin = await resolveTrustedAppOrigin(pendingConnection.oauth_origin, {
onLookupFailure: 'canonical',
})
const initiator = await requireFlowInitiator(request, pendingConnection.user_id, {
flow: 'enable-banking.callback',