6a85efb00a
* feat(mcp): allowlist Grok's connector callback and document the Grok path Grok custom connectors self-register through /api/mcp-oauth/register with redirect_uri https://grok.com/connectors-oauth-exchange-code/, which the built-in allowlist rejected with invalid_redirect_uri before consent. Add the callback as an exact-path BUILT_IN_PATTERNS entry (trailing slash optional, no prefix) with provider 'grok', named "Grok (xAI)" on the consent page. Tests: accept, foreign-host and other-path rejection, provider mapping, and a register route test for the Grok DCR shape. Surface Grok next to ChatGPT: a "Using Grok?" side door on the onboarding Claude step (one side door open at a time, telemetry step grok), a Grok row under "Other clients" in the API & MCP settings tab using ?client=grok, and sv/en strings for both. Docs: mcp-server rule, ARCHITECTURE, README, registry entry (install section), DECISIONS. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGbspj3hiNqvqTWZqdwysa Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(mcp): cite X Corp's published Grok callback, test the consent label Review pass on #2158: the allowlist comment and DECISIONS entry claimed xAI publishes no callback and the value came from a live observation; X Corp lists https://grok.com/connectors-oauth-exchange-code/ as the "Grok (web)" redirect URL at docs.x.com/x-ads-api/mcp, and grok.com serves the path itself (slash form 308s to no-slash on the same origin). Reworded both to cite that. Adds the consent-page test for "Grok (xAI)" next to the ChatGPT one and a JSDoc on the onboarding side-door toggle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGbspj3hiNqvqTWZqdwysa Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
245 lines
10 KiB
TypeScript
245 lines
10 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 connector from a look-alike registration.
|
|
*/
|
|
export type BuiltInProvider = 'claude' | 'chatgpt' | 'grok' | 'local'
|
|
|
|
/**
|
|
* Built-in redirect URI patterns. These bypass the DB lookup entirely so
|
|
* the Claude, ChatGPT and Grok 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.
|
|
*/
|
|
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: /^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 }
|
|
}
|