Skip to content

Commit 0aab03a

Browse files
authored
Add MCP authorization HTTP flow (#32)
## Summary - add service-authenticated issuance of short-lived signed MCP authorization tickets - make client identity, client label and requested scope tamper-evident before the human consent step - add human session endpoints to inspect a request, authorize one manageable agent, list grants and revoke access - add single-use delegation redemption and active-grant resolution for the MCP Worker - revalidate active account status and current sponsor/platform-owner authority on every redemption and use - classify MCP service traffic separately in telemetry ## Security boundary - the browser submits only a signed ticket and selected agent ID - v0.2A remains fixed to `feed:read` - no `orb_agent_v1` credential is accepted, copied or stored - authorization tickets expire after 10 minutes - delegation codes expire after 5 minutes and can be redeemed once - grants expire after 90 days and are immediately revocable ## Verification - `npm run check` — 0 diagnostics - `npm run test:d1` — 122/122 passed - `npm run build` — full D1, content, client, site and browser regression passed
1 parent b43d1fb commit 0aab03a

10 files changed

Lines changed: 970 additions & 6 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
PRAGMA foreign_keys = ON;
2+
3+
DROP TRIGGER IF EXISTS mcp_delegation_redemptions_validate;
4+
5+
CREATE TRIGGER mcp_delegation_redemptions_validate
6+
BEFORE INSERT ON mcp_delegation_redemptions
7+
BEGIN
8+
SELECT RAISE(ABORT, 'invalid_mcp_delegation_code')
9+
WHERE NOT EXISTS (
10+
SELECT 1
11+
FROM mcp_delegation_codes code
12+
JOIN mcp_authorization_grants grant_row
13+
ON grant_row.id = code.grant_id
14+
JOIN accounts account
15+
ON account.id = grant_row.account_id
16+
WHERE code.id = NEW.code_id
17+
AND code.grant_id = NEW.grant_id
18+
AND code.authorization_request_id = NEW.authorization_request_id
19+
AND code.consumed_at IS NULL
20+
AND code.expires_at > NEW.redeemed_at
21+
AND grant_row.revoked_at IS NULL
22+
AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.redeemed_at)
23+
AND account.status = 'active'
24+
AND (
25+
EXISTS (
26+
SELECT 1
27+
FROM agent_memberships membership
28+
WHERE membership.agent_id = grant_row.agent_id
29+
AND membership.account_id = grant_row.account_id
30+
AND membership.role = 'primary_sponsor'
31+
AND membership.revoked_at IS NULL
32+
)
33+
OR EXISTS (
34+
SELECT 1
35+
FROM account_roles role
36+
WHERE role.account_id = grant_row.account_id
37+
AND role.role = 'platform_owner'
38+
AND role.revoked_at IS NULL
39+
)
40+
)
41+
);
42+
END;

‎scripts/orbit-d1-test-worker.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,22 @@ async function handleAction(body: ActionRequest, env: Environment): Promise<Resp
346346
return json({ redemption, revocation, audits: audits.results });
347347
}
348348

349+
case 'revokeMcpMembership': {
350+
await env.DB.prepare(`
351+
UPDATE agent_memberships
352+
SET revoked_at = ?
353+
WHERE account_id = ?
354+
AND agent_id = ?
355+
AND role = 'primary_sponsor'
356+
AND revoked_at IS NULL
357+
`).bind(
358+
numberValue(data, 'now'),
359+
stringValue(data, 'accountId'),
360+
stringValue(data, 'agentId'),
361+
).run();
362+
return json({ ok: true });
363+
}
364+
349365
case 'exportBackup': {
350366
return json(await createDynamicBackup(
351367
env.DB,

‎scripts/orbit-mcp-authorization-tests.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,36 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => {
401401
);
402402
});
403403

404+
test('losing current agent management invalidates touch and redemption', async () => {
405+
const now = Date.now();
406+
const accountId = 'mcp-source-truth-sponsor';
407+
const agentId = 'mcp-source-truth-agent';
408+
await callAction('seedMcpAgent', { accountId, agentId, now });
409+
const input = grantData('mcp-source-truth', accountId, agentId, now + 1);
410+
await callAction('createMcpGrant', input, 201);
411+
await callAction('revokeMcpMembership', {
412+
accountId,
413+
agentId,
414+
now: now + 2,
415+
});
416+
417+
const touched = await callAction<{ touched: boolean }>(
418+
'touchMcpGrant',
419+
{ grantId: input.grantId, now: now + 3 },
420+
);
421+
assert.equal(touched.touched, false);
422+
423+
const redemption = await callAction<{ error: string }>('redeemMcpCode', {
424+
codeId: input.codeId,
425+
grantId: input.grantId,
426+
authorizationRequestId: input.authorizationRequestId,
427+
auditEventId: 'mcp-source-truth-redeem-audit',
428+
requestId: 'mcp-source-truth-redeem-request',
429+
now: now + 4,
430+
}, 409);
431+
assert.match(redemption.error, /invalid_mcp_delegation_code/u);
432+
});
433+
404434
test('durable grants and revocations enter backups without transient codes', async () => {
405435
const backup = await callAction<{
406436
schemaVersion: number;

0 commit comments

Comments
 (0)