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 | # 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=604800 | 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 | # MCP_API_KEY_SECRET=change-me | 46 | # 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 defaults | 49 | # Force managed credentials regardless of deployment-mode defaults |
| 48 | # EXECUTOR_ENFORCE_MANAGED_CREDENTIALS=1 | 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 | import { issueMcpApiKey, verifyMcpApiKey } from "./mcp_api_key"; | 3 | import { issueMcpApiKey, verifyMcpApiKey } from "./mcp_api_key"; |
| 4 | 4 | ||
| 5 | const originalMcpApiKeySecret = process.env.MCP_API_KEY_SECRET; | 5 | const originalMcpApiKeySecret = process.env.MCP_API_KEY_SECRET; |
| 6 | const originalMcpApiKeyTtl = process.env.MCP_API_KEY_TTL_SECONDS; | ||
| 6 | const originalAnonymousPrivateKey = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM; | 7 | const originalAnonymousPrivateKey = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM; |
| 7 | 8 | ||
| 8 | afterEach(() => { | 9 | afterEach(() => { |
| 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 | |||
| 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 | export const MCP_API_KEY_ENV_NAME = "API_KEY"; | 5 | export const MCP_API_KEY_ENV_NAME = "API_KEY"; |
| 6 | const MCP_API_KEY_AUDIENCE = "executor-mcp"; | 6 | const MCP_API_KEY_AUDIENCE = "executor-mcp"; |
| 7 | const MCP_API_KEY_ALGORITHM = "HS256"; | 7 | const MCP_API_KEY_ALGORITHM = "HS256"; |
| 8 | const DEFAULT_MCP_API_KEY_TTL_SECONDS = 60 * 60 * 24 * 7; | ||
| 8 | 9 | ||
| 9 | export type VerifiedMcpApiKey = { | 10 | export 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 | ||
| 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 | export function isMcpApiKeyConfigured(): boolean { | 50 | export 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 once | 64 | 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); |