History

feat: add configurable TTL expiration to MCP API keys

MCP API keys now include a JWT expiration claim. TTL defaults to 7 days and can be configured via MCP_API_KEY_TTL_SECONDS. Removes the fallback to ANONYMOUS_AUTH_PRIVATE_KEY_PEM for signing.

Rhys Sullivan committed 8 months ago

Commit a8077161 parent 56b73f93 files · +33 −4Browse files

Unified · Split

3 files
  1. .env.example +3 −1
  2. executor/packages/database/src/auth/mcp_api_key.test.ts +17 −0
  3. executor/packages/database/src/auth/mcp_api_key.ts +13 −3
.env.example+3−1
@@ -42,8 +42,10 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 41 unmodified lines
42# ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"42# ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
43# Optional overrides:43# Optional overrides:
44# ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=60480044# ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800
45# MCP API key signing secret (optional; falls back to ANONYMOUS_AUTH_PRIVATE_KEY_PEM when unset)45# MCP API key signing secret (required to issue anonymous MCP API keys)
46# MCP_API_KEY_SECRET=change-me46# MCP_API_KEY_SECRET=change-me
47# MCP API key TTL in seconds (default: 604800)
48# MCP_API_KEY_TTL_SECONDS=604800
47# Force managed credentials regardless of deployment-mode defaults49# Force managed credentials regardless of deployment-mode defaults
48# EXECUTOR_ENFORCE_MANAGED_CREDENTIALS=150# EXECUTOR_ENFORCE_MANAGED_CREDENTIALS=1
49 51
executor/packages/database/src/auth/mcp_api_key.test.ts+17−0
@@ -3,10 +3,12 @@ import type { Id } from "../../convex/_generated/dataModel.d.ts"; · 2 unmodified lines
3import { issueMcpApiKey, verifyMcpApiKey } from "./mcp_api_key";3import { issueMcpApiKey, verifyMcpApiKey } from "./mcp_api_key";
4 4
5const originalMcpApiKeySecret = process.env.MCP_API_KEY_SECRET;5const originalMcpApiKeySecret = process.env.MCP_API_KEY_SECRET;
6const originalMcpApiKeyTtl = process.env.MCP_API_KEY_TTL_SECONDS;
6const originalAnonymousPrivateKey = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;7const originalAnonymousPrivateKey = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
7 8
8afterEach(() => {9afterEach(() => {
9 process.env.MCP_API_KEY_SECRET = originalMcpApiKeySecret;10 process.env.MCP_API_KEY_SECRET = originalMcpApiKeySecret;
11 process.env.MCP_API_KEY_TTL_SECONDS = originalMcpApiKeyTtl;
10 process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = originalAnonymousPrivateKey;12 process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = originalAnonymousPrivateKey;
11});13});
12 14
@@ -62,3 +64,18 @@ test("does not fall back to anonymous private key", async () => { · 49 unmodified lines
62 64
63 expect(apiKey).toBeNull();65 expect(apiKey).toBeNull();
64});66});
67
68test("verifyMcpApiKey rejects expired token", async () => {
69 process.env.MCP_API_KEY_SECRET = "test-secret";
70 process.env.MCP_API_KEY_TTL_SECONDS = "1";
71
72 const apiKey = await issueMcpApiKey({
73 workspaceId: "workspace_expired" as Id<"workspaces">,
74 accountId: "account_expired" as Id<"accounts">,
75 });
76
77 expect(apiKey).toBeTruthy();
78 await new Promise((resolve) => setTimeout(resolve, 1_200));
79 const verified = await verifyMcpApiKey(apiKey);
80 expect(verified).toBeNull();
81});
executor/packages/database/src/auth/mcp_api_key.ts+13−3
@@ -5,6 +5,7 @@ import { getAnonymousAuthIssuer } from "./anonymous"; · 4 unmodified lines
5export const MCP_API_KEY_ENV_NAME = "API_KEY";5export const MCP_API_KEY_ENV_NAME = "API_KEY";
6const MCP_API_KEY_AUDIENCE = "executor-mcp";6const MCP_API_KEY_AUDIENCE = "executor-mcp";
7const MCP_API_KEY_ALGORITHM = "HS256";7const MCP_API_KEY_ALGORITHM = "HS256";
8const DEFAULT_MCP_API_KEY_TTL_SECONDS = 60 * 60 * 24 * 7;
8 9
9export type VerifiedMcpApiKey = {10export type VerifiedMcpApiKey = {
10 workspaceId: Id<"workspaces">;11 workspaceId: Id<"workspaces">;
@@ -37,6 +38,15 @@ function getSecretBytes(secret: string): Uint8Array { · 26 unmodified lines
37 return new TextEncoder().encode(secret);38 return new TextEncoder().encode(secret);
38}39}
39 40
41function getMcpApiKeyTtlSeconds(): number {
42 const raw = process.env.MCP_API_KEY_TTL_SECONDS;
43 const parsed = Number.parseInt(raw ?? "", 10);
44 if (!Number.isFinite(parsed) || parsed <= 0) {
45 return DEFAULT_MCP_API_KEY_TTL_SECONDS;
46 }
47 return parsed;
48}
49
40export function isMcpApiKeyConfigured(): boolean {50export function isMcpApiKeyConfigured(): boolean {
41 return Boolean(getConfiguredSecret());51 return Boolean(getConfiguredSecret());
42}52}
@@ -51,8 +61,7 @@ export async function issueMcpApiKey(input: { · 8 unmodified lines
51 }61 }
52 62
53 const issuer = getIssuer();63 const issuer = getIssuer();
54 // TODO(security): Add explicit key expiration + revocation support once64 const ttlSeconds = getMcpApiKeyTtlSeconds();
55 // anonymous MCP key lifecycle management is implemented.
56 let jwt = new SignJWT({65 let jwt = new SignJWT({
57 workspaceId: input.workspaceId,66 workspaceId: input.workspaceId,
58 accountId: input.accountId,67 accountId: input.accountId,
@@ -60,7 +69,8 @@ export async function issueMcpApiKey(input: { · 1 unmodified line
60 .setProtectedHeader({ alg: MCP_API_KEY_ALGORITHM, typ: "JWT" })69 .setProtectedHeader({ alg: MCP_API_KEY_ALGORITHM, typ: "JWT" })
61 .setAudience(MCP_API_KEY_AUDIENCE)70 .setAudience(MCP_API_KEY_AUDIENCE)
62 .setSubject(input.accountId)71 .setSubject(input.accountId)
63 .setIssuedAt();72 .setIssuedAt()
73 .setExpirationTime(`${ttlSeconds}s`);
64 74
65 if (issuer) {75 if (issuer) {
66 jwt = jwt.setIssuer(issuer);76 jwt = jwt.setIssuer(issuer);