d29a5bda14
* 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>
243 lines
8.6 KiB
TypeScript
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)
|
|
})
|
|
})
|