diff --git a/index.ts b/index.ts index 98ea120c..3cea923a 100644 --- a/index.ts +++ b/index.ts @@ -48,79 +48,6 @@ import { /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */ const IS_REMOTE = SSE || STREAMABLE_HTTP; -/** - * Encryption key for download tokens. When DOWNLOAD_TOKEN_SECRET is set - * (recommended for HA deployments behind a load balancer) the key is - * derived from that value so all replicas share the same key. Otherwise - * a random key is generated per process (tokens are not portable across - * restarts or replicas). - */ -const DOWNLOAD_TOKEN_KEY: Buffer = (() => { - const secret = process.env.DOWNLOAD_TOKEN_SECRET; - if (secret) { - return createHash("sha256").update(secret).digest(); - } - return randomBytes(32); -})(); - -/** Download token TTL in seconds (default 5 minutes). */ -const DOWNLOAD_TOKEN_TTL = Number.parseInt(process.env.DOWNLOAD_TOKEN_TTL || "300", 10); - -function createDownloadToken( - header: string, - token: string, - apiUrl?: string, - resource?: { type: string; params: Record } -): string { - const iv = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", DOWNLOAD_TOKEN_KEY, iv); - const payload = JSON.stringify({ - h: header, - t: token, - e: Math.floor(Date.now() / 1000) + DOWNLOAD_TOKEN_TTL, - ...(apiUrl ? { u: apiUrl } : {}), - ...(resource ? { r: resource.type, p: resource.params } : {}), - }); - const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); - const tag = cipher.getAuthTag(); - return Buffer.concat([iv, tag, encrypted]).toString("base64url"); -} - -function decryptDownloadToken( - tokenStr: string -): { - header: string; - token: string; - apiUrl?: string; - resourceType?: string; - resourceParams?: Record; -} | null { - try { - const buf = Buffer.from(tokenStr, "base64url"); - if (buf.length < 29) return null; - const iv = buf.subarray(0, 12); - const tag = buf.subarray(12, 28); - const encrypted = buf.subarray(28); - const decipher = createDecipheriv("aes-256-gcm", DOWNLOAD_TOKEN_KEY, iv); - decipher.setAuthTag(tag); - const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); - const payload = JSON.parse(decrypted.toString("utf8")); - // Check TTL - if (payload.e && Math.floor(Date.now() / 1000) > payload.e) { - return null; // expired - } - return { - header: payload.h, - token: payload.t, - ...(payload.u ? { apiUrl: payload.u } : {}), - ...(payload.r ? { resourceType: payload.r, resourceParams: payload.p } : {}), - }; - } catch { - return null; - } -} - - /** * Build a URL pointing to the download proxy endpoint. * Embeds an encrypted auth token (and API URL for dynamic routing) @@ -199,6 +126,7 @@ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import { ipKeyGenerator } from "express-rate-limit"; import { normalizeProxyClientIpForRateLimit } from "./utils/proxy-client-ip.js"; import { getForwardedPublicBaseUrl } from "./utils/forwarded-public-base-url.js"; +import { createDownloadToken, decryptDownloadToken } from "./utils/download-token.js"; import { normalizeGitLabApiUrl } from "./utils/url.js"; import { estimateMergeCommitCount, @@ -573,7 +501,7 @@ import { HealthCheckSchema, } from "./schemas.js"; -import { randomUUID, createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { pino } from "pino"; const logger = pino({ diff --git a/package.json b/package.json index 080d5d8b..89dc5c19 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "changelog": "auto-changelog -p", "test": "npm run test:all", "test:all": "npm run build && npm run test:mock && npm run test:live", - "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts", + "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/utils/download-token.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts", "test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts", "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts", "test:live": "node test/validate-api.js", diff --git a/test/utils/download-token.test.ts b/test/utils/download-token.test.ts new file mode 100644 index 00000000..09483dbf --- /dev/null +++ b/test/utils/download-token.test.ts @@ -0,0 +1,46 @@ +import { describe, test } from "node:test"; +import assert from "node:assert"; +import { createDownloadToken, decryptDownloadToken } from "../../utils/download-token.js"; + +describe("download token helpers", () => { + test("round-trips auth, api URL, and resource binding", () => { + const token = createDownloadToken( + "Authorization", + "Bearer glpat-example-token", + "https://gitlab.example.com/api/v4", + { + type: "attachment", + params: { project_id: "group/project", secret: "abc", filename: "file.txt" }, + } + ); + + const decrypted = decryptDownloadToken(token); + + assert.deepStrictEqual(decrypted, { + header: "Authorization", + token: "Bearer glpat-example-token", + apiUrl: "https://gitlab.example.com/api/v4", + resourceType: "attachment", + resourceParams: { project_id: "group/project", secret: "abc", filename: "file.txt" }, + }); + }); + + test("returns null for invalid tokens", () => { + assert.strictEqual(decryptDownloadToken("not-a-valid-token"), null); + }); + + test("returns null for expired tokens", () => { + const originalNow = Date.now; + const issuedAt = 1_700_000_000_000; + + try { + Date.now = () => issuedAt; + const token = createDownloadToken("Private-Token", "glpat-example-token"); + + Date.now = () => issuedAt + 301_000; + assert.strictEqual(decryptDownloadToken(token), null); + } finally { + Date.now = originalNow; + } + }); +}); diff --git a/utils/download-token.ts b/utils/download-token.ts new file mode 100644 index 00000000..8fdfe2e8 --- /dev/null +++ b/utils/download-token.ts @@ -0,0 +1,90 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; + +export interface DownloadTokenResource { + type: string; + params: Record; +} + +export interface DecryptedDownloadToken { + header: string; + token: string; + apiUrl?: string; + resourceType?: string; + resourceParams?: Record; +} + +/** + * Encryption key for download tokens. When DOWNLOAD_TOKEN_SECRET is set + * (recommended for HA deployments behind a load balancer) the key is + * derived from that value so all replicas share the same key. Otherwise + * a random key is generated per process (tokens are not portable across + * restarts or replicas). + */ +const DOWNLOAD_TOKEN_KEY: Buffer = (() => { + const secret = process.env.DOWNLOAD_TOKEN_SECRET; + if (secret) { + return createHash("sha256").update(secret).digest(); + } + return randomBytes(32); +})(); + +/** Download token TTL in seconds (default 5 minutes). */ +const DEFAULT_DOWNLOAD_TOKEN_TTL = 300; +const parsedDownloadTokenTtl = Number.parseInt(process.env.DOWNLOAD_TOKEN_TTL || "", 10); +const DOWNLOAD_TOKEN_TTL = + Number.isFinite(parsedDownloadTokenTtl) && parsedDownloadTokenTtl > 0 + ? parsedDownloadTokenTtl + : DEFAULT_DOWNLOAD_TOKEN_TTL; + +/** + * Create a self-contained encrypted download token for a specific GitLab resource. + */ +export function createDownloadToken( + header: string, + token: string, + apiUrl?: string, + resource?: DownloadTokenResource +): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", DOWNLOAD_TOKEN_KEY, iv); + const payload = JSON.stringify({ + h: header, + t: token, + e: Math.floor(Date.now() / 1000) + DOWNLOAD_TOKEN_TTL, + ...(apiUrl ? { u: apiUrl } : {}), + ...(resource ? { r: resource.type, p: resource.params } : {}), + }); + const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString("base64url"); +} + +/** + * Decrypt and validate a download token, returning null when it is invalid or expired. + */ +export function decryptDownloadToken(tokenStr: string): DecryptedDownloadToken | null { + try { + const buf = Buffer.from(tokenStr, "base64url"); + if (buf.length < 29) return null; + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const encrypted = buf.subarray(28); + const decipher = createDecipheriv("aes-256-gcm", DOWNLOAD_TOKEN_KEY, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + const payload = JSON.parse(decrypted.toString("utf8")); + if (payload.e && Math.floor(Date.now() / 1000) > payload.e) { + return null; + } + if (typeof payload.h !== "string" || payload.h.length === 0) return null; + if (typeof payload.t !== "string" || payload.t.length === 0) return null; + return { + header: payload.h, + token: payload.t, + ...(payload.u ? { apiUrl: payload.u } : {}), + ...(payload.r ? { resourceType: payload.r, resourceParams: payload.p } : {}), + }; + } catch { + return null; + } +}