Files
accounted/lib/auth/oauth-allowlist.ts
T
Mattsson bc5da12372 fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds (#2225)
* fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds

Cursor (IDE, CLI, and Grok Bot on top of it) registers three redirect URIs
in one /register request: cursor://anysphere.cursor-mcp/oauth/callback,
https://www.cursor.com/agents/mcp/oauth/callback and
http://localhost:8787/callback. Only the loopback matched a built-in
pattern and /register fails the whole set on any unknown URI, so every
Cursor connection to the URL we hand out in Settings died with
"Redirect URI not allowed". Users cannot self-register the cursor://
form either (the settings panel requires https).

Add a built-in `cursor` provider with the two non-loopback callbacks as
exact matches (no cursor.com prefix), name it "Cursor (Anysphere)" on
the consent page, list the pre-approved clients in the OAuth clients
settings text (sv + en) and the mcp-server rules, and cover the
register, allowlist and consent paths with tests.

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

* fix(mcp-oauth): show the cursor:// deeplink unverified and let CSP pass its post-consent redirect

Review findings on #2225, one pass:

- Skeptic (correctness), REFUTED: new URL('cursor://...').origin is the
  string "null", so the consent page emitted form-action 'self' null and
  Chromium would block the 303 to the deeplink after Allow. The header
  now uses the scheme-source (cursor:) when the origin is opaque; a test
  pins the header on the cursor:// URI.
- Skeptic (security), CodeRabbit (Major) and Superagent (P2): a custom
  scheme can be claimed by any local app (RFC 8252 section 8.4), so it
  must not be presented as a vendor-verified callback. The deeplink is
  its own provider, cursor_deeplink, rendered "Cursor (Anysphere)" with
  the localhost tag "Din egen dator" and verified: false. The https
  cursor.com callback keeps the verified label. A test pins that a code
  minted without a code_challenge can never be exchanged, which is what
  keeps a scheme hijack from turning into a token.
- CodeRabbit (Minor): the rules doc now says the Grok callback matches
  with or without the trailing slash.
- Regression skeptic: docs/WHITELABEL.md listed only Claude and
  localhost and pointed at the wrong file; now lists the built-ins and
  points at lib/auth/oauth-allowlist.ts.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 15:33:58 +02:00

264 lines
11 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClientNoCookies } from './api-keys'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { scopeKind, type ApiKeyScope } from './scope-catalog'
/**
* What an OAuth consent may grant: which redirect URIs a code may be sent
* to, who the client behind a URI is, and which scopes the consenting user's
* role in the selected company permits.
*/
// ── Redirect URI allowlist ─────────────────────────────────────
/**
* Identity of a built-in client, derived from the redirect URI pattern that
* matched. Rendered on the consent page so the user can tell a real Claude /
* ChatGPT / Grok / Cursor connector from a look-alike registration.
*/
export type BuiltInProvider = 'claude' | 'chatgpt' | 'grok' | 'cursor' | 'cursor_deeplink' | 'local'
/**
* Built-in redirect URI patterns. These bypass the DB lookup entirely so
* the Claude, ChatGPT, Grok and Cursor connectors keep working without seeded rows, and so
* local development never depends on having a registration.
*
* ChatGPT uses a per-connector-instance callback path
* (https://chatgpt.com/connector/oauth/{callback_id}) plus the legacy fixed
* callback for already-published apps; both are documented at
* developers.openai.com/apps-sdk/build/auth.
*
* Grok (grok.com custom connectors) registers itself through /register as a
* public client and sends a single fixed callback,
* https://grok.com/connectors-oauth-exchange-code/, published by X Corp as
* the "Grok (web)" redirect URL at docs.x.com/x-ads-api/mcp (xAI's own
* connector docs at docs.x.ai do not state it). grok.com serves the path
* itself: the slash form 308s to the no-slash form on the same origin, so
* both are accepted. Matched as an exact path, never a prefix, so a future
* grok.com path cannot ride on this entry.
*
* Cursor (IDE and CLI) registers through /register with three redirect URIs
* in one request: the legacy custom-scheme deeplink
* cursor://anysphere.cursor-mcp/oauth/callback, the web fallback used by
* Cloud Agents and Automations https://www.cursor.com/agents/mcp/oauth/callback,
* and the RFC 8252 loopback http://localhost:8787/callback that current
* builds actually redirect to (Cursor staff statement, forum.cursor.com
* thread 165019). /register rejects the whole set when any one URI is
* unknown, so the two non-loopback callbacks are allowlisted here as exact
* matches; the loopback already passes through the local rule. The custom
* scheme is accepted despite RFC 8252 section 8.4 (any local app can claim a
* scheme) because the code is PKCE-bound to the client that started the flow
* (S256 only, verifier required at /token, a code minted without a challenge
* can never be exchanged) and the loopback form carries the same
* local-machine trust. It is its own provider, `cursor_deeplink`, so the
* consent page can show it unverified like localhost: a scheme proves
* nothing about who receives the code, an https host does.
*/
const BUILT_IN_PATTERNS: readonly { pattern: RegExp; provider: BuiltInProvider }[] = [
{ pattern: /^https:\/\/claude\.ai\/api\//, provider: 'claude' },
{ pattern: /^https:\/\/claude\.com\/api\//, provider: 'claude' },
{ pattern: /^https:\/\/chatgpt\.com\/connector\/oauth\//, provider: 'chatgpt' },
{ pattern: /^https:\/\/chatgpt\.com\/connector_platform_oauth_redirect$/, provider: 'chatgpt' },
{ pattern: /^https:\/\/grok\.com\/connectors-oauth-exchange-code\/?$/, provider: 'grok' },
{ pattern: /^cursor:\/\/anysphere\.cursor-mcp\/oauth\/callback$/, provider: 'cursor_deeplink' },
{ pattern: /^https:\/\/www\.cursor\.com\/agents\/mcp\/oauth\/callback$/, provider: 'cursor' },
{ pattern: /^http:\/\/localhost(:\d+)?(\/|$)/, provider: 'local' },
{ pattern: /^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/, provider: 'local' },
]
export const BUILT_IN_REDIRECT_PATTERNS: readonly RegExp[] = BUILT_IN_PATTERNS.map((p) => p.pattern)
/** Which built-in client a redirect URI belongs to, or null when none matches. */
export function builtInRedirectProvider(uri: string): BuiltInProvider | null {
if (typeof uri !== 'string') return null
return BUILT_IN_PATTERNS.find(({ pattern }) => pattern.test(uri))?.provider ?? null
}
export function isBuiltInRedirectUri(uri: string): boolean {
return builtInRedirectProvider(uri) !== null
}
export type RedirectUriResolution =
| { allowed: true; kind: 'built_in'; provider: BuiltInProvider }
| {
allowed: true
kind: 'registered'
/** Display name the registering user gave the client (settings UI). */
clientName: string
/** True when the consenting user registered the URI themselves. */
registeredByConsentingUser: boolean
}
| { allowed: false }
export interface RedirectUriOptions {
/**
* The user about to consent at /authorize. When set, a DB-registered URI is
* accepted only if this user registered it, or shares at least one company
* with the user who did. Any authenticated user can insert into
* oauth_client_registrations (RLS: user_id = auth.uid()), so without this
* binding a stranger's registration would be a valid phishing target for
* every account on the instance. Built-in patterns are unaffected.
*
* Omitted by the anonymous /register endpoint, which has no user to bind
* to: it accepts any active registration, which is harmless because the
* code is only ever minted at /authorize where the binding is enforced.
*/
consentingUserId?: string
}
type RegistrationRow = { id: string; user_id: string; client_name: string }
/**
* Resolve a redirect URI to the client behind it. Built-in patterns
* short-circuit; otherwise we look for a non-revoked registration in
* oauth_client_registrations and, when a consenting user is given, check
* that the registration is theirs or a colleague's.
*
* The lookup runs with the service role. The table's SELECT policy is
* user_id = auth.uid(), so a user-scoped client cannot see a colleague's
* registration at all; the trust boundary is instead the explicit binding to
* `consentingUserId` below (SOC 2 CC6.1). Callers may pass a client (the
* /register endpoint already holds one); otherwise one is constructed here.
*
* Fails closed on any error (client construction, DB query): for an
* allowlist, "unknown → deny" is the safe default. The lookup is by exact
* URI; the unique partial index on the table ensures at most one active row.
*/
export async function resolveRedirectUri(
uri: string,
supabase?: SupabaseClient,
options: RedirectUriOptions = {},
): Promise<RedirectUriResolution> {
if (typeof uri !== 'string' || uri.length === 0) return { allowed: false }
const provider = builtInRedirectProvider(uri)
if (provider) return { allowed: true, kind: 'built_in', provider }
// Service-role client construction can throw when Supabase env vars are
// absent (unit tests, misconfigured deploys). Treat that as "not allowed":
// failing closed is the safe default for an allowlist.
let client: SupabaseClient
try {
client = supabase ?? createServiceClientNoCookies()
} catch {
return { allowed: false }
}
const { data, error } = await client
.from('oauth_client_registrations')
.select('id, user_id, client_name')
.eq('redirect_uri', uri)
.is('revoked_at', null)
.limit(1)
.maybeSingle()
if (error || !data) return { allowed: false }
const registration = data as RegistrationRow
const { consentingUserId } = options
if (consentingUserId === undefined) {
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: false }
}
if (registration.user_id === consentingUserId) {
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: true }
}
const shared = await usersShareCompany(client, consentingUserId, registration.user_id)
if (!shared) return { allowed: false }
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: false }
}
/**
* Boolean view of resolveRedirectUri, kept for the callers that only need
* the allow/deny answer (the /register endpoint and its tests).
*/
export async function isAllowedRedirectUri(
uri: string,
supabase?: SupabaseClient,
options: RedirectUriOptions = {},
): Promise<boolean> {
const resolution = await resolveRedirectUri(uri, supabase, options)
return resolution.allowed
}
/**
* True when the two users are both members of at least one common company.
* Both membership lists are paginated (a byrå consultant can sit in hundreds
* of companies) and intersected here rather than via an `.in()` filter, whose
* URL length would grow with the membership count. Any query failure counts
* as "not shared" (fail closed).
*/
async function usersShareCompany(
client: SupabaseClient,
userA: string,
userB: string,
): Promise<boolean> {
try {
const companyIdsOf = (userId: string) =>
fetchAllRows<{ company_id: string }>(({ from, to }) =>
client
.from('company_members')
.select('company_id')
.eq('user_id', userId)
.order('id', { ascending: true })
.range(from, to),
)
const [rowsA, rowsB] = await Promise.all([companyIdsOf(userA), companyIdsOf(userB)])
const companiesA = new Set(rowsA.map((r) => r.company_id))
return rowsB.some((r) => companiesA.has(r.company_id))
} catch {
return false
}
}
// ── Role ceiling ──────────────────────────────────────────────
/** Company roles whose members may hold write, manage, approve or signoff scopes. */
const WRITER_ROLES: ReadonlySet<string> = new Set(['owner', 'admin', 'member'])
/**
* Cap a scope set to what the consenting user's role in the selected company
* permits. Mirrors the app's own gate: `viewer` is read-only everywhere
* (withRouteContext requireWrite, the DB-level enforce_company_writer_role
* trigger), while owner/admin/member may hold every scope. The stage+approve
* segregation-of-duties combination is not capped by role, matching
* app/api/settings/api-keys (warn, acknowledge, record), so the consent page
* states the rule and the token route records the acknowledgement.
*
* A null role (no membership row) or an unrecognised role string caps to
* read-only: an unknown privilege level must never widen the grant.
*/
export function capScopesForRole(
scopes: readonly ApiKeyScope[],
role: string | null,
): ApiKeyScope[] {
if (role !== null && WRITER_ROLES.has(role)) return [...scopes]
return scopes.filter((s) => scopeKind(s) === 'read')
}
export type CompanyRoleLookup =
| { role: string | null; error: null }
| { role: null; error: string }
/**
* The user's role in a company, or null when no membership row exists. A
* failed query is reported separately so callers can fail loudly instead of
* silently downgrading (or widening) a grant on a transient error.
*/
export async function lookupCompanyRole(
supabase: SupabaseClient,
userId: string,
companyId: string,
): Promise<CompanyRoleLookup> {
const { data, error } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (error) return { role: null, error: error.message }
const role = (data as { role?: unknown } | null)?.role
return { role: typeof role === 'string' ? role : null, error: null }
}