Files
accounted/lib/auth/oauth-flow-binding.ts
T
Mattsson d29a5bda14 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>
2026-09-07 15:12:22 +02:00

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