Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/recipes/capability-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ The raw `as_cap_...` token appears in the mint response body and is never stored

### Expiry vs. revocation

New tokens minted through REST or MCP default to a 30-day expiry when `expires_at` is omitted. Explicit expiry must be a future Unix-millisecond integer within 365 days of minting; invalid horizons return `INVALID_REQUEST`. The mint response includes the effective `expires_at`. Existing tokens are not retroactively changed: revoke and reissue legacy tokens with no expiry. Prefer a shorter expiry matching the delegated task.

Set `expires_at` (Unix milliseconds) for time-bounded delegation — for example, a per-run token that expires when the job is done. Call `DELETE /api/v1/capability-tokens/:id` for immediate revocation at any time, regardless of `expires_at`. Both strategies invalidate the token; revocation takes effect synchronously.

### Using a capability token in the SDK
Expand Down
20 changes: 14 additions & 6 deletions packages/api/src/routes/capability-tokens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { apiKeyAuth } from "../../middleware/auth";
import { rateLimitMiddleware } from "../../middleware/rate-limit";
import { requireScope } from "../../middleware/require-scope";
import {
CapabilityTokenExpiryError,
createCapabilityToken,
listCapabilityTokens,
revokeCapabilityToken,
Expand Down Expand Up @@ -45,12 +46,19 @@ router.post("/", requireScope("keys:write"), async (c) => {
);
}

const token = await createCapabilityToken(c.get("db"), c.get("projectId"), {
name: data.name,
scopes: data.scopes,
expires_at: data.expires_at,
});
return c.json(token, 201);
try {
const token = await createCapabilityToken(c.get("db"), c.get("projectId"), {
name: data.name,
scopes: data.scopes,
expires_at: data.expires_at,
});
return c.json(token, 201);
} catch (err) {
if (err instanceof CapabilityTokenExpiryError) {
return errorResponse(c, err.code, err.message, 400);
}
throw err;
}
});

router.get("/", requireScope("keys:read"), async (c) => {
Expand Down
19 changes: 13 additions & 6 deletions packages/api/src/routes/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ export const TOOLS: ToolDefinition[] = [
{
name: "mint_capability_token",
description:
"Mint a scoped capability token for delegation to sub-agents. The raw token is shown once — store it before discarding the response.",
"Mint a scoped capability token for delegation to sub-agents. Expiry defaults to 30 days and cannot exceed 365 days. The raw token is shown once — store it before discarding the response.",
requiredScope: "keys:write",
zodSchema: mintCapabilityTokenSchema,
inputSchema: jsonSchema(mintCapabilityTokenSchema),
Expand All @@ -485,11 +485,18 @@ export const TOOLS: ToolDefinition[] = [
if (!scopesSatisfyAll(callerScopes, args.scopes)) {
throw new ToolError("FORBIDDEN", "Cannot grant scopes beyond the calling key's own scopes");
}
return capabilityTokensService.createCapabilityToken(c.get("db"), c.get("projectId"), {
name: args.name,
scopes: args.scopes,
expires_at: args.expires_at,
});
try {
return await capabilityTokensService.createCapabilityToken(c.get("db"), c.get("projectId"), {
name: args.name,
scopes: args.scopes,
expires_at: args.expires_at,
});
} catch (err) {
if (err instanceof capabilityTokensService.CapabilityTokenExpiryError) {
throw new ToolError(err.code, err.message);
}
throw err;
}
},
},

Expand Down
36 changes: 34 additions & 2 deletions packages/api/src/services/capability-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import { generateCapabilityToken, generateId } from "../lib/id";
import { encodeJson } from "../lib/state-json";
import type { CapabilityScope } from "../lib/validation";

export const CAPABILITY_TOKEN_DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
export const CAPABILITY_TOKEN_MAX_TTL_MS = 365 * 24 * 60 * 60 * 1000;

export class CapabilityTokenExpiryError extends Error {
readonly code = "INVALID_REQUEST";

constructor() {
super("expires_at must be a future Unix-millisecond integer no more than 365 days from now");
this.name = "CapabilityTokenExpiryError";
}
}

export interface CapabilityTokenResponse {
id: string;
name: string;
Expand Down Expand Up @@ -48,18 +60,38 @@ export function mapCapabilityToken(
export async function createCapabilityToken(
db: DrizzleD1Database,
projectId: string,
input: { name: string; scopes: CapabilityScope[]; expires_at?: number },
input: { name: string; scopes: CapabilityScope[]; expires_at?: number | null },
): Promise<CreatedCapabilityTokenResponse> {
const token = generateCapabilityToken();
const now = Date.now();

// Expiry is enforced here at the shared service boundary so REST and MCP
// minters behave identically: omitted -> now + 30 days, past/non-finite
// values and horizons beyond 365 days are rejected. Without a default,
// `expiresAt: null` means never-expiring (scoped-auth/mcp-auth treat NULL
// as no expiry), which turns any leaked delegation token into a permanent
// grant.
let expiresAt: number;
if (input.expires_at === undefined || input.expires_at === null) {
expiresAt = now + CAPABILITY_TOKEN_DEFAULT_TTL_MS;
} else if (
!Number.isSafeInteger(input.expires_at) ||
input.expires_at <= now ||
input.expires_at - now > CAPABILITY_TOKEN_MAX_TTL_MS
) {
throw new CapabilityTokenExpiryError();
} else {
expiresAt = input.expires_at;
}

const row = {
id: generateId(),
projectId,
name: input.name,
keyPrefix: token.substring(0, 12),
keyHash: await hashApiKey(token),
scopes: encodeJson([...new Set(input.scopes)].sort()),
expiresAt: input.expires_at ?? null,
expiresAt,
createdAt: now,
};

Expand Down
18 changes: 17 additions & 1 deletion packages/api/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,20 @@ describe("remote MCP server", () => {
expect(json.result.isError).toBeUndefined();
});

it("reports invalid capability expiry as a tool error", async () => {
const { json } = await rpc(bearer(TEST_KEY), {
jsonrpc: "2.0",
id: 19,
method: "tools/call",
params: {
name: "mint_capability_token",
arguments: { name: "invalid", scopes: ["state:read"], expires_at: Date.now() + 366 * 24 * 60 * 60 * 1000 },
},
});
expect(json.result.isError).toBe(true);
expect(json.result.content[0].text).toContain("INVALID_REQUEST");
});

it("a capability token with lease:write can call acquire_lease (scope-form normalization)", async () => {
// Mint a capability token scoped to the singular capability form.
const mint = await rpc(bearer(TEST_KEY), {
Expand All @@ -278,7 +292,9 @@ describe("remote MCP server", () => {
},
});
expect(mint.json.result.isError).toBeUndefined();
const token = JSON.parse(mint.json.result.content[0].text).token as string;
const minted = JSON.parse(mint.json.result.content[0].text);
expect(minted.expires_at).toBe(minted.created_at + 30 * 24 * 60 * 60 * 1000);
const token = minted.token as string;
expect(token.startsWith("as_cap_")).toBe(true);

// The token's singular lease:write must satisfy the lease tool's plural
Expand Down
23 changes: 23 additions & 0 deletions packages/api/test/v2-capability-tokens-leases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,29 @@ describe("V2 Capability Tokens", () => {
await resetCapabilityTables();
});

it("defaults new tokens to a persisted 30-day expiry", async () => {
const body = await createCapabilityTokenBody({ name: "default expiry", scopes: ["state:read"] });
expect(body.expires_at).toBe(body.created_at + 30 * 24 * 60 * 60 * 1000);
const row = await env.DB.prepare("SELECT expires_at FROM capability_tokens WHERE id = ?")
.bind(body.id).first<{ expires_at: number }>();
expect(row?.expires_at).toBe(body.expires_at);
});

it.each(["past", "beyond maximum"])("rejects %s expiry without creating a token", async (kind) => {
const expires_at = kind === "past" ? Date.now() - 1000 : Date.now() + 366 * 24 * 60 * 60 * 1000;
const res = await createCapabilityToken({ name: "invalid expiry", scopes: ["state:read"], expires_at });
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ error: { code: "INVALID_REQUEST" } });
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM capability_tokens").first<{ count: number }>();
expect(row?.count).toBe(0);
});

it("preserves an explicit expiry within the maximum", async () => {
const expires_at = Date.now() + 365 * 24 * 60 * 60 * 1000;
const body = await createCapabilityTokenBody({ name: "maximum expiry", scopes: ["state:read"], expires_at });
expect(body.expires_at).toBe(expires_at);
});

it("creates a scoped capability token and stores only its hash", async () => {
const body = await createCapabilityTokenBody({
name: "state writer",
Expand Down
21 changes: 18 additions & 3 deletions packages/api/test/v2-coordination-edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
* scopedAuth checks `expiresAt > now`; a token past its expiry is silently
* excluded by the DB query and returns 401 UNAUTHORIZED — identical to a
* revoked token from the caller's perspective, but a distinct code branch.
* The mint API rejects past expires_at outright (INVALID_REQUEST), so the
* token is minted with a valid future expiry and force-expired via a direct
* DB update before the auth check.
*
* 3. RELEASE ALREADY-RELEASED LEASE → 404
* releaseLease guards against double-release (releasedAt IS NOT NULL → NOT_FOUND).
Expand Down Expand Up @@ -102,6 +105,17 @@ async function acquireLease(
});
}

/**
* Force-expire a capability token directly in the DB — mirrors the direct-row
* pattern of insertLease() below. The mint API rejects past expires_at
* (INVALID_REQUEST), so expiry is simulated without real sleeps.
*/
async function forceExpireToken(tokenId: string): Promise<void> {
await env.DB.prepare("UPDATE capability_tokens SET expires_at = ? WHERE id = ?")
.bind(Date.now() - 1_000, tokenId)
.run();
}

/**
* Insert a lease row directly into the DB — mirrors insertLease() in
* v2-capability-tokens-leases.test.ts. Allows setting expiresAt to a past
Expand Down Expand Up @@ -224,13 +238,14 @@ describe("Expired capability token denial", () => {
// through to authFailure → 401. Without this test, a regression in the expiry
// filter would silently make time-limited delegation tokens permanent.
//
// We mint a token with expires_at in the past via the management API (which does
// not validate that expires_at is in the future), then immediately try to use it.
// Mint with a valid future expires_at (the API rejects past values with 400
// INVALID_REQUEST), then force-expire the row directly in the DB before use.
const expiredToken = await mintCapabilityToken(
["lease:write"],
"expired-token-test",
Date.now() - 1_000, // already expired when minted
Date.now() + 60_000,
);
await forceExpireToken(expiredToken.id);

// Attempt to acquire a lease using the expired token.
const res = await acquireLease("state:expired-token-attempt", expiredToken.token, {
Expand Down