* 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>
192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { parseJsonResponse } from '@/tests/helpers'
|
|
|
|
const signUpMock = vi.hoisted(() => vi.fn())
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(async () => ({ auth: { signUp: signUpMock } })),
|
|
}))
|
|
|
|
const gateMock = vi.hoisted(() => vi.fn())
|
|
vi.mock('@/lib/auth/brand-signup-gate', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('@/lib/auth/brand-signup-gate')>()
|
|
return {
|
|
...actual,
|
|
evaluateBrandSignupGate: (...args: unknown[]) => gateMock(...args),
|
|
}
|
|
})
|
|
|
|
const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn())
|
|
vi.mock('@/lib/branding/resolve', () => ({
|
|
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args),
|
|
}))
|
|
|
|
import { POST } from '../route'
|
|
|
|
function makeRequest(
|
|
body: unknown,
|
|
headers: Record<string, string> = {},
|
|
): Request {
|
|
return new Request('https://internal/api/auth/signup', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...headers },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
const validBody = { email: 'kund@example.com', password: 'Str0ng!Pass' }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.se'
|
|
// app.testbrand.example is a registered brand host; app.accounted.se is
|
|
// the canonical host and never consults the registry.
|
|
resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({
|
|
brand: host === 'app.testbrand.example' ? { domain: host } : null,
|
|
lookupFailed: false,
|
|
}))
|
|
gateMock.mockResolvedValue({ allowed: true, brand: null, via: 'no_brand' })
|
|
signUpMock.mockResolvedValue({
|
|
data: { user: { identities: [{ id: 'i1' }] }, session: null },
|
|
error: null,
|
|
})
|
|
})
|
|
|
|
describe('POST /api/auth/signup', () => {
|
|
it('400s on invalid body', async () => {
|
|
const res = await POST(makeRequest({ email: 'not-an-email', password: 'x' }))
|
|
expect(res.status).toBe(400)
|
|
expect(signUpMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('403s with signup_not_allowed when the gate blocks', async () => {
|
|
gateMock.mockResolvedValue({ allowed: false, brand: { id: 'brand-1' } })
|
|
|
|
const res = await POST(
|
|
makeRequest(validBody, { host: 'app.testbrand.example' }),
|
|
)
|
|
const { body: json } = await parseJsonResponse<{ error: { code: string } }>(res)
|
|
|
|
expect(res.status).toBe(403)
|
|
expect(json.error.code).toBe('signup_not_allowed')
|
|
expect(signUpMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('503s (fail safe) when the brand lookup errors, without creating an account', async () => {
|
|
gateMock.mockResolvedValue({ allowed: false, brand: null, lookupFailed: true })
|
|
|
|
const res = await POST(
|
|
makeRequest(validBody, { host: 'app.testbrand.example' }),
|
|
)
|
|
const { body: json } = await parseJsonResponse<{ error: { code: string } }>(res)
|
|
|
|
expect(res.status).toBe(503)
|
|
expect(json.error.code).toBe('brand_lookup_failed')
|
|
expect(signUpMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('feeds the gate the forwarded host, normalized email and invite cookie', async () => {
|
|
await POST(
|
|
makeRequest(
|
|
{ ...validBody, email: ' Kund@Example.COM ' },
|
|
{
|
|
host: 'internal',
|
|
'x-forwarded-host': 'app.testbrand.example',
|
|
cookie: 'gnubok-invite-token=gnubok_inv_x',
|
|
},
|
|
),
|
|
)
|
|
|
|
expect(gateMock).toHaveBeenCalledWith({
|
|
host: 'app.testbrand.example',
|
|
email: 'kund@example.com',
|
|
inviteToken: 'gnubok_inv_x',
|
|
})
|
|
})
|
|
|
|
it('signs up with a confirmation callback on the originating host', async () => {
|
|
const res = await POST(
|
|
makeRequest(validBody, {
|
|
'x-forwarded-host': 'app.testbrand.example',
|
|
'x-forwarded-proto': 'https',
|
|
}),
|
|
)
|
|
const { body: json } = await parseJsonResponse<{ data: { status: string } }>(res)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(json.data.status).toBe('confirmation_sent')
|
|
expect(signUpMock).toHaveBeenCalledWith({
|
|
email: 'kund@example.com',
|
|
password: 'Str0ng!Pass',
|
|
options: {
|
|
emailRedirectTo: 'https://app.testbrand.example/auth/callback',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('forwards the captcha token and a safe next path', async () => {
|
|
await POST(
|
|
makeRequest(
|
|
{ ...validBody, captchaToken: 'tok', next: '/api/mcp-oauth/authorize?x=1' },
|
|
{ host: 'app.accounted.se' },
|
|
),
|
|
)
|
|
|
|
const call = signUpMock.mock.calls[0][0]
|
|
expect(call.options.captchaToken).toBe('tok')
|
|
expect(call.options.emailRedirectTo).toBe(
|
|
'https://app.accounted.se/auth/callback?next=%2Fapi%2Fmcp-oauth%2Fauthorize%3Fx%3D1',
|
|
)
|
|
})
|
|
|
|
it('drops an unsafe next path instead of forwarding it', async () => {
|
|
await POST(
|
|
makeRequest(
|
|
{ ...validBody, next: 'https://evil.example.com/phish' },
|
|
{ host: 'app.accounted.se' },
|
|
),
|
|
)
|
|
const call = signUpMock.mock.calls[0][0]
|
|
expect(call.options.emailRedirectTo).toBe(
|
|
'https://app.accounted.se/auth/callback',
|
|
)
|
|
})
|
|
|
|
it('maps a GoTrue error to the canonical envelope', async () => {
|
|
signUpMock.mockResolvedValue({
|
|
data: { user: null, session: null },
|
|
error: { code: 'weak_password', message: 'Password is too weak', status: 422 },
|
|
})
|
|
|
|
const res = await POST(makeRequest(validBody, { host: 'app.accounted.se' }))
|
|
const { body: json } = await parseJsonResponse<{ error: { code: string; message: string } }>(res)
|
|
|
|
expect(res.status).toBe(422)
|
|
expect(json.error.code).toBe('weak_password')
|
|
})
|
|
|
|
it('reports duplicate for the obfuscated existing-account response', async () => {
|
|
signUpMock.mockResolvedValue({
|
|
data: { user: { identities: [] }, session: null },
|
|
error: null,
|
|
})
|
|
|
|
const res = await POST(makeRequest(validBody, { host: 'app.accounted.se' }))
|
|
const { body: json } = await parseJsonResponse<{ data: { status: string } }>(res)
|
|
|
|
expect(json.data.status).toBe('duplicate')
|
|
})
|
|
|
|
it('reports session for auto-confirmed signups', async () => {
|
|
signUpMock.mockResolvedValue({
|
|
data: { user: { identities: [{ id: 'i1' }] }, session: { access_token: 'x' } },
|
|
error: null,
|
|
})
|
|
|
|
const res = await POST(makeRequest(validBody, { host: 'app.accounted.se' }))
|
|
const { body: json } = await parseJsonResponse<{ data: { status: string } }>(res)
|
|
|
|
expect(json.data.status).toBe('session')
|
|
})
|
|
})
|