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.
Unified · Split
| @@ -42,8 +42,10 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 41 unmodified lines | ||
| 42 | 42 | # ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" |
| 43 | 43 | # Optional overrides: |
| 44 | 44 | # 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 | 46 | # MCP_API_KEY_SECRET=change-me |
| 47 | +# MCP API key TTL in seconds (default: 604800) | |
| 48 | +# MCP_API_KEY_TTL_SECONDS=604800 | |
| 47 | 49 | # Force managed credentials regardless of deployment-mode defaults |
| 48 | 50 | # EXECUTOR_ENFORCE_MANAGED_CREDENTIALS=1 |
| 49 | 51 | |
| @@ -3,10 +3,12 @@ import type { Id } from "../../convex/_generated/dataModel.d.ts"; · 2 unmodified lines | ||
| 3 | 3 | import { issueMcpApiKey, verifyMcpApiKey } from "./mcp_api_key"; |
| 4 | 4 | |
| 5 | 5 | const originalMcpApiKeySecret = process.env.MCP_API_KEY_SECRET; |
| 6 | +const originalMcpApiKeyTtl = process.env.MCP_API_KEY_TTL_SECONDS; | |
| 6 | 7 | const originalAnonymousPrivateKey = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM; |
| 7 | 8 | |
| 8 | 9 | afterEach(() => { |
| 9 | 10 | process.env.MCP_API_KEY_SECRET = originalMcpApiKeySecret; |
| 11 | + process.env.MCP_API_KEY_TTL_SECONDS = originalMcpApiKeyTtl; | |
| 10 | 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 | 65 | expect(apiKey).toBeNull(); |
| 64 | 66 | }); |
| 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 | +}); | |
| @@ -5,6 +5,7 @@ import { getAnonymousAuthIssuer } from "./anonymous"; · 4 unmodified lines | ||
| 5 | 5 | export const MCP_API_KEY_ENV_NAME = "API_KEY"; |
| 6 | 6 | const MCP_API_KEY_AUDIENCE = "executor-mcp"; |
| 7 | 7 | const MCP_API_KEY_ALGORITHM = "HS256"; |
| 8 | +const DEFAULT_MCP_API_KEY_TTL_SECONDS = 60 * 60 * 24 * 7; | |
| 8 | 9 | |
| 9 | 10 | export type VerifiedMcpApiKey = { |
| 10 | 11 | workspaceId: Id<"workspaces">; |
| @@ -37,6 +38,15 @@ function getSecretBytes(secret: string): Uint8Array { · 26 unmodified lines | ||
| 37 | 38 | return new TextEncoder().encode(secret); |
| 38 | 39 | } |
| 39 | 40 | |
| 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 | + | |
| 40 | 50 | export function isMcpApiKeyConfigured(): boolean { |
| 41 | 51 | return Boolean(getConfiguredSecret()); |
| 42 | 52 | } |
| @@ -51,8 +61,7 @@ export async function issueMcpApiKey(input: { · 8 unmodified lines | ||
| 51 | 61 | } |
| 52 | 62 | |
| 53 | 63 | 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(); | |
| 56 | 65 | let jwt = new SignJWT({ |
| 57 | 66 | workspaceId: input.workspaceId, |
| 58 | 67 | accountId: input.accountId, |
| @@ -60,7 +69,8 @@ export async function issueMcpApiKey(input: { · 1 unmodified line | ||
| 60 | 69 | .setProtectedHeader({ alg: MCP_API_KEY_ALGORITHM, typ: "JWT" }) |
| 61 | 70 | .setAudience(MCP_API_KEY_AUDIENCE) |
| 62 | 71 | .setSubject(input.accountId) |
| 63 | − .setIssuedAt(); | |
| 72 | + .setIssuedAt() | |
| 73 | + .setExpirationTime(`${ttlSeconds}s`); | |
| 64 | 74 | |
| 65 | 75 | if (issuer) { |
| 66 | 76 | jwt = jwt.setIssuer(issuer); |