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
@@ -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