d29a5bda14
* 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>
170 lines
6.9 KiB
TypeScript
170 lines
6.9 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { createLogger } from '@/lib/logger'
|
|
import {
|
|
resolveRequestAppOrigin,
|
|
resolveTrustedAppOrigin,
|
|
} from '@/lib/domains/trusted-app-origin'
|
|
|
|
/**
|
|
* Bind the completion of a browser-driven OAuth/consent flow to the user who
|
|
* started it.
|
|
*
|
|
* Our OAuth callbacks (Enable Banking, Stripe Connect, WooCommerce wc-auth)
|
|
* locate the pending connection row by the single-use `oauth_state` token and
|
|
* then finalize it for that row's `user_id` / `company_id`. The token proves
|
|
* the callback belongs to a flow WE started; it does not prove that the
|
|
* browser completing it belongs to the user who started it. Without this
|
|
* check, a victim who is lured into completing a consent an attacker
|
|
* initiated (the authorize URL is shareable) has their bank / Stripe / store
|
|
* attached to the attacker's company.
|
|
*
|
|
* Consent redirects are top-level navigations, so on the legitimate path the
|
|
* initiator's own session cookies arrive with the callback. This helper reads
|
|
* that cookie session and compares it to the expected initiator.
|
|
*
|
|
* Outcomes:
|
|
* - ok: the session user is the initiator; carry on.
|
|
* - no_session: nobody is signed in (expired mid-flow, cookies cleared, or
|
|
* the session lives on another host). `response` redirects to
|
|
* /login?next=<this callback URL> so the initiator can sign in and the
|
|
* callback re-runs with the same code + state. The login lives on the
|
|
* origin the flow was started from when the caller recorded one: provider
|
|
* redirect URIs are pinned to the canonical host while sessions are per
|
|
* host, so a white-label user reaches the callback signed out and must be
|
|
* sent to THEIR brand host, where the session already exists and the
|
|
* login page forwards straight back into the callback.
|
|
* - mismatch: a different user is signed in. `response` is a 403 in the
|
|
* canonical error envelope; a route whose UX is a settings redirect
|
|
* inspects `reason` and builds its own redirect instead. The mismatch is
|
|
* logged with both ids redacted to prefixes.
|
|
*
|
|
* Deliberately not `requireAuth()`: this is an equality check on identity,
|
|
* not an authorization gate. The route that STARTED the flow already ran the
|
|
* MFA-enforcing guard for this user, and a 403 here for an aal1 session would
|
|
* strand the user (the callback has no MFA prompt to send them to).
|
|
*/
|
|
|
|
const log = createLogger('auth/oauth-flow-binding')
|
|
|
|
/** Swedish user-facing message for the mismatch outcome (shared by callers). */
|
|
export const FLOW_INITIATOR_MISMATCH_MESSAGE =
|
|
'Anslutningen kunde inte slutföras: den startades från ett annat användarkonto än det du är inloggad med. Logga in med det kontot eller starta anslutningen på nytt.'
|
|
|
|
export const FLOW_INITIATOR_MISMATCH_MESSAGE_EN =
|
|
'The connection could not be completed: it was started from a different user account than the one you are signed in with. Sign in with that account or start the connection again.'
|
|
|
|
export type FlowInitiatorResult =
|
|
| { ok: true; userId: string }
|
|
| { ok: false; reason: 'no_session'; response: Response }
|
|
| { ok: false; reason: 'mismatch'; response: Response; sessionUserId: string }
|
|
|
|
export interface RequireFlowInitiatorOptions {
|
|
/** Short label for the log line, e.g. 'stripe.callback'. */
|
|
flow?: string
|
|
/**
|
|
* Origin the initiator started the flow on, as recorded by the start route.
|
|
* Validated against the canonical host and the registered white-label hosts;
|
|
* anything else falls back to the canonical origin. Omit when the flow has
|
|
* no record of it.
|
|
*/
|
|
returnOrigin?: string | null
|
|
}
|
|
|
|
/**
|
|
* Shorten a user id to a stable prefix for log lines. Enough to correlate two
|
|
* log records, not enough to identify the account outside the database.
|
|
*/
|
|
export function redactUserId(id: string | null | undefined): string {
|
|
if (!id) return '(none)'
|
|
return id.length <= 8 ? id : `${id.slice(0, 8)}...`
|
|
}
|
|
|
|
/**
|
|
* The /login redirect for a callback reached without a session. `next` is the
|
|
* callback's own path + query (same-origin relative, which is the only form
|
|
* the login page's safeReturnTo accepts), so signing in resumes the flow.
|
|
*
|
|
* The login host is, in order: the recorded initiating origin (allowlisted),
|
|
* the host the callback arrived on (allowlisted, so a brand-domain callback is
|
|
* never dragged to the canonical login), or the request origin itself on a
|
|
* self-hosted deployment with no NEXT_PUBLIC_APP_URL.
|
|
*/
|
|
export async function buildLoginRedirect(
|
|
request: Request,
|
|
returnOrigin?: string | null,
|
|
): Promise<Response> {
|
|
const current = new URL(request.url)
|
|
// A login bounce carries no token, so a failed brands lookup degrades to
|
|
// the canonical host rather than failing the callback.
|
|
const appOrigin = returnOrigin
|
|
? await resolveTrustedAppOrigin(returnOrigin, { onLookupFailure: 'canonical' })
|
|
: process.env.NEXT_PUBLIC_APP_URL
|
|
? await resolveRequestAppOrigin(request, { onLookupFailure: 'canonical' })
|
|
: current.origin
|
|
const next = `${current.pathname}${current.search}`
|
|
const login = new URL('/login', appOrigin)
|
|
login.searchParams.set('next', next)
|
|
return NextResponse.redirect(login.toString())
|
|
}
|
|
|
|
export async function requireFlowInitiator(
|
|
request: Request,
|
|
expectedUserId: string,
|
|
options: RequireFlowInitiatorOptions = {},
|
|
): Promise<FlowInitiatorResult> {
|
|
const flow = options.flow ?? 'oauth-callback'
|
|
const path = new URL(request.url).pathname
|
|
|
|
let sessionUserId: string | null = null
|
|
try {
|
|
const supabase = await createClient()
|
|
const { data, error } = await supabase.auth.getUser()
|
|
if (!error && data?.user?.id) sessionUserId = data.user.id
|
|
} catch (err) {
|
|
// Fail closed: an auth outage or a missing request scope is treated as
|
|
// "no session". The login redirect below re-runs the callback once a
|
|
// session can be read, nothing is finalized on a guess.
|
|
log.error('could not read the cookie session for an OAuth callback', err as Error, {
|
|
flow,
|
|
path,
|
|
})
|
|
}
|
|
|
|
if (!sessionUserId) {
|
|
log.warn('oauth callback reached without a session; sending to login', {
|
|
flow,
|
|
path,
|
|
expectedUser: redactUserId(expectedUserId),
|
|
})
|
|
return {
|
|
ok: false,
|
|
reason: 'no_session',
|
|
response: await buildLoginRedirect(request, options.returnOrigin),
|
|
}
|
|
}
|
|
|
|
if (sessionUserId !== expectedUserId) {
|
|
log.warn('oauth callback completed by a different user than the initiator', {
|
|
flow,
|
|
path,
|
|
expectedUser: redactUserId(expectedUserId),
|
|
sessionUser: redactUserId(sessionUserId),
|
|
alert: true,
|
|
})
|
|
const response = NextResponse.json(
|
|
{
|
|
error: {
|
|
code: 'OAUTH_FLOW_INITIATOR_MISMATCH',
|
|
message: FLOW_INITIATOR_MISMATCH_MESSAGE,
|
|
message_en: FLOW_INITIATOR_MISMATCH_MESSAGE_EN,
|
|
},
|
|
},
|
|
{ status: 403 },
|
|
)
|
|
return { ok: false, reason: 'mismatch', response, sessionUserId }
|
|
}
|
|
|
|
return { ok: true, userId: sessionUserId }
|
|
}
|