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:
co-authored by
Claude Fable 5.1
parent
7a30f623ba
commit
d29a5bda14
@@ -7,6 +7,10 @@ vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/branding/resolve', () => ({
|
||||
resolveBrandResultByHost: vi.fn(async () => ({ brand: null, lookupFailed: false })),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
function mockUserClient(opts: {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin'
|
||||
import {
|
||||
BrandLookupFailedError,
|
||||
resolveRequestAppOrigin,
|
||||
} from '@/lib/domains/trusted-app-origin'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -87,17 +90,30 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Trusted-origin resolution, not request.url: behind a proxy request.url
|
||||
// can be an internal origin (dead confirmation links on self-hosted), and
|
||||
// auth links may never follow an attacker-chosen host. Registered
|
||||
// white-label hosts pass through so the mail carries the right brand.
|
||||
// Trusted-origin resolution against the brands table, not request.url:
|
||||
// behind a proxy request.url can be an internal origin (dead confirmation
|
||||
// links on self-hosted), and auth links may never follow an
|
||||
// attacker-chosen host. Registered brand hosts pass through so the mail
|
||||
// carries the right brand.
|
||||
//
|
||||
// flow=email_change marks the callback so the stock GoTrue links (verified
|
||||
// on the GoTrue host, returned here via redirect_to with ?message=, ?error=
|
||||
// or ?code= instead of a token_hash) land on the email-change status page
|
||||
// rather than the silent login bounce. The Send Email hook preserves this
|
||||
// query on its token_hash links, so both link styles share the marker.
|
||||
const origin = resolveRequestAppOrigin(request)
|
||||
let origin: string
|
||||
try {
|
||||
origin = await resolveRequestAppOrigin(request)
|
||||
} catch (err) {
|
||||
if (!(err instanceof BrandLookupFailedError)) throw err
|
||||
// Refuse rather than send a wrong-brand confirmation pair; nothing has
|
||||
// been claimed or sent yet, so a retry is clean.
|
||||
log.warn('email change refused: brand lookup failed', { userId: user.id })
|
||||
return NextResponse.json(
|
||||
{ error: 'Tillfälligt fel. Försök igen om en stund.' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
// Cross-instance gate (migration 20260903083000). The pending-state read
|
||||
// above is not atomic: two concurrent requests (two tabs, a retried fetch)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const resetPasswordForEmailMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => ({
|
||||
auth: { resetPasswordForEmail: resetPasswordForEmailMock },
|
||||
})),
|
||||
}))
|
||||
|
||||
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/password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
|
||||
resetPasswordForEmailMock.mockResolvedValue({ data: {}, error: null })
|
||||
resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({
|
||||
brand: host === 'app.testbrand.example' ? { domain: host } : null,
|
||||
lookupFailed: false,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
||||
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
|
||||
})
|
||||
|
||||
describe('POST /api/auth/password-reset', () => {
|
||||
it('400s on invalid body', async () => {
|
||||
const res = await POST(makeRequest({ email: 'not-an-email' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect(resetPasswordForEmailMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the recovery callback on a registered brand host', async () => {
|
||||
const res = await POST(
|
||||
makeRequest(
|
||||
{ email: ' Kund@Example.COM ', captchaToken: 'tok' },
|
||||
{ host: 'internal', 'x-forwarded-host': 'app.testbrand.example' },
|
||||
),
|
||||
)
|
||||
const { body: json } = await parseJsonResponse<{ data: { status: string } }>(res)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(json.data.status).toBe('sent')
|
||||
expect(resetPasswordForEmailMock).toHaveBeenCalledWith('kund@example.com', {
|
||||
redirectTo: 'https://app.testbrand.example/auth/callback?next=/reset-password',
|
||||
captchaToken: 'tok',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the canonical callback for an unregistered host', async () => {
|
||||
await POST(makeRequest({ email: 'kund@example.com' }, { host: 'attacker.test' }))
|
||||
|
||||
expect(resetPasswordForEmailMock).toHaveBeenCalledWith('kund@example.com', {
|
||||
redirectTo: 'https://app.accounted.test/auth/callback?next=/reset-password',
|
||||
})
|
||||
})
|
||||
|
||||
it('503s (fail safe) when the brand lookup errors, without calling GoTrue', async () => {
|
||||
resolveBrandResultByHostMock.mockResolvedValue({ brand: null, lookupFailed: true })
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ email: 'kund@example.com' }, { 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(resetPasswordForEmailMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a GoTrue error to the canonical envelope with its status', async () => {
|
||||
resetPasswordForEmailMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: 'over_email_send_rate_limit', message: 'rate limit', status: 429 },
|
||||
})
|
||||
|
||||
const res = await POST(makeRequest({ email: 'kund@example.com' }, { host: 'app.accounted.test' }))
|
||||
const { body: json } = await parseJsonResponse<{ error: { code: string; message: string } }>(res)
|
||||
|
||||
expect(res.status).toBe(429)
|
||||
expect(json.error.code).toBe('over_email_send_rate_limit')
|
||||
expect(json.error.message).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import {
|
||||
BrandLookupFailedError,
|
||||
buildPasswordResetRedirectTo,
|
||||
requestHost,
|
||||
} from '@/lib/domains/trusted-app-origin'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('auth-password-reset')
|
||||
|
||||
/**
|
||||
* POST /api/auth/password-reset: request a password recovery mail.
|
||||
*
|
||||
* Moved server-side so the recovery callback is resolved against the brands
|
||||
* table (lib/domains/trusted-app-origin.ts) instead of a domain list compiled
|
||||
* into the browser bundle. The login page used to call
|
||||
* supabase.auth.resetPasswordForEmail directly with a redirectTo it validated
|
||||
* against NEXT_PUBLIC_WHITELABEL_DOMAINS; every new brand then needed that
|
||||
* env var updated and a redeploy, and when that was forgotten the mail went
|
||||
* out canonical-branded to the canonical host. Here the request host decides:
|
||||
* a registered brand host gets its own callback (and, through the Send Email
|
||||
* hook, its own brand), everything else gets the canonical one.
|
||||
*
|
||||
* Anonymous by design: a user asking for a reset has no session, so no
|
||||
* withRouteContext / requireAuth. Abuse is bounded exactly as the direct
|
||||
* GoTrue call was: the forwarded Turnstile token (verified by GoTrue) plus
|
||||
* GoTrue's own recovery rate limits. A signed-in user may also call this
|
||||
* (the login page is reachable while signed in); the cookie-backed client
|
||||
* carries their session and GoTrue behaves the same either way.
|
||||
*
|
||||
* The response never says whether the address exists: GoTrue answers 200 for
|
||||
* unknown addresses and this route passes that through unchanged.
|
||||
*/
|
||||
|
||||
const PasswordResetSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().max(320).pipe(z.string().email()),
|
||||
captchaToken: z.string().max(4096).nullish(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const validation = await validateBody(request, PasswordResetSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { email, captchaToken } = validation.data
|
||||
|
||||
let redirectTo: string
|
||||
try {
|
||||
redirectTo = await buildPasswordResetRedirectTo(requestHost(request))
|
||||
} catch (err) {
|
||||
if (!(err instanceof BrandLookupFailedError)) throw err
|
||||
// Transient brands-table error: fail safe like /api/auth/signup. A
|
||||
// canonical fallback here would mail a white-label user a wrong-brand
|
||||
// link; 503 tells the client to retry instead.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'brand_lookup_failed',
|
||||
message: 'Tillfälligt fel. Försök igen om en stund.',
|
||||
message_en: 'Temporary error. Please try again shortly.',
|
||||
},
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo,
|
||||
...(captchaToken ? { captchaToken } : {}),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
log.warn('resetPasswordForEmail rejected', { status: error.status, code: error.code })
|
||||
// Same envelope as /api/auth/signup: the login page feeds it to
|
||||
// classifyAuthError (keyed on code and HTTP status) and localizes the
|
||||
// display message through getErrorMessage.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: error.code ?? 'auth_error',
|
||||
message: getErrorMessage(error, { context: 'auth', locale: 'sv' }),
|
||||
message_en: getErrorMessage(error, { context: 'auth', locale: 'en' }),
|
||||
},
|
||||
},
|
||||
{ status: error.status && error.status >= 400 ? error.status : 400 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { status: 'sent' } })
|
||||
}
|
||||
@@ -16,6 +16,11 @@ vi.mock('@/lib/auth/brand-signup-gate', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/branding/resolve', () => ({
|
||||
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
function makeRequest(
|
||||
@@ -33,6 +38,13 @@ 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 },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readInviteTokenFromCookieHeader,
|
||||
} from '@/lib/auth/brand-signup-gate'
|
||||
import { safeReturnTo } from '@/lib/auth/safe-return-to'
|
||||
import { resolveTrustedAppOrigin } from '@/lib/domains/trusted-app-origin'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
@@ -81,11 +82,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Confirmation links must land back on the ORIGINATING host (WL-05 brand
|
||||
// mail resolves its brand from this URL), so build the callback from the
|
||||
// forwarded host rather than request.url, which can be an internal origin
|
||||
// behind the proxy.
|
||||
const proto = request.headers.get('x-forwarded-proto') ?? 'https'
|
||||
const confirmationCallback = new URL(`${proto}://${host}/auth/callback`)
|
||||
// mail resolves its brand from this URL). The host is resolved through the
|
||||
// same registry as every other auth link (canonical, this deployment's
|
||||
// own Vercel hosts, or a registered brand domain); anything else falls
|
||||
// back to the canonical origin rather than following the raw header.
|
||||
const confirmationCallback = new URL(
|
||||
'/auth/callback',
|
||||
await resolveTrustedAppOrigin(host),
|
||||
)
|
||||
const nextPath = safeReturnTo(validation.data.next ?? null, '/')
|
||||
if (nextPath !== '/') confirmationCallback.searchParams.set('next', nextPath)
|
||||
|
||||
|
||||
@@ -46,14 +46,23 @@ import { POST } from '../checkout/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
// The trusted-origin resolver reads the brands table; pin one registered
|
||||
// brand host so the return-URL tests exercise the real resolver logic.
|
||||
const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/branding/resolve', () => ({
|
||||
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args),
|
||||
}))
|
||||
|
||||
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL
|
||||
const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
|
||||
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({
|
||||
brand: host === 'portal.brand.test' ? { domain: host } : null,
|
||||
lookupFailed: false,
|
||||
}))
|
||||
guardSandboxMock.mockResolvedValue(null)
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1', email: 'u@example.com', is_anonymous: false },
|
||||
@@ -65,8 +74,6 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
||||
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
|
||||
if (originalWhiteLabelDomains === undefined) delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
else process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains
|
||||
})
|
||||
|
||||
describe('POST /api/billing/checkout', () => {
|
||||
@@ -276,7 +283,6 @@ describe('POST /api/billing/checkout', () => {
|
||||
})
|
||||
|
||||
it('returns to a registered white-label host when checkout starts there', async () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
|
||||
const { status } = await parseJsonResponse(
|
||||
await checkoutFrom('https://portal.brand.test/api/billing/checkout'),
|
||||
@@ -292,7 +298,6 @@ describe('POST /api/billing/checkout', () => {
|
||||
})
|
||||
|
||||
it('falls back to the canonical app for an unregistered or spoofed host', async () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
|
||||
const { status } = await parseJsonResponse(
|
||||
await checkoutFrom('https://portal.brand.test.attacker.test/api/billing/checkout'),
|
||||
|
||||
@@ -39,14 +39,23 @@ import { POST } from '../portal/route'
|
||||
|
||||
const routeParams = { params: Promise.resolve({}) }
|
||||
|
||||
// The trusted-origin resolver reads the brands table; pin one registered
|
||||
// brand host so the return-URL tests exercise the real resolver logic.
|
||||
const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/branding/resolve', () => ({
|
||||
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args),
|
||||
}))
|
||||
|
||||
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL
|
||||
const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
|
||||
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({
|
||||
brand: host === 'portal.brand.test' ? { domain: host } : null,
|
||||
lookupFailed: false,
|
||||
}))
|
||||
guardSandboxMock.mockResolvedValue(null)
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1', is_anonymous: false }, supabase: {}, error: null })
|
||||
})
|
||||
@@ -54,8 +63,6 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
||||
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
|
||||
if (originalWhiteLabelDomains === undefined) delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
else process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains
|
||||
})
|
||||
|
||||
describe('POST /api/billing/portal', () => {
|
||||
@@ -151,7 +158,6 @@ describe('POST /api/billing/portal', () => {
|
||||
})
|
||||
|
||||
it('comes back to a registered white-label host when opened there', async () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
|
||||
const { status } = await parseJsonResponse(
|
||||
await portalFrom('https://portal.brand.test/api/billing/portal'),
|
||||
@@ -164,7 +170,6 @@ describe('POST /api/billing/portal', () => {
|
||||
})
|
||||
|
||||
it('falls back to the canonical app for an unregistered or spoofed host', async () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
|
||||
const { status } = await parseJsonResponse(
|
||||
await portalFrom('https://portal.brand.test.attacker.test/api/billing/portal'),
|
||||
|
||||
@@ -120,10 +120,10 @@ export const POST = withRouteContext('billing.checkout', async (request, ctx) =>
|
||||
// Return the user to the host they started on. Sessions are per domain, so
|
||||
// sending a white-label user back to the canonical app would land them on a
|
||||
// foreign-branded login with no session. The origin is resolved against the
|
||||
// registered host allowlist; an unknown or spoofed host falls back to the
|
||||
// brands table; an unknown or spoofed host falls back to the
|
||||
// canonical app URL. The paths stay fixed: never accept a caller-supplied
|
||||
// return URL here.
|
||||
const appOrigin = resolveRequestAppOrigin(request)
|
||||
const appOrigin = await resolveRequestAppOrigin(request)
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: customerId,
|
||||
|
||||
@@ -48,7 +48,7 @@ export const POST = withRouteContext('billing.portal', async (request, ctx) => {
|
||||
// Same host the user started on (see billing/checkout): a registered
|
||||
// white-label host stays on its brand, anything else returns to the
|
||||
// canonical app. The path is fixed.
|
||||
const appOrigin = resolveRequestAppOrigin(request)
|
||||
const appOrigin = await resolveRequestAppOrigin(request)
|
||||
const portal = await getStripe().billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: `${appOrigin}/settings/billing`,
|
||||
|
||||
@@ -60,6 +60,13 @@ const brandSenderMock = vi.hoisted(() => ({
|
||||
}))
|
||||
vi.mock('@/lib/email/brand-sender', () => brandSenderMock)
|
||||
|
||||
// The trusted-origin resolver reads the brands table; pin one registered
|
||||
// brand host so the invite link tests exercise the real resolver logic.
|
||||
const resolveBrandResultByHostMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/branding/resolve', () => ({
|
||||
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultByHostMock(...args),
|
||||
}))
|
||||
|
||||
const generateInviteEmailHtmlMock = vi.hoisted(() =>
|
||||
vi.fn((data: { inviteUrl: string }) => `<p>${data.inviteUrl}</p>`),
|
||||
)
|
||||
@@ -81,14 +88,16 @@ function post(body: unknown, url = '/api/company/members/invite') {
|
||||
}
|
||||
|
||||
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL
|
||||
const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
delete process.env.AUTH_SIGNUPS_DISABLED
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
|
||||
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
resolveBrandResultByHostMock.mockImplementation(async (host: string) => ({
|
||||
brand: host === 'portal.brand.test' ? { domain: host } : null,
|
||||
lookupFailed: false,
|
||||
}))
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1', email: 'owner@example.com' },
|
||||
supabase: {},
|
||||
@@ -113,12 +122,6 @@ afterEach(() => {
|
||||
delete process.env.AUTH_SIGNUPS_DISABLED
|
||||
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
||||
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl
|
||||
|
||||
if (originalWhiteLabelDomains === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains
|
||||
}
|
||||
})
|
||||
|
||||
describe('POST /api/company/members/invite', () => {
|
||||
@@ -331,8 +334,27 @@ describe('POST /api/company/members/invite', () => {
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses a registered white-label request host in the invitation email', async () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
it('503s (retryable) when the brand lookup fails instead of mailing a canonical link', async () => {
|
||||
resolveBrandResultByHostMock.mockResolvedValue({ brand: null, lookupFailed: true })
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: { name: 'Acme AB' } })
|
||||
enqueue({ data: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await post(
|
||||
{ email: 'client@example.com' },
|
||||
'https://portal.brand.test/api/company/members/invite',
|
||||
),
|
||||
)
|
||||
|
||||
expect(status).toBe(503)
|
||||
expect(body.error.code).toBe('TRANSIENT_ERROR')
|
||||
expect(sendEmailMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses a registered brand request host in the invitation email', async () => {
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
@@ -355,7 +377,6 @@ describe('POST /api/company/members/invite', () => {
|
||||
})
|
||||
|
||||
it('falls back to the canonical app for an untrusted spoofed request host', async () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
@@ -444,9 +465,8 @@ describe('POST /api/company/members/invite: AUTH_SIGNUPS_DISABLED provisioning',
|
||||
expect(body.data.email_sent).toBe(true)
|
||||
})
|
||||
|
||||
it('uses a registered white-label request host for the GoTrue invite redirect', async () => {
|
||||
it('uses a registered brand request host for the GoTrue invite redirect', async () => {
|
||||
process.env.AUTH_SIGNUPS_DISABLED = 'true'
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
|
||||
@@ -142,10 +142,10 @@ export const POST = withRouteContext(
|
||||
const expiresAt = getInviteExpiry()
|
||||
|
||||
// The request host is used only when it is the canonical app host or an
|
||||
// exact registered white-label domain. A spoofed Host header falls back to
|
||||
// NEXT_PUBLIC_APP_URL, so neither the email nor GoTrue gets an open
|
||||
// redirect target.
|
||||
const appOrigin = resolveRequestAppOrigin(request)
|
||||
// exact registered brand domain (brands table). A spoofed Host header
|
||||
// falls back to NEXT_PUBLIC_APP_URL, so neither the email nor GoTrue gets
|
||||
// an open redirect target.
|
||||
const appOrigin = await resolveRequestAppOrigin(request)
|
||||
|
||||
// Self-hosted installations that turn public signup off in GoTrue
|
||||
// (disable_signup) set AUTH_SIGNUPS_DISABLED=true to mirror that config:
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user