* 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>
217 lines
8.2 KiB
TypeScript
217 lines
8.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(),
|
|
}))
|
|
|
|
// 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 { createClient } from '@/lib/supabase/server'
|
|
import {
|
|
requireFlowInitiator,
|
|
buildLoginRedirect,
|
|
redactUserId,
|
|
FLOW_INITIATOR_MISMATCH_MESSAGE,
|
|
} from '../oauth-flow-binding'
|
|
|
|
const CALLBACK_URL =
|
|
'https://app.example.se/api/extensions/stripe/callback?code=ac_123&state=state-1'
|
|
|
|
function mockSession(getUser: ReturnType<typeof vi.fn>) {
|
|
vi.mocked(createClient).mockResolvedValue({ auth: { getUser } } as never)
|
|
return getUser
|
|
}
|
|
|
|
function sessionWith(userId: string | null, error: unknown = null) {
|
|
return mockSession(
|
|
vi.fn().mockResolvedValue({
|
|
data: { user: userId ? { id: userId } : null },
|
|
error,
|
|
}),
|
|
)
|
|
}
|
|
|
|
describe('requireFlowInitiator', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.se')
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('passes when the cookie session belongs to the initiator', async () => {
|
|
const getUser = sessionWith('user-1')
|
|
|
|
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
|
|
|
|
expect(result).toEqual({ ok: true, userId: 'user-1' })
|
|
expect(getUser).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('refuses with a 403 envelope when a different user completes the flow', async () => {
|
|
sessionWith('user-2')
|
|
|
|
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1', {
|
|
flow: 'stripe.callback',
|
|
})
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) throw new Error('unreachable')
|
|
expect(result.reason).toBe('mismatch')
|
|
if (result.reason !== 'mismatch') throw new Error('unreachable')
|
|
expect(result.sessionUserId).toBe('user-2')
|
|
expect(result.response.status).toBe(403)
|
|
const body = await result.response.json()
|
|
expect(body.error.code).toBe('OAUTH_FLOW_INITIATOR_MISMATCH')
|
|
expect(body.error.message).toBe(FLOW_INITIATOR_MISMATCH_MESSAGE)
|
|
expect(typeof body.error.message_en).toBe('string')
|
|
})
|
|
|
|
it('sends an anonymous browser to /login with the callback URL as next', async () => {
|
|
sessionWith(null)
|
|
|
|
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) throw new Error('unreachable')
|
|
expect(result.reason).toBe('no_session')
|
|
expect(result.response.status).toBe(307)
|
|
const location = new URL(result.response.headers.get('location') ?? '')
|
|
expect(location.origin).toBe('https://app.example.se')
|
|
expect(location.pathname).toBe('/login')
|
|
// Same-origin relative path + query, the only shape the login page's
|
|
// safeReturnTo accepts, so signing in resumes the very same callback.
|
|
expect(location.searchParams.get('next')).toBe(
|
|
'/api/extensions/stripe/callback?code=ac_123&state=state-1',
|
|
)
|
|
})
|
|
|
|
it('sends an anonymous browser to the recorded brand origin when the caller passes one', async () => {
|
|
// Provider redirect URIs are pinned to the canonical host while sessions
|
|
// are per host: a white-label user reaches the callback signed out and
|
|
// must be sent to THEIR brand login, where the session already exists.
|
|
sessionWith(null)
|
|
|
|
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1', {
|
|
returnOrigin: 'https://books.partner.example',
|
|
})
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) throw new Error('unreachable')
|
|
expect(result.reason).toBe('no_session')
|
|
const location = new URL(result.response.headers.get('location') ?? '')
|
|
expect(location.origin).toBe('https://books.partner.example')
|
|
expect(location.pathname).toBe('/login')
|
|
expect(location.searchParams.get('next')).toBe(
|
|
'/api/extensions/stripe/callback?code=ac_123&state=state-1',
|
|
)
|
|
})
|
|
|
|
it('treats a getUser error as no session (fail closed)', async () => {
|
|
sessionWith(null, { message: 'invalid JWT' })
|
|
|
|
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) throw new Error('unreachable')
|
|
expect(result.reason).toBe('no_session')
|
|
})
|
|
|
|
it('treats a thrown client error as no session instead of finalizing on a guess', async () => {
|
|
vi.mocked(createClient).mockRejectedValue(new Error('cookies() outside request scope'))
|
|
|
|
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) throw new Error('unreachable')
|
|
expect(result.reason).toBe('no_session')
|
|
expect(result.response.headers.get('location')).toContain('/login?next=')
|
|
})
|
|
|
|
it('never passes on an empty expected id even when someone is signed in', async () => {
|
|
sessionWith('user-2')
|
|
|
|
const result = await requireFlowInitiator(new Request(CALLBACK_URL), '')
|
|
|
|
expect(result.ok).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('buildLoginRedirect', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('falls back to the request origin when NEXT_PUBLIC_APP_URL is unset', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
|
|
|
|
const response = await buildLoginRedirect(
|
|
new Request('http://localhost:3000/api/extensions/woocommerce/return?success=1&user_id=abc'),
|
|
)
|
|
|
|
expect(response.headers.get('location')).toBe(
|
|
'http://localhost:3000/login?next=' +
|
|
encodeURIComponent('/api/extensions/woocommerce/return?success=1&user_id=abc'),
|
|
)
|
|
})
|
|
|
|
it('uses a recorded origin only when it is the canonical host or a registered white-label host', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.se')
|
|
const request = new Request('https://app.example.se/api/extensions/stripe/callback?code=1&state=2')
|
|
|
|
const brand = await buildLoginRedirect(request, 'https://books.partner.example')
|
|
expect(new URL(brand.headers.get('location') ?? '').origin).toBe('https://books.partner.example')
|
|
|
|
// A stored value that is no longer (or never was) registered must not
|
|
// become a redirect target: the allowlist is the only authority.
|
|
const unknown = await buildLoginRedirect(request, 'https://evil.example')
|
|
expect(new URL(unknown.headers.get('location') ?? '').origin).toBe('https://app.example.se')
|
|
|
|
const canonical = await buildLoginRedirect(request, 'https://app.example.se')
|
|
expect(new URL(canonical.headers.get('location') ?? '').origin).toBe('https://app.example.se')
|
|
})
|
|
|
|
it('keeps a callback that arrived on a registered brand host on that host', async () => {
|
|
// NEXT_PUBLIC_APP_URL used to win over the request origin here, dragging
|
|
// a brand-domain callback to the canonical login. The allowlisted request
|
|
// host is the fallback now; an unregistered host still collapses.
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.se')
|
|
|
|
const onBrand = await buildLoginRedirect(
|
|
new Request('https://books.partner.example/api/extensions/stripe/callback?code=1&state=2'),
|
|
)
|
|
expect(new URL(onBrand.headers.get('location') ?? '').origin).toBe('https://books.partner.example')
|
|
|
|
const onUnknown = await buildLoginRedirect(
|
|
new Request('https://evil.example/api/extensions/stripe/callback?code=1&state=2'),
|
|
)
|
|
expect(new URL(onUnknown.headers.get('location') ?? '').origin).toBe('https://app.example.se')
|
|
})
|
|
})
|
|
|
|
describe('redactUserId', () => {
|
|
it('keeps only a correlation prefix of a uuid', () => {
|
|
expect(redactUserId('123e4567-e89b-12d3-a456-426614174000')).toBe('123e4567...')
|
|
})
|
|
|
|
it('handles short and missing ids', () => {
|
|
expect(redactUserId('user-1')).toBe('user-1')
|
|
expect(redactUserId(null)).toBe('(none)')
|
|
expect(redactUserId(undefined)).toBe('(none)')
|
|
})
|
|
})
|