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>
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||
import {
|
||
builtInRedirectProvider,
|
||
capScopesForRole,
|
||
isAllowedRedirectUri,
|
||
isBuiltInRedirectUri,
|
||
lookupCompanyRole,
|
||
resolveRedirectUri,
|
||
} from '../oauth-allowlist'
|
||
import { ALL_SCOPES, type ApiKeyScope } from '../scope-catalog'
|
||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
|
||
describe('isBuiltInRedirectUri', () => {
|
||
it.each([
|
||
['https://claude.ai/api/oauth/callback', true],
|
||
['https://claude.com/api/oauth/callback', true],
|
||
['https://chatgpt.com/connector/oauth/abc123', true],
|
||
['https://chatgpt.com/connector_platform_oauth_redirect', true],
|
||
['https://chatgpt.com/connector_platform_oauth_redirect/extra', false],
|
||
['https://chatgpt.com/other/path', false],
|
||
['https://chatgpt.com.evil.com/connector/oauth/x', false],
|
||
['https://grok.com/connectors-oauth-exchange-code/', true],
|
||
['https://grok.com/connectors-oauth-exchange-code', true],
|
||
['https://grok.com/connectors-oauth-exchange-code/extra', false],
|
||
['https://grok.com/connectors-oauth-exchange-code/?next=x', false],
|
||
['https://grok.com/other/path', false],
|
||
['https://grok.com.evil.com/connectors-oauth-exchange-code/', false],
|
||
['http://grok.com/connectors-oauth-exchange-code/', false],
|
||
['http://localhost:3000/cb', true],
|
||
['http://localhost/cb', true],
|
||
['http://127.0.0.1:8080/cb', true],
|
||
['https://evil.com/cb', false],
|
||
['https://example.com/api/foo', false],
|
||
['ftp://localhost/cb', false],
|
||
['', false],
|
||
])('classifies %s as %s', (uri, expected) => {
|
||
expect(isBuiltInRedirectUri(uri)).toBe(expected)
|
||
})
|
||
})
|
||
|
||
describe('builtInRedirectProvider', () => {
|
||
it.each([
|
||
['https://claude.ai/api/oauth/callback', 'claude'],
|
||
['https://claude.com/api/oauth/callback', 'claude'],
|
||
['https://chatgpt.com/connector/oauth/abc123', 'chatgpt'],
|
||
['https://chatgpt.com/connector_platform_oauth_redirect', 'chatgpt'],
|
||
['https://grok.com/connectors-oauth-exchange-code/', 'grok'],
|
||
['https://grok.com/connectors-oauth-exchange-code/extra', null],
|
||
['http://localhost:3000/cb', 'local'],
|
||
['http://127.0.0.1:8080/cb', 'local'],
|
||
['https://claude-login.example/cb', null],
|
||
['', null],
|
||
])('maps %s to %s', (uri, expected) => {
|
||
expect(builtInRedirectProvider(uri)).toBe(expected)
|
||
})
|
||
})
|
||
|
||
type Row = Record<string, unknown>
|
||
|
||
/**
|
||
* Minimal PostgREST-shaped fake over in-memory tables: eq/is filters are
|
||
* applied, everything else is a no-op, and the chain resolves to the filtered
|
||
* rows (all rows when awaited, first row via maybeSingle). `failTable` makes
|
||
* every query against that table return a DB error.
|
||
*/
|
||
function fakeClient(tables: Record<string, Row[]>, failTable?: string) {
|
||
const from = vi.fn((table: string) => {
|
||
const filters: [string, unknown][] = []
|
||
const run = () =>
|
||
(tables[table] ?? []).filter((row) =>
|
||
filters.every(([col, val]) => (val === null ? row[col] == null : row[col] === val)),
|
||
)
|
||
const result = () =>
|
||
table === failTable
|
||
? { data: null, error: { message: 'db down' } }
|
||
: { data: run(), error: null }
|
||
const chain: Record<string, unknown> = {}
|
||
for (const method of ['select', 'order', 'range', 'limit']) chain[method] = () => chain
|
||
chain.eq = (col: string, val: unknown) => {
|
||
filters.push([col, val])
|
||
return chain
|
||
}
|
||
chain.is = (col: string, val: unknown) => {
|
||
filters.push([col, val])
|
||
return chain
|
||
}
|
||
chain.maybeSingle = async () => {
|
||
const r = result()
|
||
return { data: Array.isArray(r.data) ? (r.data[0] ?? null) : null, error: r.error }
|
||
}
|
||
chain.then = (resolve: (v: unknown) => void) => resolve(result())
|
||
return chain
|
||
})
|
||
return { from } as unknown as SupabaseClient & { from: ReturnType<typeof vi.fn> }
|
||
}
|
||
|
||
const REGISTRATIONS: Row[] = [
|
||
{ id: 'reg-1', user_id: 'user-2', client_name: 'Byråns bot', redirect_uri: 'https://app.example.com/cb', revoked_at: null },
|
||
{ id: 'reg-2', user_id: 'user-9', client_name: 'Evil', redirect_uri: 'https://evil.example/cb', revoked_at: null },
|
||
{ id: 'reg-3', user_id: 'user-1', client_name: 'Min egen app', redirect_uri: 'https://mine.example/cb', revoked_at: null },
|
||
{ id: 'reg-4', user_id: 'user-1', client_name: 'Gammal app', redirect_uri: 'https://old.example/cb', revoked_at: '2026-01-01T00:00:00Z' },
|
||
]
|
||
|
||
const MEMBERSHIPS: Row[] = [
|
||
{ id: 'm1', user_id: 'user-1', company_id: 'company-1', role: 'owner' },
|
||
{ id: 'm2', user_id: 'user-2', company_id: 'company-1', role: 'member' },
|
||
{ id: 'm3', user_id: 'user-2', company_id: 'company-2', role: 'owner' },
|
||
{ id: 'm4', user_id: 'user-9', company_id: 'company-9', role: 'owner' },
|
||
]
|
||
|
||
const DB = { oauth_client_registrations: REGISTRATIONS, company_members: MEMBERSHIPS }
|
||
|
||
describe('resolveRedirectUri', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs()
|
||
})
|
||
|
||
it('short-circuits to the built-in provider without touching the DB', async () => {
|
||
const sb = {
|
||
from: vi.fn(() => {
|
||
throw new Error('should not be called')
|
||
}),
|
||
} as unknown as SupabaseClient
|
||
expect(await resolveRedirectUri('https://claude.ai/api/cb', sb, { consentingUserId: 'user-1' })).toEqual({
|
||
allowed: true,
|
||
kind: 'built_in',
|
||
provider: 'claude',
|
||
})
|
||
expect(await resolveRedirectUri('http://localhost:3000/cb', sb)).toEqual({
|
||
allowed: true,
|
||
kind: 'built_in',
|
||
provider: 'local',
|
||
})
|
||
})
|
||
|
||
it("accepts the consenting user's own registration", async () => {
|
||
const result = await resolveRedirectUri('https://mine.example/cb', fakeClient(DB), {
|
||
consentingUserId: 'user-1',
|
||
})
|
||
expect(result).toEqual({
|
||
allowed: true,
|
||
kind: 'registered',
|
||
clientName: 'Min egen app',
|
||
registeredByConsentingUser: true,
|
||
})
|
||
})
|
||
|
||
it('accepts a registration by a colleague who shares a company', async () => {
|
||
const result = await resolveRedirectUri('https://app.example.com/cb', fakeClient(DB), {
|
||
consentingUserId: 'user-1',
|
||
})
|
||
expect(result).toEqual({
|
||
allowed: true,
|
||
kind: 'registered',
|
||
clientName: 'Byråns bot',
|
||
registeredByConsentingUser: false,
|
||
})
|
||
})
|
||
|
||
it('rejects a registration by an unrelated user', async () => {
|
||
// user-9 is a real member of the instance, just not of any company user-1
|
||
// belongs to: their registration must not be a valid target for user-1.
|
||
const result = await resolveRedirectUri('https://evil.example/cb', fakeClient(DB), {
|
||
consentingUserId: 'user-1',
|
||
})
|
||
expect(result).toEqual({ allowed: false })
|
||
})
|
||
|
||
it('accepts any active registration when no consenting user is given (anonymous /register)', async () => {
|
||
const result = await resolveRedirectUri('https://evil.example/cb', fakeClient(DB))
|
||
expect(result).toEqual({
|
||
allowed: true,
|
||
kind: 'registered',
|
||
clientName: 'Evil',
|
||
registeredByConsentingUser: false,
|
||
})
|
||
})
|
||
|
||
it('rejects a revoked registration, even the user’s own', async () => {
|
||
const result = await resolveRedirectUri('https://old.example/cb', fakeClient(DB), {
|
||
consentingUserId: 'user-1',
|
||
})
|
||
expect(result).toEqual({ allowed: false })
|
||
})
|
||
|
||
it('rejects an unknown URI', async () => {
|
||
expect(await resolveRedirectUri('https://nowhere.example/cb', fakeClient(DB))).toEqual({ allowed: false })
|
||
})
|
||
|
||
it('fails closed when the registration lookup errors', async () => {
|
||
const result = await resolveRedirectUri(
|
||
'https://mine.example/cb',
|
||
fakeClient(DB, 'oauth_client_registrations'),
|
||
{ consentingUserId: 'user-1' },
|
||
)
|
||
expect(result).toEqual({ allowed: false })
|
||
})
|
||
|
||
it('fails closed when the shared-company check errors', async () => {
|
||
const result = await resolveRedirectUri('https://app.example.com/cb', fakeClient(DB, 'company_members'), {
|
||
consentingUserId: 'user-1',
|
||
})
|
||
expect(result).toEqual({ allowed: false })
|
||
})
|
||
|
||
it('fails closed when no client is given and none can be constructed', async () => {
|
||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '')
|
||
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', '')
|
||
expect(await resolveRedirectUri('https://mine.example/cb', undefined, { consentingUserId: 'user-1' })).toEqual({
|
||
allowed: false,
|
||
})
|
||
})
|
||
|
||
it('returns not allowed for empty / non-string inputs', async () => {
|
||
expect(await resolveRedirectUri('')).toEqual({ allowed: false })
|
||
expect(await resolveRedirectUri(undefined as unknown as string)).toEqual({ allowed: false })
|
||
})
|
||
})
|
||
|
||
describe('isAllowedRedirectUri', () => {
|
||
it('is the boolean view of resolveRedirectUri', async () => {
|
||
expect(await isAllowedRedirectUri('https://claude.ai/api/cb')).toBe(true)
|
||
expect(await isAllowedRedirectUri('https://mine.example/cb', fakeClient(DB), { consentingUserId: 'user-1' })).toBe(true)
|
||
expect(await isAllowedRedirectUri('https://evil.example/cb', fakeClient(DB), { consentingUserId: 'user-1' })).toBe(false)
|
||
expect(await isAllowedRedirectUri('https://evil.example/cb', fakeClient(DB))).toBe(true)
|
||
expect(await isAllowedRedirectUri('', fakeClient(DB))).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('capScopesForRole', () => {
|
||
const mixed: ApiKeyScope[] = [
|
||
'transactions:read',
|
||
'transactions:write',
|
||
'pending_operations:approve',
|
||
'webhooks:manage',
|
||
'reconciliation:signoff',
|
||
'reports:read',
|
||
]
|
||
|
||
it('keeps every scope for owner, admin and member', () => {
|
||
for (const role of ['owner', 'admin', 'member']) {
|
||
expect(capScopesForRole(mixed, role)).toEqual(mixed)
|
||
}
|
||
})
|
||
|
||
it('caps a viewer to :read scopes only', () => {
|
||
expect(capScopesForRole(mixed, 'viewer')).toEqual(['transactions:read', 'reports:read'])
|
||
})
|
||
|
||
it('caps an unknown role and a missing membership to :read scopes', () => {
|
||
expect(capScopesForRole(mixed, 'superuser')).toEqual(['transactions:read', 'reports:read'])
|
||
expect(capScopesForRole(mixed, null)).toEqual(['transactions:read', 'reports:read'])
|
||
})
|
||
|
||
it('leaves no write-kind scope in a capped set for the full catalogue', () => {
|
||
const capped = capScopesForRole(ALL_SCOPES, 'viewer')
|
||
expect(capped.length).toBeGreaterThan(0)
|
||
expect(capped.every((s) => s.endsWith(':read'))).toBe(true)
|
||
expect(capped).not.toContain('pending_operations:approve')
|
||
})
|
||
|
||
it('returns a copy, never the caller’s array', () => {
|
||
const result = capScopesForRole(mixed, 'owner')
|
||
expect(result).not.toBe(mixed)
|
||
})
|
||
})
|
||
|
||
describe('lookupCompanyRole', () => {
|
||
it('returns the role for an existing membership', async () => {
|
||
expect(await lookupCompanyRole(fakeClient(DB), 'user-2', 'company-1')).toEqual({ role: 'member', error: null })
|
||
expect(await lookupCompanyRole(fakeClient(DB), 'user-2', 'company-2')).toEqual({ role: 'owner', error: null })
|
||
})
|
||
|
||
it('returns a null role when no membership row exists', async () => {
|
||
expect(await lookupCompanyRole(fakeClient(DB), 'user-9', 'company-1')).toEqual({ role: null, error: null })
|
||
})
|
||
|
||
it('surfaces a query error instead of guessing', async () => {
|
||
const result = await lookupCompanyRole(fakeClient(DB, 'company_members'), 'user-1', 'company-1')
|
||
expect(result.role).toBeNull()
|
||
expect(result.error).toBe('db down')
|
||
})
|
||
})
|