Files
Mattsson 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

243 lines
8.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server'
import { NextRequest, NextResponse } from 'next/server'
const updateSessionMock = vi.hoisted(() =>
vi.fn(async () => new NextResponse(null, { status: 204 })),
)
const loggerErrorMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/supabase/middleware', () => ({
updateSession: updateSessionMock,
}))
vi.mock('@/lib/logger', () => ({
createLogger: () => ({ error: loggerErrorMock }),
}))
import { config, proxy } from './proxy'
const STAGING_URL = 'https://metjnjrhvujscngnpzdv.supabase.co'
const PRODUCTION_URL = 'https://pwxtzglxptnnvjrpixpg.supabase.co'
const ANON_KEY = 'anon-key'
// Both vars are stubbed explicitly in every suite below. The unit project has
// no setup file and loads no dotenv, so leaning on a developer's exported
// shell environment is a test that passes locally and fails in CI.
function stubConfiguredEnvironment(supabaseUrl: string): void {
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', supabaseUrl)
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', ANON_KEY)
}
describe('supabase environment proxy guard', () => {
beforeEach(() => {
vi.clearAllMocks()
stubConfiguredEnvironment(PRODUCTION_URL)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('returns a non-cacheable empty 503 when the Supabase URL is missing', async () => {
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', undefined)
const response = await proxy(new NextRequest('https://app.accounted.se/login'))
expect(response.status).toBe(503)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(await response.text()).toBe('')
expect(updateSessionMock).not.toHaveBeenCalled()
expect(loggerErrorMock).toHaveBeenCalledOnce()
expect(loggerErrorMock).toHaveBeenCalledWith(
'Refused request without Supabase configuration',
{
alert: true,
operation: 'supabase_env_missing',
requestHostname: 'app.accounted.se',
missing: ['NEXT_PUBLIC_SUPABASE_URL'],
},
)
})
it('returns the same 503 when the anon key is missing', async () => {
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '')
const response = await proxy(new NextRequest('https://app.accounted.se/robots.txt'))
expect(response.status).toBe(503)
expect(updateSessionMock).not.toHaveBeenCalled()
expect(loggerErrorMock).toHaveBeenCalledWith(
'Refused request without Supabase configuration',
expect.objectContaining({ missing: ['NEXT_PUBLIC_SUPABASE_ANON_KEY'] }),
)
})
it('treats an unsubstituted Docker sentinel as unconfigured', async () => {
stubConfiguredEnvironment('__NEXT_PUBLIC_SUPABASE_URL__')
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '__NEXT_PUBLIC_SUPABASE_ANON_KEY__')
expect(
(await proxy(new NextRequest('https://app.accounted.se/login'))).status,
).toBe(503)
expect(updateSessionMock).not.toHaveBeenCalled()
expect(loggerErrorMock).toHaveBeenCalledWith(
'Refused request without Supabase configuration',
expect.objectContaining({
missing: ['NEXT_PUBLIC_SUPABASE_URL', 'NEXT_PUBLIC_SUPABASE_ANON_KEY'],
}),
)
})
it('runs before the white-label guard on a customer host', async () => {
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', undefined)
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', undefined)
expect(
(await proxy(new NextRequest('https://willem.accounted.se/login'))).status,
).toBe(503)
expect(loggerErrorMock).toHaveBeenCalledOnce()
expect(loggerErrorMock).toHaveBeenCalledWith(
'Refused request without Supabase configuration',
expect.objectContaining({ operation: 'supabase_env_missing' }),
)
})
it('delegates to session handling once the environment is configured', async () => {
const request = new NextRequest('https://app.accounted.se/login')
expect((await proxy(request)).status).toBe(204)
expect(loggerErrorMock).not.toHaveBeenCalled()
expect(updateSessionMock).toHaveBeenCalledOnce()
expect(updateSessionMock).toHaveBeenCalledWith(request)
})
})
describe('production white-label proxy guard', () => {
beforeEach(() => {
vi.clearAllMocks()
stubConfiguredEnvironment(STAGING_URL)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it.each(['/login', '/auth/callback', '/api/health'])(
'covers the application request path %s',
pathname => {
expect(
unstable_doesMiddlewareMatch({
config,
nextConfig: {},
url: `https://acount.accounted.se${pathname}`,
}),
).toBe(true)
},
)
it('returns a non-cacheable empty 503 before session handling', async () => {
const response = await proxy(
new NextRequest('https://acount.accounted.se/login'),
)
expect(response.status).toBe(503)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(await response.text()).toBe('')
expect(updateSessionMock).not.toHaveBeenCalled()
expect(loggerErrorMock).toHaveBeenCalledOnce()
expect(loggerErrorMock).toHaveBeenCalledWith(
'Blocked production white-label host from a non-production backend',
{
alert: true,
operation: 'white_label_backend_guard',
requestHostname: 'acount.accounted.se',
backendClassification: 'non_production',
},
)
})
// The message says non-production rather than staging because the guard
// asserts the production project: a project it has never heard of fails the
// same way, and an alert rule keys on `operation`, which does not move.
it('logs the same guard event for a project it has never heard of', async () => {
stubConfiguredEnvironment('https://qqqqqqqqqqqqqqqqqqqq.supabase.co')
const response = await proxy(
new NextRequest('https://willem.accounted.se/login'),
)
expect(response.status).toBe(503)
expect(updateSessionMock).not.toHaveBeenCalled()
expect(loggerErrorMock).toHaveBeenCalledWith(
'Blocked production white-label host from a non-production backend',
{
alert: true,
operation: 'white_label_backend_guard',
requestHostname: 'willem.accounted.se',
backendClassification: 'non_production',
},
)
})
// The 2026-08-26 incident. This host was served by a branch preview wired to
// staging and was missing from the protected-host list, so the guard used to
// let it through.
it('blocks a hosted brand nobody remembered to classify', async () => {
const response = await proxy(
new NextRequest('https://improveone.accounted.se/login'),
)
expect(response.status).toBe(503)
expect(updateSessionMock).not.toHaveBeenCalled()
})
// Deliberate reversal of the earlier assertion that the canonical app host
// passes on the staging project. app.accounted.se is the production host: if
// the build serving it is wired to another project, it fails closed too.
it('blocks the canonical app host on the same backend', async () => {
const response = await proxy(new NextRequest('https://app.accounted.se/login'))
expect(response.status).toBe(503)
expect(updateSessionMock).not.toHaveBeenCalled()
})
it('does not treat an unclassified custom domain as production', async () => {
const request = new NextRequest('https://demo.partner-brand.se/login')
expect((await proxy(request)).status).toBe(204)
expect(updateSessionMock).toHaveBeenCalledOnce()
})
it.each([
'https://erp-base-git-add-white-label-infra.vercel.app/login',
'http://localhost:3000/login',
])('leaves the preview or local request %s alone', async url => {
expect((await proxy(new NextRequest(url))).status).toBe(204)
expect(updateSessionMock).toHaveBeenCalledOnce()
expect(loggerErrorMock).not.toHaveBeenCalled()
})
it('allows the production host once it is on the production backend', async () => {
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', PRODUCTION_URL)
const request = new NextRequest('https://acount.accounted.se/login')
expect((await proxy(request)).status).toBe(204)
expect(updateSessionMock).toHaveBeenCalledOnce()
})
it('uses the request URL host and ignores x-forwarded-host', async () => {
const spoofedForwardedHost = new NextRequest(
'https://preview.vercel.app/login',
{ headers: { 'x-forwarded-host': 'acount.accounted.se' } },
)
expect((await proxy(spoofedForwardedHost)).status).toBe(204)
const productionHost = new NextRequest(
'https://acount.accounted.se/login',
{ headers: { 'x-forwarded-host': 'preview.vercel.app' } },
)
expect((await proxy(productionHost)).status).toBe(503)
expect(updateSessionMock).toHaveBeenCalledTimes(1)
})
})