fix(mcp): grants are visible, revocable and bounded — tie to approving key, /v1/oauth-grants, 90-day family end, replay-safe mint, member removal, purge, per-IP token limit (migration 0028)

This commit is contained in:
Neil
2026-11-09 05:04:16 -08:00
parent a13e17cd46
commit 00a881f14b
9 changed files with 412 additions and 38 deletions
@@ -0,0 +1,23 @@
-- MCP OAuth grants (src/mcp/oauth.ts): tie grants to the credential that approved them, bound their
-- lifetime, remember revoked families and let housekeeping purge abandoned registrations. Additive.
-- The orca_sk_ key that approved the grant (NULL: a desktop approval code). Revoking that key
-- revokes every token of the grant.
ALTER TABLE oauth_codes ADD COLUMN grantor_key_id TEXT;
-- Set when a used code is presented again (replay), so a racing exchange revokes what it minted.
ALTER TABLE oauth_codes ADD COLUMN replayed_at INTEGER;
ALTER TABLE api_keys ADD COLUMN grantor_key_id TEXT;
-- Absolute end of a grant (90 days from the code exchange): refresh is refused after it.
ALTER TABLE api_keys ADD COLUMN family_expires_at INTEGER;
CREATE INDEX api_keys_grantor ON api_keys (grantor_key_id) WHERE grantor_key_id IS NOT NULL;
-- Revoked grant families. Checked after minting, so a token minted concurrently with a revocation
-- or replay never survives it.
CREATE TABLE oauth_revoked_families (
family_id TEXT PRIMARY KEY,
reason TEXT NOT NULL,
revoked_at INTEGER NOT NULL
);
-- Last successful code exchange; registrations that never complete one are purged after a day.
ALTER TABLE oauth_clients ADD COLUMN last_granted_at INTEGER;
+20 -10
View File
@@ -43,6 +43,9 @@ export type ApiKeyRow = {
client_id?: string | null
audience?: string | null
family_id?: string | null
/** Migration 0028: the orca_sk_ key that approved the grant, and the grant's absolute end. */
grantor_key_id?: string | null
family_expires_at?: number | null
}
const TAG: Record<ApiKeyKind, string> = { user: 'sk', workspace: 'ws', oauth: 'oat', oauth_refresh: 'ort' }
@@ -95,7 +98,7 @@ export async function mintApiKey(
kind?: ApiKeyKind
sandboxId?: string | null
expiresAt?: number | null
oauth?: { clientId: string; audience: string; familyId: string }
oauth?: { clientId: string; audience: string; familyId: string; grantorKeyId: string | null; familyExpiresAt: number }
},
now: number
): Promise<MintedKey> {
@@ -120,17 +123,19 @@ export async function mintApiKey(
revoked_by: null,
client_id: input.oauth?.clientId ?? null,
audience: input.oauth?.audience ?? null,
family_id: input.oauth?.familyId ?? null
family_id: input.oauth?.familyId ?? null,
grantor_key_id: input.oauth?.grantorKeyId ?? null,
family_expires_at: input.oauth?.familyExpiresAt ?? null
}
await db
.prepare(
`INSERT INTO api_keys (id, account_id, created_by, creator_role, name, prefix, secret_hash, scopes, kind,
sandbox_id, expires_at, created_at${row.client_id ? ', client_id, audience, family_id' : ''})
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?${row.client_id ? ', ?, ?, ?' : ''})`
sandbox_id, expires_at, created_at${row.client_id ? ', client_id, audience, family_id, grantor_key_id, family_expires_at' : ''})
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?${row.client_id ? ', ?, ?, ?, ?, ?' : ''})`
)
.bind(row.id, row.account_id, row.created_by, row.creator_role, row.name, row.prefix, row.secret_hash,
row.scopes, row.kind, row.sandbox_id, row.expires_at, row.created_at,
...(row.client_id ? [row.client_id, row.audience, row.family_id] : []))
...(row.client_id ? [row.client_id, row.audience, row.family_id, row.grantor_key_id, row.family_expires_at] : []))
.run()
return { row, secret }
}
@@ -173,11 +178,16 @@ export function getApiKey(db: D1Database, id: string): Promise<ApiKeyRow | null>
}
export async function revokeApiKey(db: D1Database, id: string, by: string, now: number): Promise<boolean> {
const result = await db
.prepare('UPDATE api_keys SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL')
.bind(now, by, id)
.run()
return result.meta.changes === 1
const [result] = await db.batch([
db.prepare('UPDATE api_keys SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL').bind(now, by, id),
// MCP grants approved with this key die with it (their families stay revoked, see oauth.ts).
db
.prepare(`INSERT OR IGNORE INTO oauth_revoked_families (family_id, reason, revoked_at)
SELECT DISTINCT family_id, 'grantor_revoked', ? FROM api_keys WHERE grantor_key_id = ? AND family_id IS NOT NULL`)
.bind(now, id),
db.prepare(`UPDATE api_keys SET revoked_at = ?, revoked_by = 'system:grantor_revoked' WHERE grantor_key_id = ? AND revoked_at IS NULL`).bind(now, id)
])
return result!.meta.changes === 1
}
/** Workspace tokens die with their session: revoked on every new boot, suspend and destroy. */
@@ -196,6 +196,38 @@ export const OPERATIONS: Record<string, Record<string, Op>> = {
responses: { 201: { description: 'Created', schema: obj({ code: { type: 'string' }, expiresAt: { type: 'integer' }, scopes: { type: 'array', items: { type: 'string' } } }) } }
}
},
'/v1/oauth-grants': {
get: {
tags: ['API keys'],
sessionOnly: true,
summary: 'List connected MCP apps',
description: 'Live OAuth grants: members see their own, owners/admins the whole account. A grant ends 90 days after approval, when revoked here, or when the API key that approved it is revoked.',
responses: {
200: {
description: 'Grants',
schema: obj({
grants: {
type: 'array',
items: obj({
id: { type: 'string' },
clientId: { type: 'string' },
clientName: { type: ['string', 'null'] },
userId: { type: 'string' },
scopes: { type: 'array', items: { type: 'string' } },
approvedWith: { type: 'object' },
grantedAt: { type: 'integer' },
lastUsedAt: { type: ['integer', 'null'] },
expiresAt: { type: ['integer', 'null'] }
})
}
})
}
}
}
},
'/v1/oauth-grants/{id}': {
delete: { tags: ['API keys'], sessionOnly: true, summary: 'Disconnect an MCP app (revoke every token of the grant)', params: [idParam('id', 'Grant id (fam_…)')], responses: { 200: 'Revoked' } }
},
'/v1/settings': {
get: { tags: ['Account'], summary: 'Programmatic-access settings', responses: { 200: { description: 'Settings', schema: obj({ settings: obj({ vmCliEnabled: { type: 'boolean' } }) }) } } },
put: { tags: ['Account'], sessionOnly: true, summary: 'Update settings (owner/admin)', body: obj({ vmCliEnabled: { type: 'boolean' } }, ['vmCliEnabled']), responses: { 200: 'Updated' } }
+1 -1
View File
@@ -130,7 +130,7 @@ function messageBucket(caller: ApiCaller): string {
}
export function isApiPath(pathname: string): boolean {
return /^\/v1\/(me|api-keys|settings|audit|projects|workspaces|routines|oauth-approvals)(\/|$)/.test(pathname)
return /^\/v1\/(me|api-keys|settings|audit|projects|workspaces|routines|oauth-approvals|oauth-grants)(\/|$)/.test(pathname)
}
export async function handleApi(request: Request, url: URL, env: Parameters<typeof authenticateApi>[0], ctx: ApiContext): Promise<Response> {
+8 -2
View File
@@ -16,7 +16,8 @@ import { maintainPool, poolConfigFromEnv } from './pool'
import { authenticateDesktop } from './auth'
import { authenticateApi } from './api/caller'
import { handleApi, handleApiAs, isApiPath, type ApiContext, type ApiExtension } from './api/router'
import { handleMcpRoutes, isMcpPath, oauthApprovalsApi } from './mcp'
import { handleMcpRoutes, isMcpPath, oauthApprovalsApi, oauthGrantsApi } from './mcp'
import { purgeOAuth } from './mcp/oauth'
import { retryPendingMessages } from './api/messages'
import { revokeWorkspaceTokens } from './api/keys'
import { provisionWorkspaceToken } from './api/settings'
@@ -301,7 +302,7 @@ async function apiContext(env: Env, deps: Deps, request: Request, life?: Promise
life: await (life ?? lifecycleDeps(env, deps)),
waitUntil: deps.waitUntil ?? ((promise) => void promise.catch(() => undefined)),
readJson: () => readJson(request),
extensions: [oauthApprovalsApi, ...(deps.apiExtensions ?? [])]
extensions: [oauthApprovalsApi, oauthGrantsApi,...(deps.apiExtensions ?? [])]
}
}
@@ -691,6 +692,11 @@ export default {
console.log(JSON.stringify({ event: 'message_retry', retried }))
}
}
// Abandoned MCP client registrations and dead OAuth codes/approvals.
const purged = await purgeOAuth(env.SANDBOX_DB, Date.now()).catch(() => -1)
if (purged !== 0) {
console.log(JSON.stringify({ event: 'oauth_purge', purged }))
}
await advanceGoldenImagesFromEnv(env).catch((error: unknown) =>
console.log(JSON.stringify({ event: 'golden_image_tick_failed', error: String(error).slice(0, 200) }))
)
+38 -1
View File
@@ -2,7 +2,7 @@
// /v1/oauth-approvals API route the desktop uses to approve an MCP client.
import { z } from 'zod'
import { auditCaller } from '../api/audit'
import { ApiError, scopesForRole, type ApiCaller } from '../api/caller'
import { ApiError, isAdmin, scopesForRole, type ApiCaller } from '../api/caller'
import { API_SCOPES, type ApiScope } from '../api/keys'
import type { ApiExtension } from '../api/router'
import {
@@ -15,6 +15,10 @@ import {
protectedResourceMetadataUrl,
registerClient,
revoke,
revokeFamily,
grantOwner,
grantView,
listGrants,
token
} from './oauth'
import { handleMcpPost } from './server'
@@ -170,3 +174,36 @@ export const oauthApprovalsApi: ApiExtension = async (request, url, caller, ctx)
await auditCaller(ctx.db, caller, { action: 'mcp.approval_create', targetType: 'account', targetId: caller.accountId, detail: { scopes: requested } }, now)
return Response.json(approval, { status: 201, headers: { 'cache-control': 'no-store' } })
}
/**
* Connected MCP apps (signed-in desktop only): GET /v1/oauth-grants lists live grants (members see
* their own, owners/admins the account's); DELETE /v1/oauth-grants/:id revokes one for good.
*/
export const oauthGrantsApi: ApiExtension = async (request, url, caller, ctx) => {
if (url.pathname !== '/v1/oauth-grants' && !url.pathname.startsWith('/v1/oauth-grants/')) {
return null
}
const match = /^\/v1\/oauth-grants(?:\/(fam_[A-Za-z0-9]{20}))?$/.exec(url.pathname)
if (!match) {
throw new ApiError(404, 'not_found')
}
if (caller.via !== 'session') {
throw new ApiError(403, 'forbidden', 'session_required')
}
const now = ctx.now()
const familyId = match[1]
if (!familyId && request.method === 'GET') {
const rows = await listGrants(ctx.db, caller.accountId, now, isAdmin(caller) ? undefined : caller.userId)
return Response.json({ grants: rows.map(grantView) }, { headers: { 'cache-control': 'no-store' } })
}
if (familyId && request.method === 'DELETE') {
const owner = await grantOwner(ctx.db, caller.accountId, familyId)
if (!owner || (owner.created_by !== caller.userId && !isAdmin(caller))) {
throw new ApiError(404, 'not_found')
}
const revoked = await revokeFamily(ctx.db, familyId, 'user_revoked', now)
await auditCaller(ctx.db, caller, { action: 'mcp.grant_revoke', targetType: 'oauth_grant', targetId: familyId, detail: { clientId: owner.client_id, tokensRevoked: revoked } }, now)
return Response.json({ revoked: true }, { headers: { 'cache-control': 'no-store' } })
}
throw new ApiError(405, 'method_not_allowed')
}
+162 -22
View File
@@ -26,6 +26,11 @@ const MAX_REDIRECT_URIS = 10
const REGISTRATIONS_PER_MINUTE_PER_IP = 10
const CONSENTS_PER_MINUTE_PER_IP = 20
const TOKENS_PER_MINUTE_PER_CLIENT = 60
const TOKENS_PER_MINUTE_PER_IP = 120
/** A grant ends 90 days after its code exchange, however often it is refreshed: re-consent then. */
export const GRANT_TTL_MS = 90 * 86_400_000
/** Registrations that never complete a code exchange are purged after this. */
const UNUSED_CLIENT_TTL_MS = 86_400_000
/** What an MCP client gets when it asks for nothing specific: everything but admin and project edits. */
export const DEFAULT_MCP_SCOPES: readonly ApiScope[] = ['workspaces:read', 'workspaces:write', 'messages:write', 'projects:read', 'routines:read', 'routines:write']
@@ -322,10 +327,10 @@ export async function authorize(request: Request, url: URL, env: OAuthEnv): Prom
const familyId = randomId('fam')
await env.db
.prepare(
`INSERT INTO oauth_codes (code_hash, client_id, account_id, user_id, creator_role, scopes, redirect_uri, code_challenge, resource, expires_at, family_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO oauth_codes (code_hash, client_id, account_id, user_id, creator_role, scopes, redirect_uri, code_challenge, resource, expires_at, family_id, created_at, grantor_key_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(await sha256Hex(code), req.clientId, grantor.accountId, grantor.userId, grantor.role, scopes.join(' '), req.redirectUri, req.codeChallenge, req.resource, now + CODE_TTL_MS, familyId, now)
.bind(await sha256Hex(code), req.clientId, grantor.accountId, grantor.userId, grantor.role, scopes.join(' '), req.redirectUri, req.codeChallenge, req.resource, now + CODE_TTL_MS, familyId, now, grantor.keyId)
.run()
await audit(
env.db,
@@ -360,7 +365,7 @@ function viewFor(req: AuthorizeParams, client: ClientRow, csrf: string, error: s
}
}
type Grantor = { accountId: string; userId: string; role: OrgRole | null; scopes: Set<ApiScope>; via: 'approval_code' | 'api_key' }
type Grantor = { accountId: string; userId: string; role: OrgRole | null; scopes: Set<ApiScope>; via: 'approval_code' | 'api_key'; keyId: string | null }
function orgOf(accountId: string): string | undefined {
return accountId.startsWith('org:') ? accountId.slice(4) : accountId.startsWith('dev:org:') ? accountId.slice(8) : undefined
@@ -375,7 +380,7 @@ async function resolveGrantor(db: D1Database, credential: string, now: number):
.bind(now, hash, now)
.first<{ account_id: string; user_id: string; creator_role: OrgRole | null; scopes: string }>()
if (!claimed) return null
return { accountId: claimed.account_id, userId: claimed.user_id, role: claimed.creator_role, scopes: new Set(parseScopes(claimed.scopes)), via: 'approval_code' }
return { accountId: claimed.account_id, userId: claimed.user_id, role: claimed.creator_role, scopes: new Set(parseScopes(claimed.scopes)), via: 'approval_code', keyId: null }
}
const key = await resolveApiKey(db, credential, now)
// Only a person's own key; workspace and OAuth tokens cannot bootstrap new grants.
@@ -387,7 +392,8 @@ async function resolveGrantor(db: D1Database, credential: string, now: number):
userId: key.created_by,
role: key.creator_role,
scopes: new Set(parseScopes(key.scopes).filter((scope) => allowed.has(scope))),
via: 'api_key'
via: 'api_key',
keyId: key.id
}
}
@@ -413,20 +419,50 @@ async function pkceS256(verifier: string): Promise<string> {
return btoa(String.fromCharCode(...digest)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
}
async function revokeFamily(db: D1Database, familyId: string, now: number): Promise<void> {
await db.prepare(`UPDATE api_keys SET revoked_at = ?, revoked_by = 'system' WHERE family_id = ? AND revoked_at IS NULL`).bind(now, familyId).run()
/** Ends a grant for good: its tokens are revoked and the family is remembered (see issueTokens). */
export async function revokeFamily(db: D1Database, familyId: string, reason: string, now: number): Promise<number> {
const [, revoked] = await db.batch([
db.prepare('INSERT OR IGNORE INTO oauth_revoked_families (family_id, reason, revoked_at) VALUES (?, ?, ?)').bind(familyId, reason, now),
db.prepare(`UPDATE api_keys SET revoked_at = ?, revoked_by = 'system' WHERE family_id = ? AND revoked_at IS NULL`).bind(now, familyId)
])
return revoked!.meta.changes
}
async function familyRevoked(db: D1Database, familyId: string): Promise<boolean> {
return (await db.prepare('SELECT 1 AS x FROM oauth_revoked_families WHERE family_id = ?').bind(familyId).first()) !== null
}
async function issueTokens(
env: OAuthEnv,
grant: { accountId: string; userId: string; role: OrgRole | null; scopes: ApiScope[]; clientId: string; clientName: string | null; familyId: string },
now: number
grant: {
accountId: string
userId: string
role: OrgRole | null
scopes: ApiScope[]
clientId: string
clientName: string | null
familyId: string
grantorKeyId: string | null
familyExpiresAt: number
},
now: number,
/** Re-checked after minting: true when the code was replayed meanwhile. */
replayed: () => Promise<boolean>
): Promise<Response> {
const name = `MCP · ${grant.clientName ?? grant.clientId}`.slice(0, 100)
const oauth = { clientId: grant.clientId, audience: mcpResource(env.origin), familyId: grant.familyId }
const oauth = { clientId: grant.clientId, audience: mcpResource(env.origin), familyId: grant.familyId, grantorKeyId: grant.grantorKeyId, familyExpiresAt: grant.familyExpiresAt }
const base = { accountId: grant.accountId, createdBy: grant.userId, creatorRole: grant.role, name, scopes: grant.scopes, oauth }
const access = await mintApiKey(env.db, { ...base, kind: 'oauth', expiresAt: now + ACCESS_TOKEN_TTL_MS }, now)
const refresh = await mintApiKey(env.db, { ...base, kind: 'oauth_refresh', expiresAt: now + REFRESH_TOKEN_TTL_MS }, now)
const access = await mintApiKey(env.db, { ...base, kind: 'oauth', expiresAt: Math.min(now + ACCESS_TOKEN_TTL_MS, grant.familyExpiresAt) }, now)
const refresh = await mintApiKey(env.db, { ...base, kind: 'oauth_refresh', expiresAt: Math.min(now + REFRESH_TOKEN_TTL_MS, grant.familyExpiresAt) }, now)
// Close the window between claim and mint: a replay, a revoked family or a revoked approving key
// that landed meanwhile takes these new tokens down too.
const grantorGone =
grant.grantorKeyId !== null &&
(await env.db.prepare('SELECT 1 AS x FROM api_keys WHERE id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)').bind(grant.grantorKeyId, now).first()) === null
if (grantorGone || (await replayed()) || (await familyRevoked(env.db, grant.familyId))) {
await revokeFamily(env.db, grant.familyId, grantorGone ? 'grantor_revoked' : 'replay', now)
return oauthError('invalid_grant', grantorGone ? 'The credential that approved this grant was revoked' : 'Grant revoked')
}
return json({
access_token: access.secret,
token_type: 'Bearer',
@@ -449,6 +485,8 @@ type CodeRow = {
expires_at: number
consumed_at: number | null
family_id: string | null
grantor_key_id: string | null
replayed_at: number | null
}
export async function token(request: Request, env: OAuthEnv): Promise<Response> {
@@ -457,6 +495,8 @@ export async function token(request: Request, env: OAuthEnv): Promise<Response>
}
const form = await readForm(request)
const now = env.now()
const tooFast = await limited(env.db, `oauth-token-ip:${clientIp(request)}`, TOKENS_PER_MINUTE_PER_IP, now)
if (tooFast) return tooFast
const client = await getClient(env.db, form.get('client_id'))
if (!client) {
return oauthError('invalid_client', 'Unknown client_id', 401)
@@ -477,21 +517,35 @@ export async function token(request: Request, env: OAuthEnv): Promise<Response>
const hash = await sha256Hex(code)
const row = await env.db.prepare('SELECT * FROM oauth_codes WHERE code_hash = ?').bind(hash).first<CodeRow>()
if (!row || row.client_id !== client.client_id) return oauthError('invalid_grant', 'Invalid authorization code')
if (row.consumed_at !== null) {
const replay = async () => {
// A replayed code means it leaked: everything minted from it dies (OAuth 2.1 §4.1.3).
if (row.family_id) await revokeFamily(env.db, row.family_id, now)
await env.db.prepare('UPDATE oauth_codes SET replayed_at = ? WHERE code_hash = ?').bind(now, hash).run()
if (row.family_id) await revokeFamily(env.db, row.family_id, 'code_replay', now)
await audit(env.db, { accountId: row.account_id, type: 'mcp', id: client.client_id, userId: row.user_id }, { action: 'mcp.code_replay', targetType: 'oauth_client', targetId: client.client_id }, now)
return oauthError('invalid_grant', 'Authorization code already used')
}
if (row.consumed_at !== null) return replay()
if (row.expires_at <= now) return oauthError('invalid_grant', 'Authorization code expired')
if (form.get('redirect_uri') !== null && form.get('redirect_uri') !== row.redirect_uri) return oauthError('invalid_grant', 'redirect_uri does not match')
if (!timingSafeEqual(await pkceS256(verifier), row.code_challenge)) return oauthError('invalid_grant', 'PKCE verification failed')
const claimed = await env.db.prepare('UPDATE oauth_codes SET consumed_at = ? WHERE code_hash = ? AND consumed_at IS NULL').bind(now, hash).run()
if (claimed.meta.changes !== 1) return oauthError('invalid_grant', 'Authorization code already used')
if (claimed.meta.changes !== 1) return replay()
await env.db.prepare('UPDATE oauth_clients SET last_granted_at = ? WHERE client_id = ?').bind(now, client.client_id).run()
return issueTokens(
env,
{ accountId: row.account_id, userId: row.user_id, role: row.creator_role, scopes: parseScopes(row.scopes), clientId: client.client_id, clientName: client.client_name, familyId: row.family_id ?? randomId('fam') },
now
{
accountId: row.account_id,
userId: row.user_id,
role: row.creator_role,
scopes: parseScopes(row.scopes),
clientId: client.client_id,
clientName: client.client_name,
familyId: row.family_id ?? randomId('fam'),
grantorKeyId: row.grantor_key_id,
familyExpiresAt: now + GRANT_TTL_MS
},
now,
async () => (await env.db.prepare('SELECT replayed_at FROM oauth_codes WHERE code_hash = ?').bind(hash).first<{ replayed_at: number | null }>())?.replayed_at != null
)
}
@@ -503,12 +557,17 @@ export async function token(request: Request, env: OAuthEnv): Promise<Response>
if (row.revoked_at !== null) {
// Rotated tokens are single use: a second use means a copy leaked, so end the whole grant.
if (row.family_id && row.revoked_by === 'rotated') {
await revokeFamily(env.db, row.family_id, now)
await revokeFamily(env.db, row.family_id, 'refresh_replay', now)
await audit(env.db, { accountId: row.account_id, type: 'mcp', id: client.client_id, userId: row.created_by }, { action: 'mcp.refresh_replay', targetType: 'oauth_client', targetId: client.client_id }, now)
}
return oauthError('invalid_grant', 'Refresh token revoked')
}
if (row.expires_at !== null && row.expires_at <= now) return oauthError('invalid_grant', 'Refresh token expired')
// Rows from before migration 0028 have no family end: count 90 days from this token.
const familyExpiresAt = row.family_expires_at ?? row.created_at + GRANT_TTL_MS
if (familyExpiresAt <= now) return oauthError('invalid_grant', 'Grant expired; reconnect to approve again')
if (!row.family_id || (await familyRevoked(env.db, row.family_id))) return oauthError('invalid_grant', 'Grant revoked')
const familyId = row.family_id
const rotated = await env.db.prepare(`UPDATE api_keys SET revoked_at = ?, revoked_by = 'rotated' WHERE id = ? AND revoked_at IS NULL`).bind(now, row.id).run()
if (rotated.meta.changes !== 1) return oauthError('invalid_grant', 'Refresh token revoked')
const granted = parseScopes(row.scopes)
@@ -517,8 +576,20 @@ export async function token(request: Request, env: OAuthEnv): Promise<Response>
if (scopes.length === 0) return oauthError('invalid_scope', 'Requested scope exceeds the grant')
return issueTokens(
env,
{ accountId: row.account_id, userId: row.created_by, role: row.creator_role, scopes, clientId: client.client_id, clientName: client.client_name, familyId: row.family_id ?? randomId('fam') },
now
{
accountId: row.account_id,
userId: row.created_by,
role: row.creator_role,
scopes,
clientId: client.client_id,
clientName: client.client_name,
familyId,
grantorKeyId: row.grantor_key_id ?? null,
familyExpiresAt
},
now,
// A replay of the token just rotated revokes the family, which issueTokens re-checks.
async () => false
)
}
@@ -536,7 +607,7 @@ export async function revoke(request: Request, env: OAuthEnv): Promise<Response>
if (/^orca_(oat|ort)_[A-Za-z0-9]{32}$/.test(presented)) {
const row = await env.db.prepare(`SELECT * FROM api_keys WHERE secret_hash = ? AND kind IN ('oauth', 'oauth_refresh')`).bind(await sha256Hex(presented)).first<ApiKeyRow>()
if (row && row.family_id && (clientId === null || clientId === row.client_id)) {
await revokeFamily(env.db, row.family_id, env.now())
await revokeFamily(env.db, row.family_id, 'client_revoked', env.now())
}
}
return json({})
@@ -558,3 +629,72 @@ export async function mintApproval(
.run()
return { code, expiresAt, scopes }
}
// --- Grants (connected apps) and housekeeping -----------------------------------------------
export type GrantRow = {
family_id: string
client_id: string
client_name: string | null
created_by: string
scopes: string
family_expires_at: number | null
granted_at: number
last_used_at: number | null
via_key: string | null
}
/** Live grants: one row per family that still has an unrevoked, unexpired refresh token. */
export async function listGrants(db: D1Database, accountId: string, now: number, createdBy?: string): Promise<GrantRow[]> {
const { results } = await db
.prepare(
`SELECT r.family_id, r.client_id, c.client_name, r.created_by, r.scopes, r.family_expires_at,
(SELECT MIN(f.created_at) FROM api_keys f WHERE f.family_id = r.family_id) AS granted_at,
(SELECT MAX(f.last_used_at) FROM api_keys f WHERE f.family_id = r.family_id) AS last_used_at,
(SELECT k.prefix FROM api_keys k WHERE k.id = r.grantor_key_id) AS via_key
FROM api_keys r LEFT JOIN oauth_clients c ON c.client_id = r.client_id
WHERE r.account_id = ? AND r.kind = 'oauth_refresh' AND r.revoked_at IS NULL AND r.expires_at > ? AND r.family_id IS NOT NULL
${createdBy ? 'AND r.created_by = ?' : ''}
ORDER BY granted_at DESC LIMIT 200`
)
.bind(accountId, now, ...(createdBy ? [createdBy] : []))
.all<GrantRow>()
return results
}
export function grantView(row: GrantRow) {
return {
id: row.family_id,
clientId: row.client_id,
clientName: row.client_name,
userId: row.created_by,
scopes: row.scopes.split(' ').filter(Boolean),
approvedWith: row.via_key ? { apiKeyPrefix: row.via_key } : { approvalCode: true },
grantedAt: row.granted_at,
lastUsedAt: row.last_used_at,
expiresAt: row.family_expires_at
}
}
/** Who owns a grant family inside an account, or null when it is not this account's. */
export function grantOwner(db: D1Database, accountId: string, familyId: string): Promise<{ created_by: string; client_id: string | null } | null> {
return db
.prepare(`SELECT created_by, client_id FROM api_keys WHERE family_id = ? AND account_id = ? AND kind IN ('oauth', 'oauth_refresh') LIMIT 1`)
.bind(familyId, accountId)
.first()
}
/** Cron housekeeping: abandoned registrations and dead codes/approvals. Returns rows deleted. */
export async function purgeOAuth(db: D1Database, now: number): Promise<number> {
const results = await db.batch([
db
.prepare(
`DELETE FROM oauth_clients WHERE last_granted_at IS NULL AND created_at < ?
AND NOT EXISTS (SELECT 1 FROM oauth_codes c WHERE c.client_id = oauth_clients.client_id AND c.expires_at > ?)`
)
.bind(now - UNUSED_CLIENT_TTL_MS, now),
db.prepare('DELETE FROM oauth_codes WHERE expires_at < ?').bind(now - 86_400_000),
db.prepare('DELETE FROM oauth_approvals WHERE expires_at < ?').bind(now - 86_400_000)
])
return results.reduce((sum, r) => sum + r.meta.changes, 0)
}
@@ -124,6 +124,11 @@ export async function handleOperator(
.prepare(`UPDATE api_keys SET revoked_at = ?, revoked_by = 'system:member_removed' WHERE account_id = ? AND created_by = ? AND revoked_at IS NULL`)
.bind(now, body.data.accountId, body.data.userId)
.run()
// MCP OAuth tokens are api_keys rows (revoked above); pending codes and approvals die too.
await life.db.batch([
life.db.prepare('UPDATE oauth_codes SET consumed_at = ? WHERE account_id = ? AND user_id = ? AND consumed_at IS NULL').bind(now, body.data.accountId, body.data.userId),
life.db.prepare('UPDATE oauth_approvals SET consumed_at = ? WHERE account_id = ? AND user_id = ? AND consumed_at IS NULL').bind(now, body.data.accountId, body.data.userId)
])
await audit(life.db, { accountId: body.data.accountId, type: 'system', id: 'auth', userId: body.data.userId }, { action: 'member.removed', detail: { keysRevoked: result.meta.changes } }, now)
return Response.json({ revoked: result.meta.changes })
}
+123 -2
View File
@@ -1,13 +1,14 @@
import { createHash, randomBytes } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { purgeOAuth } from '../src/mcp/oauth'
import { createHarness } from './harness'
const ORIGIN = 'https://sandbox.test'
const REDIRECT = 'http://127.0.0.1:33418/callback'
async function harness() {
async function harness(overrides: Record<string, string> = {}) {
const pending: Promise<unknown>[] = []
const h = await createHarness({ PUBLIC_API_URL: ORIGIN }, { waitUntil: (p) => void pending.push(p) })
const h = await createHarness({ PUBLIC_API_URL: ORIGIN, ...overrides }, { waitUntil: (p) => void pending.push(p) })
let rpcId = 0
async function rpc(bearer: string | null, method: string, params?: unknown, headers: Record<string, string> = {}) {
@@ -431,3 +432,123 @@ describe('MCP authorization', () => {
expect(again.res.status).toBe(401)
})
})
describe('MCP grants', () => {
it('lists connected apps, lets owners and admins disconnect them, and audits it', async () => {
const h = await harness()
const memberJwt = await h.token('bob', 'acme', 'sandbox', { orgRole: 'member' })
const adminJwt = await h.token('carol', 'acme', 'sandbox', { orgRole: 'admin' })
const bobKey = await h.apiKey(['workspaces:read'], 'bob', { org: 'acme', role: 'member' })
const carolKey = await h.apiKey(['workspaces:read'], 'carol', { org: 'acme', role: 'admin' })
const bob = await h.connect(bobKey, { scope: 'workspaces:read' })
const carol = await h.connect(carolKey, { scope: 'workspaces:read' })
await h.rpc(bob.tokens.access_token, 'ping')
const mine = await h.call('GET', '/v1/oauth-grants', { bearer: memberJwt })
expect(mine.status).toBe(200)
expect(mine.body.grants).toHaveLength(1)
const grant = mine.body.grants[0]
expect(grant).toMatchObject({ clientName: 'Test Client', userId: 'bob', scopes: ['workspaces:read'], approvedWith: { apiKeyPrefix: bobKey.slice(0, 12) } })
expect(grant.id).toMatch(/^fam_/)
expect(grant.expiresAt).toBe(grant.grantedAt + 90 * 86_400_000)
expect((await h.call('GET', '/v1/oauth-grants', { bearer: adminJwt })).body.grants).toHaveLength(2)
// Keys cannot manage grants; other accounts cannot see them.
expect((await h.call('GET', '/v1/oauth-grants', { bearer: bobKey })).status).toBe(403)
expect((await h.call('GET', '/v1/oauth-grants', { as: 'mallory' })).body.grants).toHaveLength(0)
// A member cannot disconnect the admin's app; the admin can disconnect the member's.
const carolGrant = (await h.call('GET', '/v1/oauth-grants', { bearer: adminJwt })).body.grants.find((g: { userId: string }) => g.userId === 'carol')
expect((await h.call('DELETE', `/v1/oauth-grants/${carolGrant.id}`, { bearer: memberJwt })).status).toBe(404)
expect((await h.call('DELETE', `/v1/oauth-grants/${grant.id}`, { as: 'mallory' })).status).toBe(404)
expect((await h.call('DELETE', `/v1/oauth-grants/${grant.id}`, { bearer: adminJwt })).status).toBe(200)
expect((await h.rpc(bob.tokens.access_token, 'ping')).status).toBe(401)
expect((await h.tokenRequest({ grant_type: 'refresh_token', refresh_token: bob.tokens.refresh_token, client_id: bob.client.client_id })).body.error).toBe('invalid_grant')
expect((await h.rpc(carol.tokens.access_token, 'ping')).status).toBe(200)
expect((await h.call('GET', '/v1/oauth-grants', { bearer: memberJwt })).body.grants).toHaveLength(0)
expect(h.raw.prepare(`SELECT actor_type, target_id FROM audit_log WHERE action = 'mcp.grant_revoke'`).all()).toEqual([{ actor_type: 'user', target_id: grant.id }])
})
it('revokes every grant an API key approved when that key is revoked, pending codes included', async () => {
const h = await harness()
const key = await h.apiKey(['workspaces:read'])
const { client, tokens } = await h.connect(key, { scope: 'workspaces:read' })
const next = await h.tokenRequest({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, client_id: client.client_id })
expect(next.status).toBe(200)
// A code approved with the key but not exchanged yet.
const pending = h.pkce()
const consent = await h.consent(h.authorizeParams(client.client_id, pending.challenge, { scope: 'workspaces:read' }), key)
const id = (h.raw.prepare(`SELECT id FROM api_keys WHERE kind = 'user'`).get() as { id: string }).id
expect((await h.call('DELETE', `/v1/api-keys/${id}`)).status).toBe(200)
expect((await h.rpc(next.body.access_token, 'ping')).status).toBe(401)
expect((await h.tokenRequest({ grant_type: 'refresh_token', refresh_token: next.body.refresh_token, client_id: client.client_id })).body.error).toBe('invalid_grant')
const late = await h.tokenRequest({ grant_type: 'authorization_code', code: consent.location!.searchParams.get('code')!, code_verifier: pending.verifier, client_id: client.client_id })
expect(late.body).toMatchObject({ error: 'invalid_grant', error_description: 'The credential that approved this grant was revoked' })
expect(h.raw.prepare(`SELECT COUNT(*) AS n FROM api_keys WHERE kind IN ('oauth', 'oauth_refresh') AND revoked_at IS NULL`).get()).toEqual({ n: 0 })
})
it('ends a grant 90 days after approval however often it is refreshed', async () => {
const h = await harness()
const key = await h.apiKey(['workspaces:read'])
const { client, tokens } = await h.connect(key, { scope: 'workspaces:read' })
let refresh = tokens.refresh_token
for (let i = 0; i < 3; i++) {
h.advance(29 * 86_400_000)
const next = await h.tokenRequest({ grant_type: 'refresh_token', refresh_token: refresh, client_id: client.client_id })
expect(next.status).toBe(200)
refresh = next.body.refresh_token
}
// The last pair cannot outlive the grant.
const last = h.raw.prepare(`SELECT expires_at, family_expires_at FROM api_keys WHERE kind = 'oauth_refresh' AND revoked_at IS NULL`).get() as { expires_at: number; family_expires_at: number }
expect(last.expires_at).toBe(last.family_expires_at)
h.advance(3 * 86_400_000 + 1)
expect((await h.tokenRequest({ grant_type: 'refresh_token', refresh_token: refresh, client_id: client.client_id })).body.error).toBe('invalid_grant')
})
it('kills tokens minted while the code was being replayed (claim/mint race)', async () => {
const h = await harness()
const key = await h.apiKey(['workspaces:read'])
const client = (await h.register()).body
const { verifier, challenge } = h.pkce()
const { location } = await h.consent(h.authorizeParams(client.client_id, challenge, { scope: 'workspaces:read' }), key)
// Simulate a replay landing between the claim and the mint: the first token insert flags it.
h.raw.exec(`CREATE TRIGGER replay_mid_flight AFTER INSERT ON api_keys WHEN NEW.kind = 'oauth' BEGIN UPDATE oauth_codes SET replayed_at = 1; END`)
const res = await h.tokenRequest({ grant_type: 'authorization_code', code: location!.searchParams.get('code')!, code_verifier: verifier, client_id: client.client_id })
expect(res.body).toMatchObject({ error: 'invalid_grant', error_description: 'Grant revoked' })
expect(h.raw.prepare(`SELECT COUNT(*) AS n FROM api_keys WHERE kind IN ('oauth', 'oauth_refresh') AND revoked_at IS NULL`).get()).toEqual({ n: 0 })
})
it("consumes a removed member's pending codes and approval codes", async () => {
const OP = 'o'.repeat(40)
const h = await harness({ OPERATOR_TOKEN: OP })
const bobJwt = await h.token('bob', 'acme', 'sandbox', { orgRole: 'member' })
const approval = (await h.call('POST', '/v1/oauth-approvals', { bearer: bobJwt, body: {} })).body.code
const unused = (await h.call('POST', '/v1/oauth-approvals', { bearer: bobJwt, body: {} })).body.code
const client = (await h.register()).body
const { verifier, challenge } = h.pkce()
const { location } = await h.consent(h.authorizeParams(client.client_id, challenge, { scope: 'workspaces:read' }), approval)
const removed = await h.call('POST', '/v1/internal/members/removed', { bearer: OP, body: { accountId: 'org:acme', userId: 'bob' } })
expect(removed.status).toBe(200)
expect((await h.tokenRequest({ grant_type: 'authorization_code', code: location!.searchParams.get('code')!, code_verifier: verifier, client_id: client.client_id })).body.error).toBe('invalid_grant')
expect((await h.consent(h.authorizeParams(client.client_id, h.pkce().challenge), unused)).res.status).toBe(401)
})
it('purges abandoned registrations and dead codes, and limits the token endpoint per IP', async () => {
const h = await harness()
const key = await h.apiKey(['workspaces:read'])
const used = await h.connect(key, { scope: 'workspaces:read' })
const abandoned = (await h.register()).body
h.advance(25 * 3_600_000)
expect(await purgeOAuth(h.env.SANDBOX_DB, h.now())).toBeGreaterThanOrEqual(1)
const left = (h.raw.prepare('SELECT client_id FROM oauth_clients').all() as { client_id: string }[]).map((r) => r.client_id)
expect(left).toContain(used.client.client_id)
expect(left).not.toContain(abandoned.client_id)
h.advance(24 * 3_600_000)
await purgeOAuth(h.env.SANDBOX_DB, h.now())
expect(h.raw.prepare('SELECT COUNT(*) AS n FROM oauth_codes').get()).toEqual({ n: 0 })
const hit = () =>
h.send(new Request(`${ORIGIN}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'cf-connecting-ip': '203.0.113.9' }, body: 'grant_type=refresh_token&client_id=mcpc_00000000000000000000' }))
for (let i = 0; i < 120; i++) expect((await hit()).status).toBe(401)
expect((await hit()).status).toBe(429)
})
})