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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user