Files
accounted/lib/auth/oauth-flow-binding.ts
T
MattssonandClaude Fable 5.1 c634cf9ae0 fix(banking): return Enable Banking consent callbacks to the initiating brand host (#2371)
* fix(banking): return Enable Banking consent callbacks to the initiating brand host

Enable Banking redirects every consent to the one canonical callback URL
while browser sessions are per host, so a white-label user reached the
callback signed out and was bounced to the unbranded canonical login. The
pending row now records the allowlisted origin the flow started from, and
the callback uses it for the login bounce, the success redirect and the
denial banner. The brand host already holds the session, so its /login
forwards straight back into the callback with cookies; the provider
redirect URI stays canonical, nothing changes in the Enable Banking
console. The shared login redirect helper also stops dragging a callback
that arrived on a registered brand host to the canonical login.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YPom7vRc4jiUKuq3YwjCJz

* fix(banking): reload the PostgREST schema cache after adding oauth_origin

Skeptic finding: every other ADD COLUMN migration ends with the NOTIFY,
and without it PostgREST can reject the new column on connect until its
cache refreshes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YPom7vRc4jiUKuq3YwjCJz

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:14:36 +02:00

165 lines
6.7 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 function buildLoginRedirect(request: Request, returnOrigin?: string | null): Response {
const current = new URL(request.url)
const appOrigin = returnOrigin
? resolveTrustedAppOrigin(returnOrigin)
: process.env.NEXT_PUBLIC_APP_URL
? resolveRequestAppOrigin(request)
: 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: 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 }
}