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
4242 # ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
4343 # Optional overrides:
4444 # 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)
4646 # MCP_API_KEY_SECRET=change-me
47+# MCP API key TTL in seconds (default: 604800)
48+# MCP_API_KEY_TTL_SECONDS=604800
4749 # Force managed credentials regardless of deployment-mode defaults
4850 # EXECUTOR_ENFORCE_MANAGED_CREDENTIALS=1
4951
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
33 import { issueMcpApiKey, verifyMcpApiKey } from "./mcp_api_key";
44
55 const originalMcpApiKeySecret = process.env.MCP_API_KEY_SECRET;
6+const originalMcpApiKeyTtl = process.env.MCP_API_KEY_TTL_SECONDS;
67 const originalAnonymousPrivateKey = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
78
89 afterEach(() => {
910 process.env.MCP_API_KEY_SECRET = originalMcpApiKeySecret;
11+ process.env.MCP_API_KEY_TTL_SECONDS = originalMcpApiKeyTtl;
1012 process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = originalAnonymousPrivateKey;
1113 });
1214
@@ -62,3 +64,18 @@ test("does not fall back to anonymous private key", async () => { · 49 unmodified lines
6264
6365 expect(apiKey).toBeNull();
6466 });
67+
68+test("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
55 export const MCP_API_KEY_ENV_NAME = "API_KEY";
66 const MCP_API_KEY_AUDIENCE = "executor-mcp";
77 const MCP_API_KEY_ALGORITHM = "HS256";
8+const DEFAULT_MCP_API_KEY_TTL_SECONDS = 60 * 60 * 24 * 7;
89
910 export type VerifiedMcpApiKey = {
1011 workspaceId: Id<"workspaces">;
@@ -37,6 +38,15 @@ function getSecretBytes(secret: string): Uint8Array { · 26 unmodified lines
3738 return new TextEncoder().encode(secret);
3839 }
3940
41+function 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+
4050 export function isMcpApiKeyConfigured(): boolean {
4151 return Boolean(getConfiguredSecret());
4252 }
@@ -51,8 +61,7 @@ export async function issueMcpApiKey(input: { · 8 unmodified lines
5161 }
5262
5363 const issuer = getIssuer();
54− // TODO(security): Add explicit key expiration + revocation support once
55− // anonymous MCP key lifecycle management is implemented.
64+ const ttlSeconds = getMcpApiKeyTtlSeconds();
5665 let jwt = new SignJWT({
5766 workspaceId: input.workspaceId,
5867 accountId: input.accountId,
@@ -60,7 +69,8 @@ export async function issueMcpApiKey(input: { · 1 unmodified line
6069 .setProtectedHeader({ alg: MCP_API_KEY_ALGORITHM, typ: "JWT" })
6170 .setAudience(MCP_API_KEY_AUDIENCE)
6271 .setSubject(input.accountId)
63− .setIssuedAt();
72+ .setIssuedAt()
73+ .setExpirationTime(`${ttlSeconds}s`);
6474
6575 if (issuer) {
6676 jwt = jwt.setIssuer(issuer);