From f1d4f51adbcbe3203fcb4a99876696beefaa924c Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:02:22 -0700 Subject: [PATCH 01/15] Add failing JWT tests --- src/__tests__/oauth-jwt.test.ts | 152 ++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/__tests__/oauth-jwt.test.ts diff --git a/src/__tests__/oauth-jwt.test.ts b/src/__tests__/oauth-jwt.test.ts new file mode 100644 index 0000000..6d808eb --- /dev/null +++ b/src/__tests__/oauth-jwt.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import { + signJWT, + verifyJWT, + InvalidSignature, + TokenExpired, + InvalidAudience, + MalformedToken, +} from "../oauth/jwt.js"; + +const SECRET = "a".repeat(32); +const OTHER_SECRET = "b".repeat(32); + +function nowSec() { + return Math.floor(Date.now() / 1000); +} + +describe("signJWT / verifyJWT", () => { + it("signJWT returns 3 dot-separated base64url segments", () => { + const token = signJWT( + { sub: "anonymous", iat: nowSec(), exp: nowSec() + 3600 }, + SECRET, + ); + const parts = token.split("."); + expect(parts).toHaveLength(3); + for (const seg of parts) { + expect(seg).toMatch(/^[A-Za-z0-9_-]+$/); + } + }); + + it("header contains alg: HS256 and typ: JWT", () => { + const token = signJWT( + { sub: "x", iat: nowSec(), exp: nowSec() + 60 }, + SECRET, + ); + const headerB64 = token.split(".")[0]; + const pad = "=".repeat((4 - (headerB64.length % 4)) % 4); + const header = JSON.parse( + Buffer.from( + headerB64.replace(/-/g, "+").replace(/_/g, "/") + pad, + "base64", + ).toString("utf8"), + ); + expect(header.alg).toBe("HS256"); + expect(header.typ).toBe("JWT"); + }); + + it("verifyJWT returns decoded payload", () => { + const payload = { + sub: "anonymous", + client_id: "abc", + iat: nowSec(), + exp: nowSec() + 3600, + }; + const token = signJWT(payload, SECRET); + const decoded = verifyJWT(token, SECRET); + expect(decoded.sub).toBe("anonymous"); + expect(decoded.client_id).toBe("abc"); + }); + + it("throws InvalidSignature when secret differs", () => { + const token = signJWT( + { sub: "x", iat: nowSec(), exp: nowSec() + 60 }, + SECRET, + ); + expect(() => verifyJWT(token, OTHER_SECRET)).toThrow(InvalidSignature); + }); + + it("throws TokenExpired when exp < now", () => { + const token = signJWT( + { sub: "x", iat: nowSec() - 100, exp: nowSec() - 50 }, + SECRET, + ); + expect(() => verifyJWT(token, SECRET)).toThrow(TokenExpired); + }); + + it("throws InvalidAudience when aud mismatch", () => { + const token = signJWT( + { + sub: "x", + aud: "https://a.example", + iat: nowSec(), + exp: nowSec() + 60, + }, + SECRET, + ); + expect(() => + verifyJWT(token, SECRET, { aud: "https://b.example" }), + ).toThrow(InvalidAudience); + }); + + it("accepts matching aud", () => { + const token = signJWT( + { + sub: "x", + aud: "https://a.example", + iat: nowSec(), + exp: nowSec() + 60, + }, + SECRET, + ); + expect(() => + verifyJWT(token, SECRET, { aud: "https://a.example" }), + ).not.toThrow(); + }); + + it("throws MalformedToken for non-3-segment input", () => { + expect(() => verifyJWT("only.two", SECRET)).toThrow(MalformedToken); + expect(() => verifyJWT("one", SECRET)).toThrow(MalformedToken); + expect(() => verifyJWT("a.b.c.d", SECRET)).toThrow(MalformedToken); + }); + + it("throws MalformedToken for empty string", () => { + expect(() => verifyJWT("", SECRET)).toThrow(MalformedToken); + }); + + it("throws MalformedToken for invalid base64url payload", () => { + // Valid header, gibberish payload + expect(() => verifyJWT("aaa.!!!.bbb", SECRET)).toThrow(MalformedToken); + }); + + it("base64url round-trip handles + / = characters", () => { + // A payload whose JSON, when base64-encoded, contains + / = in standard base64. + // Force this by using content that b64-encodes with padding and special chars. + const payload = { + sub: "\u00ff\u00fe\u00fd", + data: "??>>//++==", + iat: nowSec(), + exp: nowSec() + 60, + }; + const token = signJWT(payload, SECRET); + expect(token).not.toContain("+"); + expect(token).not.toContain("/"); + expect(token).not.toContain("="); + const decoded = verifyJWT(token, SECRET); + expect(decoded.sub).toBe(payload.sub); + expect(decoded.data).toBe(payload.data); + }); + + it("honors clockSkewSec for recently expired tokens", () => { + const token = signJWT( + { sub: "x", iat: nowSec() - 100, exp: nowSec() - 10 }, + SECRET, + ); + // Default skew is 30s — 10s expired should still verify + expect(() => verifyJWT(token, SECRET)).not.toThrow(); + // With 0 skew it should throw + expect(() => verifyJWT(token, SECRET, { clockSkewSec: 0 })).toThrow( + TokenExpired, + ); + }); +}); From 0b888d5fe18c1519c5dd7db14403b8b9d7a3a275 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:02:48 -0700 Subject: [PATCH 02/15] Implement HS256 JWT sign/verify with base64url --- src/oauth/jwt.ts | 149 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/oauth/jwt.ts diff --git a/src/oauth/jwt.ts b/src/oauth/jwt.ts new file mode 100644 index 0000000..3f3c0fe --- /dev/null +++ b/src/oauth/jwt.ts @@ -0,0 +1,149 @@ +// Hand-rolled HS256 JWT using node:crypto — no external deps. +// Isolated here so we can swap for `jose` later if asymmetric keys are needed. + +import { createHmac, timingSafeEqual } from "node:crypto"; + +export class InvalidSignature extends Error { + constructor() { + super("Invalid JWT signature"); + this.name = "InvalidSignature"; + } +} + +export class TokenExpired extends Error { + constructor() { + super("JWT token expired"); + this.name = "TokenExpired"; + } +} + +export class InvalidAudience extends Error { + constructor() { + super("JWT audience mismatch"); + this.name = "InvalidAudience"; + } +} + +export class MalformedToken extends Error { + constructor(reason?: string) { + super(`Malformed JWT${reason ? `: ${reason}` : ""}`); + this.name = "MalformedToken"; + } +} + +export interface JWTPayload { + sub: string; + iat: number; + exp: number; + aud?: string; + iss?: string; + client_id?: string; + [key: string]: unknown; +} + +export interface VerifyOptions { + aud?: string; + clockSkewSec?: number; +} + +function base64urlEncode(input: Buffer | string): string { + const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input; + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function base64urlDecodeToBuffer(input: string): Buffer { + if (!/^[A-Za-z0-9_-]*$/.test(input)) { + throw new MalformedToken("invalid base64url characters"); + } + const b64 = input.replace(/-/g, "+").replace(/_/g, "/"); + const pad = "=".repeat((4 - (b64.length % 4)) % 4); + return Buffer.from(b64 + pad, "base64"); +} + +function base64urlDecodeJson(input: string): unknown { + const buf = base64urlDecodeToBuffer(input); + try { + return JSON.parse(buf.toString("utf8")); + } catch { + throw new MalformedToken("invalid JSON"); + } +} + +const HEADER = { alg: "HS256", typ: "JWT" }; +const HEADER_B64 = base64urlEncode(JSON.stringify(HEADER)); + +function sign(data: string, secret: string): string { + const mac = createHmac("sha256", secret).update(data).digest(); + return base64urlEncode(mac); +} + +export function signJWT(payload: JWTPayload, secret: string): string { + const payloadB64 = base64urlEncode(JSON.stringify(payload)); + const signingInput = `${HEADER_B64}.${payloadB64}`; + const sig = sign(signingInput, secret); + return `${signingInput}.${sig}`; +} + +export function verifyJWT( + token: string, + secret: string, + opts: VerifyOptions = {}, +): JWTPayload { + if (!token || typeof token !== "string") { + throw new MalformedToken("empty token"); + } + const parts = token.split("."); + if (parts.length !== 3) { + throw new MalformedToken(`expected 3 segments, got ${parts.length}`); + } + const [headerB64, payloadB64, sigB64] = parts; + if (!headerB64 || !payloadB64 || !sigB64) { + throw new MalformedToken("empty segment"); + } + + // Verify header + const header = base64urlDecodeJson(headerB64) as { + alg?: string; + typ?: string; + }; + if (header.alg !== "HS256") { + throw new MalformedToken(`unsupported alg: ${header.alg}`); + } + + // Verify signature (timing-safe) + const expectedSigBuf = base64urlDecodeToBuffer( + sign(`${headerB64}.${payloadB64}`, secret), + ); + const presentedSigBuf = base64urlDecodeToBuffer(sigB64); + if ( + expectedSigBuf.length !== presentedSigBuf.length || + !timingSafeEqual(expectedSigBuf, presentedSigBuf) + ) { + throw new InvalidSignature(); + } + + const payload = base64urlDecodeJson(payloadB64) as JWTPayload; + if ( + typeof payload !== "object" || + payload === null || + typeof payload.exp !== "number" + ) { + throw new MalformedToken("missing or invalid exp claim"); + } + + const clockSkewSec = opts.clockSkewSec ?? 30; + const nowSec = Math.floor(Date.now() / 1000); + if (payload.exp + clockSkewSec < nowSec) { + throw new TokenExpired(); + } + + if (opts.aud && payload.aud !== opts.aud) { + throw new InvalidAudience(); + } + + return payload; +} From e8e455d49db82e14da1efeebbc90faacbf2ffc80 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:03:10 -0700 Subject: [PATCH 03/15] Add failing OAuth store tests --- src/__tests__/oauth-store.test.ts | 116 ++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/__tests__/oauth-store.test.ts diff --git a/src/__tests__/oauth-store.test.ts b/src/__tests__/oauth-store.test.ts new file mode 100644 index 0000000..5cad15e --- /dev/null +++ b/src/__tests__/oauth-store.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { ClientStore, CodeStore } from "../oauth/store.js"; + +describe("ClientStore", () => { + let store: ClientStore; + beforeEach(() => { + store = new ClientStore(); + }); + + it("register returns client_id, client_id_issued_at, and echoes redirect_uris", () => { + const result = store.register({ + redirect_uris: ["https://example.com/cb"], + }); + expect(result.client_id).toBeDefined(); + expect(typeof result.client_id).toBe("string"); + expect(result.client_id_issued_at).toBeTypeOf("number"); + expect(result.redirect_uris).toEqual(["https://example.com/cb"]); + }); + + it("get(client_id) returns registered client", () => { + const { client_id } = store.register({ + redirect_uris: ["https://example.com/cb"], + }); + const fetched = store.get(client_id); + expect(fetched).toBeDefined(); + expect(fetched?.redirect_uris).toEqual(["https://example.com/cb"]); + }); + + it("two registers return distinct UUIDs", () => { + const a = store.register({ redirect_uris: [] }); + const b = store.register({ redirect_uris: [] }); + expect(a.client_id).not.toBe(b.client_id); + }); + + it("get returns undefined for unknown client", () => { + expect(store.get("nope")).toBeUndefined(); + }); + + it("accepts empty redirect_uris array", () => { + const r = store.register({ redirect_uris: [] }); + expect(r.redirect_uris).toEqual([]); + }); +}); + +describe("CodeStore", () => { + let store: CodeStore; + + beforeEach(() => { + vi.useFakeTimers(); + store = new CodeStore(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("issue returns code and expiresAt", () => { + const result = store.issue({ + clientId: "c1", + codeChallenge: "abc", + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + expect(result.code).toBeDefined(); + expect(result.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("consume returns record once, then undefined", () => { + const { code } = store.issue({ + clientId: "c1", + codeChallenge: "abc", + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + const first = store.consume(code); + expect(first).toBeDefined(); + expect(first?.clientId).toBe("c1"); + expect(first?.codeChallenge).toBe("abc"); + expect(first?.redirectUri).toBe("https://x.example/cb"); + + const second = store.consume(code); + expect(second).toBeUndefined(); + }); + + it("returns undefined for expired codes", () => { + const { code } = store.issue({ + clientId: "c1", + codeChallenge: "abc", + redirectUri: "https://x.example/cb", + ttlMs: 1000, + }); + vi.advanceTimersByTime(1500); + const result = store.consume(code); + expect(result).toBeUndefined(); + }); + + it("returns undefined for unknown code", () => { + expect(store.consume("notacode")).toBeUndefined(); + }); + + it("issues distinct codes on repeat calls", () => { + const a = store.issue({ + clientId: "c1", + codeChallenge: "x", + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + const b = store.issue({ + clientId: "c1", + codeChallenge: "x", + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + expect(a.code).not.toBe(b.code); + }); +}); From f001a424eaad742ee2edf976a19d869b89278234 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:03:26 -0700 Subject: [PATCH 04/15] Add in-memory OAuth client and code stores --- src/oauth/store.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/oauth/store.ts diff --git a/src/oauth/store.ts b/src/oauth/store.ts new file mode 100644 index 0000000..f73ec67 --- /dev/null +++ b/src/oauth/store.ts @@ -0,0 +1,70 @@ +// In-memory stores for OAuth state — dynamic clients and authorization codes. +// Singleton exports match the `src/ip-limiter.ts` pattern. + +import { randomUUID } from "node:crypto"; + +export interface RegisteredClient { + client_id: string; + client_id_issued_at: number; + redirect_uris: string[]; +} + +export interface AuthCode { + clientId: string; + codeChallenge: string; + redirectUri: string; + expiresAt: number; +} + +export interface IssueCodeInput { + clientId: string; + codeChallenge: string; + redirectUri: string; + ttlMs: number; +} + +export class ClientStore { + private clients = new Map(); + + register(input: { redirect_uris: string[] }): RegisteredClient { + const client: RegisteredClient = { + client_id: randomUUID(), + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: [...input.redirect_uris], + }; + this.clients.set(client.client_id, client); + return client; + } + + get(clientId: string): RegisteredClient | undefined { + return this.clients.get(clientId); + } +} + +export class CodeStore { + private codes = new Map(); + + issue(input: IssueCodeInput): { code: string; expiresAt: number } { + const code = randomUUID(); + const expiresAt = Date.now() + input.ttlMs; + this.codes.set(code, { + clientId: input.clientId, + codeChallenge: input.codeChallenge, + redirectUri: input.redirectUri, + expiresAt, + }); + return { code, expiresAt }; + } + + consume(code: string): AuthCode | undefined { + const record = this.codes.get(code); + if (!record) return undefined; + // One-time use: always remove on consume attempt + this.codes.delete(code); + if (record.expiresAt < Date.now()) return undefined; + return record; + } +} + +export const clientStore = new ClientStore(); +export const codeStore = new CodeStore(); From 3196370556ebe5252ac169f256d1163b7999b031 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:03:45 -0700 Subject: [PATCH 05/15] Add failing JWT secret resolution tests --- src/__tests__/oauth-secret.test.ts | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/__tests__/oauth-secret.test.ts diff --git a/src/__tests__/oauth-secret.test.ts b/src/__tests__/oauth-secret.test.ts new file mode 100644 index 0000000..4659ecd --- /dev/null +++ b/src/__tests__/oauth-secret.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + resolveJwtSecret, + resetJwtSecretCache, +} from "../oauth/secret.js"; + +describe("resolveJwtSecret", () => { + const savedSecret = process.env.MCP_JWT_SECRET; + + beforeEach(() => { + resetJwtSecretCache(); + delete process.env.MCP_JWT_SECRET; + }); + + afterEach(() => { + resetJwtSecretCache(); + if (savedSecret === undefined) { + delete process.env.MCP_JWT_SECRET; + } else { + process.env.MCP_JWT_SECRET = savedSecret; + } + }); + + it("development + env unset → generates 32-byte hex and warns", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const secret = resolveJwtSecret({ nodeEnv: "development" }); + expect(secret).toMatch(/^[0-9a-f]{64}$/); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("same call twice returns same string (cached)", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const a = resolveJwtSecret({ nodeEnv: "development" }); + const b = resolveJwtSecret({ nodeEnv: "development" }); + expect(a).toBe(b); + }); + + it("production + env unset → throws", () => { + expect(() => resolveJwtSecret({ nodeEnv: "production" })).toThrow( + /MCP_JWT_SECRET/, + ); + }); + + it("production + env set → returns env value, no warning", () => { + process.env.MCP_JWT_SECRET = "x".repeat(64); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const secret = resolveJwtSecret({ nodeEnv: "production" }); + expect(secret).toBe("x".repeat(64)); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("rejects secret < 16 bytes in development", () => { + process.env.MCP_JWT_SECRET = "short"; + expect(() => resolveJwtSecret({ nodeEnv: "development" })).toThrow( + /at least 16/, + ); + }); + + it("rejects secret < 16 bytes in production", () => { + process.env.MCP_JWT_SECRET = "short"; + expect(() => resolveJwtSecret({ nodeEnv: "production" })).toThrow( + /at least 16/, + ); + }); +}); From 429273d5fddb940f44410ee29fe95754b4be263a Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:04:37 -0700 Subject: [PATCH 06/15] Implement JWT secret resolution with dev/prod policy --- src/__tests__/oauth-secret.test.ts | 1 + src/oauth/secret.ts | 44 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/oauth/secret.ts diff --git a/src/__tests__/oauth-secret.test.ts b/src/__tests__/oauth-secret.test.ts index 4659ecd..5aa8703 100644 --- a/src/__tests__/oauth-secret.test.ts +++ b/src/__tests__/oauth-secret.test.ts @@ -13,6 +13,7 @@ describe("resolveJwtSecret", () => { }); afterEach(() => { + vi.restoreAllMocks(); resetJwtSecretCache(); if (savedSecret === undefined) { delete process.env.MCP_JWT_SECRET; diff --git a/src/oauth/secret.ts b/src/oauth/secret.ts new file mode 100644 index 0000000..921d81e --- /dev/null +++ b/src/oauth/secret.ts @@ -0,0 +1,44 @@ +// Resolves MCP_JWT_SECRET with dev/prod policy. Cached after first resolution +// so generated dev secrets remain stable within a single process. + +import { randomBytes } from "node:crypto"; + +const MIN_BYTES = 16; + +let cached: string | null = null; + +export function resolveJwtSecret(opts: { nodeEnv: string }): string { + if (cached !== null) return cached; + + const fromEnv = process.env.MCP_JWT_SECRET; + if (fromEnv && fromEnv.length > 0) { + if (Buffer.byteLength(fromEnv, "utf8") < MIN_BYTES) { + throw new Error( + `MCP_JWT_SECRET must be at least ${MIN_BYTES} bytes. ` + + "Generate with: openssl rand -hex 32", + ); + } + cached = fromEnv; + return cached; + } + + if (opts.nodeEnv === "production") { + throw new Error( + "MCP_JWT_SECRET is required in production. " + + "Generate with: openssl rand -hex 32", + ); + } + + const generated = randomBytes(32).toString("hex"); + console.warn( + "[oauth] MCP_JWT_SECRET not set — generated an ephemeral secret for development. " + + "All issued tokens will be invalidated on restart. " + + "Set MCP_JWT_SECRET to persist across restarts.", + ); + cached = generated; + return cached; +} + +export function resetJwtSecretCache(): void { + cached = null; +} From 2ca6777b9c7cd2b3ca7f89facfc19b569dd87f7b Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:07:01 -0700 Subject: [PATCH 07/15] Expose MCP_JWT_SECRET via Config --- package-lock.json | 14 +++++++++++--- src/__tests__/analytics-endpoints.test.ts | 3 +++ src/__tests__/analytics-server.test.ts | 1 + src/__tests__/config.test.ts | 1 + src/__tests__/validate.test.ts | 2 ++ src/config.ts | 8 +++++++- 6 files changed, 25 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5defbf..4d5c565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "@copilotkit/pathfinder", - "version": "1.5.0", + "version": "1.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@copilotkit/pathfinder", - "version": "1.5.0", - "license": "MIT", + "version": "1.11.1", + "license": "Elastic-2.0", "dependencies": { "@discordjs/rest": "^2.6.1", "@modelcontextprotocol/sdk": "^1.25.2", @@ -40,6 +40,14 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.1.2" + }, + "peerDependencies": { + "@xenova/transformers": "^2.17.0" + }, + "peerDependenciesMeta": { + "@xenova/transformers": { + "optional": true + } } }, "node_modules/@borewit/text-codec": { diff --git a/src/__tests__/analytics-endpoints.test.ts b/src/__tests__/analytics-endpoints.test.ts index 951761f..4af6f74 100644 --- a/src/__tests__/analytics-endpoints.test.ts +++ b/src/__tests__/analytics-endpoints.test.ts @@ -31,6 +31,7 @@ vi.mock("../config.js", () => ({ discordBotToken: "", discordPublicKey: "", notionToken: "", + mcpJwtSecret: "x".repeat(32), }), hasSearchTools: vi.fn().mockReturnValue(false), hasKnowledgeTools: vi.fn().mockReturnValue(false), @@ -296,6 +297,7 @@ describe("analyticsAuth middleware", () => { discordBotToken: "", discordPublicKey: "", notionToken: "", + mcpJwtSecret: "x".repeat(32), }); const res = mockRes(); const next = vi.fn(); @@ -329,6 +331,7 @@ describe("analyticsAuth middleware", () => { discordBotToken: "", discordPublicKey: "", notionToken: "", + mcpJwtSecret: "x".repeat(32), }); const res = mockRes(); const next = vi.fn(); diff --git a/src/__tests__/analytics-server.test.ts b/src/__tests__/analytics-server.test.ts index d7951df..e580ffc 100644 --- a/src/__tests__/analytics-server.test.ts +++ b/src/__tests__/analytics-server.test.ts @@ -31,6 +31,7 @@ vi.mock("../config.js", () => ({ discordBotToken: "", discordPublicKey: "", notionToken: "", + mcpJwtSecret: "x".repeat(32), }), hasSearchTools: vi.fn().mockReturnValue(false), hasKnowledgeTools: vi.fn().mockReturnValue(false), diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 346fc15..fabad06 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1034,6 +1034,7 @@ describe("config.ts", () => { process.env.CLONE_DIR = "/tmp/clones"; process.env.GITHUB_TOKEN = "ghp_test"; process.env.GITHUB_WEBHOOK_SECRET = "secret"; + process.env.MCP_JWT_SECRET = "x".repeat(64); mockedExistsSync.mockReturnValue(true); mockedReadFileSync.mockReturnValue(makeYaml()); diff --git a/src/__tests__/validate.test.ts b/src/__tests__/validate.test.ts index 3b313e4..9f31953 100644 --- a/src/__tests__/validate.test.ts +++ b/src/__tests__/validate.test.ts @@ -75,6 +75,7 @@ vi.mock("../config.js", () => { logLevel: "info", cloneDir: "/tmp/test", notionToken: "", + mcpJwtSecret: "x".repeat(32), }), getServerConfig: vi.fn().mockReturnValue({ server: { name: "test", version: "1.0" }, @@ -193,6 +194,7 @@ const defaultConfig = { logLevel: "info", cloneDir: "/tmp/test", notionToken: "", + mcpJwtSecret: "x".repeat(32), }; describe("validateConfig", () => { diff --git a/src/config.ts b/src/config.ts index e7b735a..8435269 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ import { isDiscordSourceConfig, isFileSourceConfig, } from "./types.js"; +import { resolveJwtSecret } from "./oauth/secret.js"; // ── Environment variable config (secrets and runtime settings) ──────────────── @@ -28,6 +29,7 @@ export interface Config { discordBotToken: string; discordPublicKey: string; notionToken: string; + mcpJwtSecret: string; } /** @@ -145,13 +147,16 @@ function parseConfig(): Config { ); } + const nodeEnv = process.env.NODE_ENV || "development"; + const mcpJwtSecret = resolveJwtSecret({ nodeEnv }); + return { databaseUrl, openaiApiKey: openaiApiKey ?? "", githubToken: process.env.GITHUB_TOKEN || "", githubWebhookSecret: githubWebhookSecret!, port, - nodeEnv: process.env.NODE_ENV || "development", + nodeEnv, logLevel: process.env.LOG_LEVEL || "info", cloneDir: process.env.CLONE_DIR || "/tmp/mcp-repos", slackBotToken, @@ -159,6 +164,7 @@ function parseConfig(): Config { discordBotToken, discordPublicKey, notionToken, + mcpJwtSecret, }; } From cbcd917784789967c166aad60731ecbf926e9559 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:07:20 -0700 Subject: [PATCH 08/15] Add failing OAuth rate limiter tests --- src/__tests__/oauth-rate-limiter.test.ts | 59 ++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/__tests__/oauth-rate-limiter.test.ts diff --git a/src/__tests__/oauth-rate-limiter.test.ts b/src/__tests__/oauth-rate-limiter.test.ts new file mode 100644 index 0000000..3c52c03 --- /dev/null +++ b/src/__tests__/oauth-rate-limiter.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { OAuthRateLimiter } from "../oauth/rate-limiter.js"; + +describe("OAuthRateLimiter", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 1, 0, 0, 0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("check returns {ok: true} under the limit", () => { + const limiter = new OAuthRateLimiter(3, 60_000); + expect(limiter.check("1.2.3.4")).toEqual({ ok: true }); + expect(limiter.check("1.2.3.4")).toEqual({ ok: true }); + expect(limiter.check("1.2.3.4")).toEqual({ ok: true }); + }); + + it("returns {ok: false, retryAfterSec} when exceeded", () => { + const limiter = new OAuthRateLimiter(2, 60_000); + limiter.check("1.2.3.4"); + limiter.check("1.2.3.4"); + const result = limiter.check("1.2.3.4"); + expect(result.ok).toBe(false); + expect(result.retryAfterSec).toBeGreaterThan(0); + expect(result.retryAfterSec).toBeLessThanOrEqual(60); + }); + + it("resets after windowMs elapses", () => { + const limiter = new OAuthRateLimiter(2, 60_000); + limiter.check("1.2.3.4"); + limiter.check("1.2.3.4"); + expect(limiter.check("1.2.3.4").ok).toBe(false); + + vi.advanceTimersByTime(60_001); + expect(limiter.check("1.2.3.4").ok).toBe(true); + }); + + it("tracks different IPs independently", () => { + const limiter = new OAuthRateLimiter(1, 60_000); + expect(limiter.check("1.2.3.4").ok).toBe(true); + expect(limiter.check("5.6.7.8").ok).toBe(true); + expect(limiter.check("1.2.3.4").ok).toBe(false); + expect(limiter.check("5.6.7.8").ok).toBe(false); + }); + + it("retryAfterSec reflects time remaining in the current window", () => { + const limiter = new OAuthRateLimiter(1, 60_000); + limiter.check("1.2.3.4"); + vi.advanceTimersByTime(15_000); + const result = limiter.check("1.2.3.4"); + expect(result.ok).toBe(false); + // Roughly 45 seconds left + expect(result.retryAfterSec).toBeLessThanOrEqual(45); + expect(result.retryAfterSec).toBeGreaterThanOrEqual(44); + }); +}); From e879ebe5fa625b8258d93bdbea9b2a80543af4d8 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:07:34 -0700 Subject: [PATCH 09/15] Add per-endpoint OAuth rate limiting --- src/oauth/rate-limiter.ts | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/oauth/rate-limiter.ts diff --git a/src/oauth/rate-limiter.ts b/src/oauth/rate-limiter.ts new file mode 100644 index 0000000..6b7098d --- /dev/null +++ b/src/oauth/rate-limiter.ts @@ -0,0 +1,45 @@ +// Fixed-window per-IP rate limiter for OAuth endpoints. +// Simple and cheap; the in-memory Map naturally bounded by unique IPs per window. + +interface WindowState { + count: number; + windowStart: number; +} + +export interface CheckResult { + ok: boolean; + retryAfterSec?: number; +} + +export class OAuthRateLimiter { + private readonly max: number; + private readonly windowMs: number; + private readonly buckets = new Map(); + + constructor(max: number, windowMs: number) { + this.max = max; + this.windowMs = windowMs; + } + + check(ip: string): CheckResult { + const now = Date.now(); + const state = this.buckets.get(ip); + if (!state || now - state.windowStart >= this.windowMs) { + this.buckets.set(ip, { count: 1, windowStart: now }); + return { ok: true }; + } + + if (state.count < this.max) { + state.count += 1; + return { ok: true }; + } + + const elapsed = now - state.windowStart; + const retryAfterSec = Math.max(1, Math.ceil((this.windowMs - elapsed) / 1000)); + return { ok: false, retryAfterSec }; + } +} + +export const registerLimiter = new OAuthRateLimiter(10, 60_000); +export const authorizeLimiter = new OAuthRateLimiter(30, 60_000); +export const tokenLimiter = new OAuthRateLimiter(30, 60_000); From 68a728f0a584d6ab5b304d2b7398b5e3cf8f545a Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:09:24 -0700 Subject: [PATCH 10/15] Add failing OAuth handler tests --- src/__tests__/oauth-handlers.test.ts | 807 +++++++++++++++++++++++++++ 1 file changed, 807 insertions(+) create mode 100644 src/__tests__/oauth-handlers.test.ts diff --git a/src/__tests__/oauth-handlers.test.ts b/src/__tests__/oauth-handlers.test.ts new file mode 100644 index 0000000..c657c2f --- /dev/null +++ b/src/__tests__/oauth-handlers.test.ts @@ -0,0 +1,807 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createHash, randomBytes } from "node:crypto"; + +// Mock the config module because handlers import it for origin derivation, +// JWT secret, and server port. +vi.mock("../config.js", () => ({ + getConfig: vi.fn().mockReturnValue({ + port: 3001, + databaseUrl: "pglite:///tmp/test", + openaiApiKey: "", + githubToken: "", + githubWebhookSecret: "", + nodeEnv: "test", + logLevel: "info", + cloneDir: "/tmp/test", + slackBotToken: "", + slackSigningSecret: "", + discordBotToken: "", + discordPublicKey: "", + notionToken: "", + mcpJwtSecret: "a".repeat(64), + }), + getServerConfig: vi.fn(), + getAnalyticsConfig: vi.fn(), + hasSearchTools: vi.fn().mockReturnValue(false), + hasKnowledgeTools: vi.fn().mockReturnValue(false), + hasCollectTools: vi.fn().mockReturnValue(false), + hasBashSemanticSearch: vi.fn().mockReturnValue(false), +})); + +import { + protectedResourceHandler, + authorizationServerHandler, + registerHandler, + authorizeHandler, + tokenHandler, + bearerMiddleware, +} from "../oauth/handlers.js"; +import { clientStore, codeStore } from "../oauth/store.js"; +import { signJWT } from "../oauth/jwt.js"; + +function mockReq(overrides: Record = {}): Record { + return { + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "1.2.3.4", + }, + query: {}, + body: {}, + socket: { remoteAddress: "1.2.3.4" }, + ...overrides, + }; +} + +function mockRes() { + const json = vi.fn(); + const send = vi.fn(); + const redirect = vi.fn(); + const setHeader = vi.fn(); + const status = vi.fn().mockImplementation(() => ({ json, send })); + return { + json, + send, + redirect, + setHeader, + status, + get statusCode() { + return status.mock.calls.at(-1)?.[0]; + }, + }; +} + +// Reset singleton store state between tests by clearing internal maps. +// Since we don't want to export them, we use the module's exports and +// re-register for each test. + +beforeEach(() => { + // Reset stores — cast is safe; tests own the module + const cs = clientStore as unknown as { clients: Map }; + cs.clients.clear(); + const cds = codeStore as unknown as { codes: Map }; + cds.codes.clear(); +}); + +describe("protectedResourceHandler", () => { + it("returns resource + authorization_servers derived from host/proto", () => { + const req = mockReq(); + const res = mockRes(); + protectedResourceHandler(req as never, res as never); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "https://mcp.example.com", + authorization_servers: ["https://mcp.example.com"], + bearer_methods_supported: ["header"], + }), + ); + }); + + it("falls back to http and request host when x-forwarded-proto missing", () => { + const req = mockReq({ + headers: { host: "localhost:3001" }, + }); + const res = mockRes(); + protectedResourceHandler(req as never, res as never); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "http://localhost:3001", + }), + ); + }); +}); + +describe("authorizationServerHandler", () => { + it("returns complete AS metadata", () => { + const req = mockReq(); + const res = mockRes(); + authorizationServerHandler(req as never, res as never); + const body = res.json.mock.calls[0][0]; + expect(body.issuer).toBe("https://mcp.example.com"); + expect(body.authorization_endpoint).toBe( + "https://mcp.example.com/authorize", + ); + expect(body.token_endpoint).toBe("https://mcp.example.com/token"); + expect(body.registration_endpoint).toBe( + "https://mcp.example.com/register", + ); + expect(body.response_types_supported).toContain("code"); + expect(body.grant_types_supported).toContain("authorization_code"); + expect(body.code_challenge_methods_supported).toContain("S256"); + expect(body.token_endpoint_auth_methods_supported).toContain("none"); + }); +}); + +describe("registerHandler", () => { + it("valid body returns 201 with UUID client_id, echoes redirect_uris", () => { + const req = mockReq({ + body: { redirect_uris: ["https://claude.ai/cb"] }, + }); + const res = mockRes(); + registerHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(201); + const body = res.json.mock.calls[0][0]; + expect(body.client_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(body.redirect_uris).toEqual(["https://claude.ai/cb"]); + }); + + it("accepts missing redirect_uris as empty array", () => { + const req = mockReq({ body: {} }); + const res = mockRes(); + registerHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(201); + const body = res.json.mock.calls[0][0]; + expect(body.redirect_uris).toEqual([]); + }); + + it("returns 429 + Retry-After when rate limited", () => { + for (let i = 0; i < 10; i++) { + const res = mockRes(); + registerHandler(mockReq({ body: {} }) as never, res as never); + } + const res = mockRes(); + registerHandler(mockReq({ body: {} }) as never, res as never); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.setHeader).toHaveBeenCalledWith( + "Retry-After", + expect.any(String), + ); + }); +}); + +// ────────────────────────────────────────────────────────────────────── +// Authorize +// ────────────────────────────────────────────────────────────────────── + +describe("authorizeHandler", () => { + beforeEach(() => { + // Rate limiter isolation — use a new IP per test to avoid register-test bleed + }); + + it("redirects with code + state on happy path", () => { + const client = clientStore.register({ + redirect_uris: ["https://claude.ai/cb"], + }); + const req = mockReq({ + query: { + response_type: "code", + client_id: client.client_id, + redirect_uri: "https://claude.ai/cb", + code_challenge: "abc123xyz", + code_challenge_method: "S256", + state: "xyz", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "9.9.9.1", + }, + }); + const res = mockRes(); + authorizeHandler(req as never, res as never); + expect(res.redirect).toHaveBeenCalledWith( + expect.stringMatching( + /^https:\/\/claude\.ai\/cb\?code=[0-9a-f-]+&state=xyz$/, + ), + ); + }); + + it("returns 400 on missing required params", () => { + const req = mockReq({ + query: { response_type: "code" }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "9.9.9.2", + }, + }); + const res = mockRes(); + authorizeHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.error).toBe("invalid_request"); + }); + + it("rejects response_type other than code", () => { + const client = clientStore.register({ + redirect_uris: ["https://claude.ai/cb"], + }); + const req = mockReq({ + query: { + response_type: "token", + client_id: client.client_id, + redirect_uri: "https://claude.ai/cb", + code_challenge: "abc", + code_challenge_method: "S256", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "9.9.9.3", + }, + }); + const res = mockRes(); + authorizeHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].error).toBe("unsupported_response_type"); + }); + + it("rejects code_challenge_method other than S256", () => { + const client = clientStore.register({ + redirect_uris: ["https://claude.ai/cb"], + }); + const req = mockReq({ + query: { + response_type: "code", + client_id: client.client_id, + redirect_uri: "https://claude.ai/cb", + code_challenge: "abc", + code_challenge_method: "plain", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "9.9.9.4", + }, + }); + const res = mockRes(); + authorizeHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].error).toBe("invalid_request"); + }); + + it("returns 400 unauthorized_client for unknown client_id", () => { + const req = mockReq({ + query: { + response_type: "code", + client_id: "unknown", + redirect_uri: "https://claude.ai/cb", + code_challenge: "abc", + code_challenge_method: "S256", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "9.9.9.5", + }, + }); + const res = mockRes(); + authorizeHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].error).toBe("unauthorized_client"); + }); + + it("returns 400 invalid_redirect_uri when redirect_uri not registered", () => { + const client = clientStore.register({ + redirect_uris: ["https://claude.ai/cb"], + }); + const req = mockReq({ + query: { + response_type: "code", + client_id: client.client_id, + redirect_uri: "https://evil.example/cb", + code_challenge: "abc", + code_challenge_method: "S256", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "9.9.9.6", + }, + }); + const res = mockRes(); + authorizeHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].error).toBe("invalid_redirect_uri"); + }); + + it("accepts redirect_uri when client has empty registered list", () => { + const client = clientStore.register({ redirect_uris: [] }); + const req = mockReq({ + query: { + response_type: "code", + client_id: client.client_id, + redirect_uri: "https://anywhere.example/cb", + code_challenge: "abc", + code_challenge_method: "S256", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "9.9.9.7", + }, + }); + const res = mockRes(); + authorizeHandler(req as never, res as never); + expect(res.redirect).toHaveBeenCalled(); + }); + + it("stores code in codeStore for later consumption", () => { + const client = clientStore.register({ + redirect_uris: ["https://claude.ai/cb"], + }); + const req = mockReq({ + query: { + response_type: "code", + client_id: client.client_id, + redirect_uri: "https://claude.ai/cb", + code_challenge: "ch", + code_challenge_method: "S256", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "9.9.9.8", + }, + }); + const res = mockRes(); + authorizeHandler(req as never, res as never); + const redirectArg = res.redirect.mock.calls[0][0] as string; + const url = new URL(redirectArg); + const code = url.searchParams.get("code")!; + const consumed = codeStore.consume(code); + expect(consumed?.clientId).toBe(client.client_id); + expect(consumed?.codeChallenge).toBe("ch"); + expect(consumed?.redirectUri).toBe("https://claude.ai/cb"); + }); +}); + +// ────────────────────────────────────────────────────────────────────── +// Token +// ────────────────────────────────────────────────────────────────────── + +function base64url(input: Buffer | string): string { + const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input; + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function pkcePair() { + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + return { verifier, challenge }; +} + +describe("tokenHandler", () => { + it("returns 400 unsupported_grant_type for non-authorization_code", () => { + const req = mockReq({ + body: { grant_type: "refresh_token" }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.1", + }, + }); + const res = mockRes(); + tokenHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].error).toBe("unsupported_grant_type"); + }); + + it("returns 400 invalid_request on missing fields", () => { + const req = mockReq({ + body: { grant_type: "authorization_code" }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.2", + }, + }); + const res = mockRes(); + tokenHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].error).toBe("invalid_request"); + }); + + it("returns 400 invalid_grant for unknown code", () => { + const req = mockReq({ + body: { + grant_type: "authorization_code", + code: "nope", + code_verifier: "v", + client_id: "c", + redirect_uri: "https://x.example/cb", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.3", + }, + }); + const res = mockRes(); + tokenHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].error).toBe("invalid_grant"); + }); + + it("returns 400 invalid_grant on PKCE mismatch", () => { + const client = clientStore.register({ + redirect_uris: ["https://x.example/cb"], + }); + const { challenge } = pkcePair(); + const { code } = codeStore.issue({ + clientId: client.client_id, + codeChallenge: challenge, + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + const req = mockReq({ + body: { + grant_type: "authorization_code", + code, + code_verifier: "wrong-verifier-not-matching", + client_id: client.client_id, + redirect_uri: "https://x.example/cb", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.4", + }, + }); + const res = mockRes(); + tokenHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.error).toBe("invalid_grant"); + expect(body.error_description).toBeTruthy(); + }); + + it("issues JWT on valid PKCE (RFC 7636 fixture)", () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + const client = clientStore.register({ + redirect_uris: ["https://x.example/cb"], + }); + const { code } = codeStore.issue({ + clientId: client.client_id, + codeChallenge: challenge, + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + const req = mockReq({ + body: { + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id: client.client_id, + redirect_uri: "https://x.example/cb", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.5", + }, + }); + const res = mockRes(); + tokenHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.access_token).toBeDefined(); + expect(body.token_type).toBe("Bearer"); + expect(body.expires_in).toBe(3600); + }); + + it("decoded JWT contains expected claims with exp - iat === 3600", () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + const client = clientStore.register({ + redirect_uris: ["https://x.example/cb"], + }); + const { code } = codeStore.issue({ + clientId: client.client_id, + codeChallenge: challenge, + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + const req = mockReq({ + body: { + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id: client.client_id, + redirect_uri: "https://x.example/cb", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.6", + }, + }); + const res = mockRes(); + tokenHandler(req as never, res as never); + const body = res.json.mock.calls[0][0]; + const [, payloadB64] = (body.access_token as string).split("."); + const pad = "=".repeat((4 - (payloadB64.length % 4)) % 4); + const payload = JSON.parse( + Buffer.from( + payloadB64.replace(/-/g, "+").replace(/_/g, "/") + pad, + "base64", + ).toString("utf8"), + ); + expect(payload.sub).toBe("anonymous"); + expect(payload.aud).toBe("https://mcp.example.com"); + expect(payload.iss).toBe("https://mcp.example.com"); + expect(payload.client_id).toBe(client.client_id); + expect(payload.exp - payload.iat).toBe(3600); + }); + + it("returns 400 invalid_grant on redirect_uri mismatch", () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + const client = clientStore.register({ + redirect_uris: ["https://x.example/cb"], + }); + const { code } = codeStore.issue({ + clientId: client.client_id, + codeChallenge: challenge, + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + const req = mockReq({ + body: { + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id: client.client_id, + redirect_uri: "https://different.example/cb", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.7", + }, + }); + const res = mockRes(); + tokenHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json.mock.calls[0][0].error).toBe("invalid_grant"); + }); + + it("code is one-time use (second call fails)", () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + const client = clientStore.register({ + redirect_uris: ["https://x.example/cb"], + }); + const { code } = codeStore.issue({ + clientId: client.client_id, + codeChallenge: challenge, + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + const body = { + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id: client.client_id, + redirect_uri: "https://x.example/cb", + }; + const headers = { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.8", + }; + const first = mockRes(); + tokenHandler(mockReq({ body, headers }) as never, first as never); + expect(first.status).toHaveBeenCalledWith(200); + + const second = mockRes(); + tokenHandler(mockReq({ body, headers }) as never, second as never); + expect(second.status).toHaveBeenCalledWith(400); + expect(second.json.mock.calls[0][0].error).toBe("invalid_grant"); + }); + + it("accepts form-encoded bodies (Express urlencoded parser)", () => { + // The Express urlencoded parser produces req.body the same shape as JSON, + // so this exercises the same code path. We document that here. + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + const client = clientStore.register({ + redirect_uris: ["https://x.example/cb"], + }); + const { code } = codeStore.issue({ + clientId: client.client_id, + codeChallenge: challenge, + redirectUri: "https://x.example/cb", + ttlMs: 600_000, + }); + const req = mockReq({ + body: { + // Express urlencoded would produce this same object + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id: client.client_id, + redirect_uri: "https://x.example/cb", + }, + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + "x-forwarded-for": "8.8.8.9", + "content-type": "application/x-www-form-urlencoded", + }, + }); + const res = mockRes(); + tokenHandler(req as never, res as never); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); + +// ────────────────────────────────────────────────────────────────────── +// Bearer middleware +// ────────────────────────────────────────────────────────────────────── + +describe("bearerMiddleware", () => { + it("calls next() when no Authorization header (opportunistic)", () => { + const req = mockReq({ headers: { host: "mcp.example.com" } }); + const res = mockRes(); + const next = vi.fn(); + bearerMiddleware(req as never, res as never, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("calls next() when Authorization header lacks Bearer prefix", () => { + const req = mockReq({ + headers: { host: "mcp.example.com", authorization: "Basic abc" }, + }); + const res = mockRes(); + const next = vi.fn(); + bearerMiddleware(req as never, res as never, next); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("attaches req.auth and calls next on valid JWT", () => { + const now = Math.floor(Date.now() / 1000); + const token = signJWT( + { + sub: "anonymous", + iss: "https://mcp.example.com", + aud: "https://mcp.example.com", + client_id: "cli-1", + iat: now, + exp: now + 3600, + }, + "a".repeat(64), + ); + const req = mockReq({ + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + authorization: `Bearer ${token}`, + }, + }); + const res = mockRes(); + const next = vi.fn(); + bearerMiddleware(req as never, res as never, next); + expect(next).toHaveBeenCalled(); + expect((req as { auth?: { sub: string; client_id: string } }).auth).toEqual( + { sub: "anonymous", client_id: "cli-1" }, + ); + }); + + it("returns 401 + WWW-Authenticate on expired token", () => { + const now = Math.floor(Date.now() / 1000); + const token = signJWT( + { + sub: "x", + aud: "https://mcp.example.com", + iat: now - 7200, + exp: now - 3600, + }, + "a".repeat(64), + ); + const req = mockReq({ + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + authorization: `Bearer ${token}`, + }, + }); + const res = mockRes(); + const next = vi.fn(); + bearerMiddleware(req as never, res as never, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('Bearer realm="mcp"'), + ); + expect(res.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('error="invalid_token"'), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 401 on wrong-signature token", () => { + const now = Math.floor(Date.now() / 1000); + const token = signJWT( + { sub: "x", iat: now, exp: now + 3600 }, + "wrong-secret-xxxxxxxxxxxxxxxx", + ); + const req = mockReq({ + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + authorization: `Bearer ${token}`, + }, + }); + const res = mockRes(); + const next = vi.fn(); + bearerMiddleware(req as never, res as never, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 401 on aud mismatch", () => { + const now = Math.floor(Date.now() / 1000); + const token = signJWT( + { + sub: "x", + aud: "https://other.example", + iat: now, + exp: now + 3600, + }, + "a".repeat(64), + ); + const req = mockReq({ + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + authorization: `Bearer ${token}`, + }, + }); + const res = mockRes(); + const next = vi.fn(); + bearerMiddleware(req as never, res as never, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 401 on empty Bearer token", () => { + const req = mockReq({ + headers: { + host: "mcp.example.com", + "x-forwarded-proto": "https", + authorization: "Bearer ", + }, + }); + const res = mockRes(); + const next = vi.fn(); + bearerMiddleware(req as never, res as never, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); +}); + +// Keep module-level state from leaking across files +afterEach(() => { + vi.restoreAllMocks(); +}); From 443eb1db978dcb50b221042a29f6f39ad0854ba6 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:10:26 -0700 Subject: [PATCH 11/15] =?UTF-8?q?Implement=20OAuth=20handlers=20=E2=80=94?= =?UTF-8?q?=20metadata,=20register,=20authorize,=20token,=20bearer=20middl?= =?UTF-8?q?eware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/oauth/handlers.ts | 364 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 src/oauth/handlers.ts diff --git a/src/oauth/handlers.ts b/src/oauth/handlers.ts new file mode 100644 index 0000000..cb0aef9 --- /dev/null +++ b/src/oauth/handlers.ts @@ -0,0 +1,364 @@ +// OAuth 2.1 ceremonial flow handlers for the Pathfinder MCP server. +// +// Anonymous OAuth: we run the full RFC 6749 / RFC 7636 (PKCE) / RFC 7591 +// (dynamic registration) / RFC 8414 (AS metadata) / RFC 9728 (protected +// resource metadata) ceremony, but auto-approve at /authorize and issue a +// JWT with sub: "anonymous". The /mcp endpoint uses opportunistic bearer +// auth so existing unauthenticated clients keep working. + +import type { Request, Response, NextFunction } from "express"; +import { createHash, timingSafeEqual } from "node:crypto"; + +import { getConfig } from "../config.js"; +import { clientStore, codeStore } from "./store.js"; +import { + signJWT, + verifyJWT, + InvalidSignature, + TokenExpired, + InvalidAudience, + MalformedToken, +} from "./jwt.js"; +import { + registerLimiter, + authorizeLimiter, + tokenLimiter, + type OAuthRateLimiter, +} from "./rate-limiter.js"; + +const TOKEN_TTL_SEC = 3600; +const CODE_TTL_MS = 600_000; + +function originOf(req: Request): string { + const proto = (req.headers["x-forwarded-proto"] as string) || "http"; + const host = req.headers.host ?? `localhost:${getConfig().port}`; + return `${proto}://${host}`; +} + +function clientIp(req: Request): string { + return ( + (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || + req.socket?.remoteAddress || + "unknown" + ); +} + +function base64url(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function enforceLimit( + limiter: OAuthRateLimiter, + req: Request, + res: Response, +): boolean { + const ip = clientIp(req); + const result = limiter.check(ip); + if (!result.ok) { + res.setHeader("Retry-After", String(result.retryAfterSec ?? 60)); + res.status(429).json({ + error: "rate_limited", + error_description: "Too many requests — slow down.", + }); + console.warn(`[oauth] rate_limited ip=${ip}`); + return false; + } + return true; +} + +// ────────────────────────────────────────────────────────────────────── +// Metadata handlers +// ────────────────────────────────────────────────────────────────────── + +export function protectedResourceHandler(req: Request, res: Response): void { + const origin = originOf(req); + res.json({ + resource: origin, + authorization_servers: [origin], + bearer_methods_supported: ["header"], + }); +} + +export function authorizationServerHandler(req: Request, res: Response): void { + const origin = originOf(req); + res.json({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + registration_endpoint: `${origin}/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); +} + +// ────────────────────────────────────────────────────────────────────── +// /register — RFC 7591 dynamic client registration +// ────────────────────────────────────────────────────────────────────── + +export function registerHandler(req: Request, res: Response): void { + if (!enforceLimit(registerLimiter, req, res)) return; + + const body = (req.body ?? {}) as { redirect_uris?: unknown }; + const redirectUris = Array.isArray(body.redirect_uris) + ? body.redirect_uris.filter((u): u is string => typeof u === "string") + : []; + + const client = clientStore.register({ redirect_uris: redirectUris }); + console.log( + `[oauth] register client_id=${client.client_id} ip=${clientIp(req)}`, + ); + res.status(201).json({ + client_id: client.client_id, + client_id_issued_at: client.client_id_issued_at, + redirect_uris: client.redirect_uris, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }); +} + +// ────────────────────────────────────────────────────────────────────── +// /authorize — RFC 6749 with PKCE (S256 only), auto-approve +// ────────────────────────────────────────────────────────────────────── + +export function authorizeHandler(req: Request, res: Response): void { + if (!enforceLimit(authorizeLimiter, req, res)) return; + + const q = (req.query ?? {}) as Record; + const response_type = q.response_type; + const client_id = q.client_id; + const redirect_uri = q.redirect_uri; + const code_challenge = q.code_challenge; + const code_challenge_method = q.code_challenge_method; + const state = q.state; + + if (!client_id || !redirect_uri || !code_challenge || !response_type) { + res.status(400).json({ + error: "invalid_request", + error_description: + "Missing one or more required parameters: response_type, client_id, redirect_uri, code_challenge.", + }); + return; + } + + if (response_type !== "code") { + res.status(400).json({ + error: "unsupported_response_type", + error_description: "Only response_type=code is supported.", + }); + return; + } + + if (code_challenge_method !== "S256") { + res.status(400).json({ + error: "invalid_request", + error_description: "Only code_challenge_method=S256 is supported.", + }); + return; + } + + const client = clientStore.get(client_id); + if (!client) { + res.status(400).json({ + error: "unauthorized_client", + error_description: "Unknown client_id.", + }); + console.warn( + `[oauth] authorize unknown client_id=${client_id} ip=${clientIp(req)}`, + ); + return; + } + + if ( + client.redirect_uris.length > 0 && + !client.redirect_uris.includes(redirect_uri) + ) { + res.status(400).json({ + error: "invalid_redirect_uri", + error_description: "redirect_uri does not match any registered URI.", + }); + return; + } + + const { code } = codeStore.issue({ + clientId: client_id, + codeChallenge: code_challenge, + redirectUri: redirect_uri, + ttlMs: CODE_TTL_MS, + }); + + const url = new URL(redirect_uri); + url.searchParams.set("code", code); + if (state) url.searchParams.set("state", state); + + console.log( + `[oauth] authorize client_id=${client_id} code=${code.slice(0, 8)} ip=${clientIp(req)}`, + ); + res.redirect(url.toString()); +} + +// ────────────────────────────────────────────────────────────────────── +// /token — RFC 6749 authorization_code grant with PKCE verification +// ────────────────────────────────────────────────────────────────────── + +export function tokenHandler(req: Request, res: Response): void { + if (!enforceLimit(tokenLimiter, req, res)) return; + + const body = (req.body ?? {}) as Record; + const grant_type = body.grant_type; + + if (grant_type !== "authorization_code") { + res.status(400).json({ + error: "unsupported_grant_type", + error_description: "Only authorization_code is supported.", + }); + return; + } + + const code = body.code; + const verifier = body.code_verifier; + const client_id = body.client_id; + const redirect_uri = body.redirect_uri; + + if (!code || !verifier || !client_id || !redirect_uri) { + res.status(400).json({ + error: "invalid_request", + error_description: + "Missing required fields: code, code_verifier, client_id, redirect_uri.", + }); + return; + } + + const record = codeStore.consume(code); + if (!record) { + res.status(400).json({ + error: "invalid_grant", + error_description: "Unknown or expired authorization code.", + }); + console.warn( + `[oauth] token unknown/expired code ip=${clientIp(req)} client=${client_id}`, + ); + return; + } + + if (record.clientId !== client_id || record.redirectUri !== redirect_uri) { + res.status(400).json({ + error: "invalid_grant", + error_description: "client_id or redirect_uri does not match.", + }); + return; + } + + // Verify PKCE (S256): base64url(sha256(verifier)) === stored challenge + const expectedChallenge = base64url( + createHash("sha256").update(verifier).digest(), + ); + const a = Buffer.from(expectedChallenge); + const b = Buffer.from(record.codeChallenge); + const pkceOk = + a.length === b.length && timingSafeEqual(a, b); + if (!pkceOk) { + res.status(400).json({ + error: "invalid_grant", + error_description: "PKCE verification failed.", + }); + console.warn( + `[oauth] token PKCE failure ip=${clientIp(req)} client=${client_id}`, + ); + return; + } + + const origin = originOf(req); + const iat = Math.floor(Date.now() / 1000); + const exp = iat + TOKEN_TTL_SEC; + const token = signJWT( + { + iss: origin, + aud: origin, + sub: "anonymous", + client_id, + iat, + exp, + }, + getConfig().mcpJwtSecret, + ); + + console.log( + `[oauth] token issued client_id=${client_id} ip=${clientIp(req)}`, + ); + res.status(200).json({ + access_token: token, + token_type: "Bearer", + expires_in: TOKEN_TTL_SEC, + }); +} + +// ────────────────────────────────────────────────────────────────────── +// Bearer middleware — opportunistic +// ────────────────────────────────────────────────────────────────────── + +export interface AuthContext { + sub: string; + client_id: string; +} + +export function bearerMiddleware( + req: Request & { auth?: AuthContext }, + res: Response, + next: NextFunction, +): void { + const header = req.headers.authorization; + if (!header || typeof header !== "string") { + next(); + return; + } + + const trimmed = header.trim(); + if (!/^Bearer(\s|$)/i.test(trimmed)) { + // Not a Bearer scheme — treat as if absent (opportunistic) + next(); + return; + } + const token = trimmed.slice("Bearer".length).trim(); + if (!token) { + unauthorized(res, "invalid_token"); + return; + } + + try { + const payload = verifyJWT(token, getConfig().mcpJwtSecret, { + aud: originOf(req), + }); + req.auth = { + sub: payload.sub, + client_id: (payload.client_id as string) ?? "", + }; + next(); + } catch (err) { + if ( + err instanceof InvalidSignature || + err instanceof TokenExpired || + err instanceof InvalidAudience || + err instanceof MalformedToken + ) { + unauthorized(res, "invalid_token"); + return; + } + // Unknown error — fail closed + unauthorized(res, "invalid_token"); + } +} + +function unauthorized(res: Response, error: string): void { + res.setHeader( + "WWW-Authenticate", + `Bearer realm="mcp", error="${error}"`, + ); + res.status(401).json({ error }); +} From 09c6d9b230f83ef6e9be7fe9e8459fef2facfaad Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:10:59 -0700 Subject: [PATCH 12/15] Wire real OAuth flow into /mcp server --- src/server.ts | 53 +++++++++++++++++++-------------------------------- 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/src/server.ts b/src/server.ts index 83c766a..503dff5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -31,6 +31,14 @@ import { SessionStateManager } from "./mcp/tools/bash-session.js"; import { BashTelemetry } from "./mcp/tools/bash-telemetry.js"; import { insertCollectedData } from "./db/queries.js"; import { IpSessionLimiter } from "./ip-limiter.js"; +import { + protectedResourceHandler, + authorizationServerHandler, + registerHandler, + authorizeHandler, + tokenHandler, + bearerMiddleware, +} from "./oauth/handlers.js"; import { WorkspaceManager } from "./workspace.js"; import { generateLlmsTxt, generateLlmsFullTxt } from "./llms-txt.js"; import { generateFaqTxt } from "./faq-txt.js"; @@ -195,41 +203,20 @@ app.post( // JSON parser for all other routes app.use(express.json()); +// Form-encoded parser for OAuth /token POSTs +app.use(express.urlencoded({ extended: false })); // --------------------------------------------------------------------------- -// OAuth discovery stubs — tell MCP clients that no auth is required. -// Newer Claude Code versions probe these before connecting. +// OAuth 2.1 ceremonial flow — RFC-compliant endpoints with auto-approval. +// Opportunistic bearer auth on /mcp lets existing unauthenticated clients +// keep working while claude.ai-style clients can complete the OAuth handshake. // --------------------------------------------------------------------------- -// Return resource metadata with no authorization servers — signals "no auth required" -// per RFC 9728. Newer Claude Code versions probe this before connecting. -app.get( - "/.well-known/oauth-protected-resource", - (req: Request, res: Response) => { - const host = req.headers.host || `localhost:${getConfig().port}`; - const proto = req.headers["x-forwarded-proto"] || "http"; - res.json({ - resource: `${proto}://${host}`, - }); - }, -); - -app.get( - "/.well-known/oauth-authorization-server", - (_req: Request, res: Response) => { - res.status(404).json({ - error: - "No authorization server — this resource does not require authentication", - }); - }, -); - -app.post("/register", (_req: Request, res: Response) => { - res.status(404).json({ - error: - "No authorization server — this resource does not require authentication", - }); -}); +app.get("/.well-known/oauth-protected-resource", protectedResourceHandler); +app.get("/.well-known/oauth-authorization-server", authorizationServerHandler); +app.post("/register", registerHandler); +app.get("/authorize", authorizeHandler); +app.post("/token", tokenHandler); // --------------------------------------------------------------------------- // MCP endpoint — session-based (initialize once, then tool calls reuse session) @@ -291,7 +278,7 @@ function clientIp(req: Request): string { ); } -app.post("/mcp", async (req: Request, res: Response) => { +app.post("/mcp", bearerMiddleware, async (req: Request, res: Response) => { try { const sessionId = req.headers["mcp-session-id"] as string | undefined; const ip = clientIp(req); @@ -420,7 +407,7 @@ app.get("/mcp", async (req: Request, res: Response) => { }); // Session termination -app.delete("/mcp", async (req: Request, res: Response) => { +app.delete("/mcp", bearerMiddleware, async (req: Request, res: Response) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (sessionId && transports[sessionId]) { await transports[sessionId].handleRequest(req, res); From 85ae25059ea3f7426f195b8f177c42612938707f Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:11:37 -0700 Subject: [PATCH 13/15] Add OAuth end-to-end integration test --- src/__tests__/oauth-e2e.test.ts | 195 ++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 src/__tests__/oauth-e2e.test.ts diff --git a/src/__tests__/oauth-e2e.test.ts b/src/__tests__/oauth-e2e.test.ts new file mode 100644 index 0000000..5a1fa48 --- /dev/null +++ b/src/__tests__/oauth-e2e.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import express from "express"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { createHash, randomBytes } from "node:crypto"; + +// Use a stable secret so the handlers and our verifier both agree. +vi.mock("../config.js", () => ({ + getConfig: vi.fn().mockReturnValue({ + port: 0, + databaseUrl: "pglite:///tmp/test", + openaiApiKey: "", + githubToken: "", + githubWebhookSecret: "", + nodeEnv: "test", + logLevel: "info", + cloneDir: "/tmp/test", + slackBotToken: "", + slackSigningSecret: "", + discordBotToken: "", + discordPublicKey: "", + notionToken: "", + mcpJwtSecret: "e".repeat(64), + }), + getServerConfig: vi.fn(), + getAnalyticsConfig: vi.fn(), + hasSearchTools: vi.fn().mockReturnValue(false), + hasKnowledgeTools: vi.fn().mockReturnValue(false), + hasCollectTools: vi.fn().mockReturnValue(false), + hasBashSemanticSearch: vi.fn().mockReturnValue(false), +})); + +import { + protectedResourceHandler, + authorizationServerHandler, + registerHandler, + authorizeHandler, + tokenHandler, + bearerMiddleware, + type AuthContext, +} from "../oauth/handlers.js"; + +function base64url(buf: Buffer | string): string { + const b = typeof buf === "string" ? Buffer.from(buf) : buf; + return b + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: false })); + + app.get("/.well-known/oauth-protected-resource", protectedResourceHandler); + app.get( + "/.well-known/oauth-authorization-server", + authorizationServerHandler, + ); + app.post("/register", registerHandler); + app.get("/authorize", authorizeHandler); + app.post("/token", tokenHandler); + + // Stub /mcp that echoes req.auth + app.post( + "/mcp", + bearerMiddleware, + (req: express.Request & { auth?: AuthContext }, res) => { + res.json({ echoed_auth: req.auth ?? null }); + }, + ); + + await new Promise((resolve) => { + server = app.listen(0, () => resolve()); + }); + const addr = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +}); + +describe("OAuth 2.1 end-to-end ceremonial flow", () => { + it("completes register → authorize → token → /mcp with Bearer", async () => { + // 1. POST /register + const registerRes = await fetch(`${baseUrl}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redirect_uris: [`${baseUrl}/cb`], + }), + }); + expect(registerRes.status).toBe(201); + const { client_id } = (await registerRes.json()) as { client_id: string }; + expect(client_id).toBeTruthy(); + + // 2. Generate PKCE pair + const verifier = base64url(randomBytes(32)); + const challenge = base64url( + createHash("sha256").update(verifier).digest(), + ); + + // 3. GET /authorize + const authorizeUrl = new URL(`${baseUrl}/authorize`); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("client_id", client_id); + authorizeUrl.searchParams.set("redirect_uri", `${baseUrl}/cb`); + authorizeUrl.searchParams.set("code_challenge", challenge); + authorizeUrl.searchParams.set("code_challenge_method", "S256"); + authorizeUrl.searchParams.set("state", "abc"); + const authRes = await fetch(authorizeUrl.toString(), { + redirect: "manual", + }); + expect(authRes.status).toBe(302); + const location = authRes.headers.get("location"); + expect(location).toBeTruthy(); + const redirected = new URL(location!); + const code = redirected.searchParams.get("code"); + expect(code).toBeTruthy(); + expect(redirected.searchParams.get("state")).toBe("abc"); + + // 4. POST /token (form-encoded) + const form = new URLSearchParams(); + form.set("grant_type", "authorization_code"); + form.set("code", code!); + form.set("code_verifier", verifier); + form.set("client_id", client_id); + form.set("redirect_uri", `${baseUrl}/cb`); + const tokenRes = await fetch(`${baseUrl}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: form.toString(), + }); + expect(tokenRes.status).toBe(200); + const tokenBody = (await tokenRes.json()) as { + access_token: string; + token_type: string; + expires_in: number; + }; + expect(tokenBody.access_token).toBeTruthy(); + expect(tokenBody.token_type).toBe("Bearer"); + expect(tokenBody.expires_in).toBe(3600); + + // 5. POST /mcp with Bearer — should attach req.auth + const mcpRes = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { + Authorization: `Bearer ${tokenBody.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(mcpRes.status).toBe(200); + const mcpBody = (await mcpRes.json()) as { + echoed_auth: { sub: string; client_id: string } | null; + }; + expect(mcpBody.echoed_auth).toEqual({ sub: "anonymous", client_id }); + }); + + it("/mcp succeeds with no Authorization header (opportunistic)", async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + echoed_auth: unknown; + }; + expect(body.echoed_auth).toBeNull(); + }); + + it("/mcp returns 401 + WWW-Authenticate on garbage token", async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { + Authorization: "Bearer garbage.token.here", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(401); + const www = res.headers.get("www-authenticate"); + expect(www).toContain('Bearer realm="mcp"'); + expect(www).toContain('error="invalid_token"'); + }); +}); From a19af36b5d1b3f95edfbb59ca6b5202b3e62dcd5 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:11:54 -0700 Subject: [PATCH 14/15] Document MCP_JWT_SECRET in .env.example --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index e7c4c30..32c27ab 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ NOTION_TOKEN=ntn_... # Analytics dashboard (optional — protects /analytics and /api/analytics/*) ANALYTICS_TOKEN= +# MCP OAuth JWT secret — REQUIRED in production (32+ bytes random). +# Generate: openssl rand -hex 32 +# If unset in development, an ephemeral secret is generated at startup +# (all issued tokens are invalidated on restart). +MCP_JWT_SECRET= + # Server PORT=3001 NODE_ENV=development From 044dcc2619bf10377c08d1c886702941af62a609 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Mon, 20 Apr 2026 10:15:54 -0700 Subject: [PATCH 15/15] Apply prettier formatting to OAuth modules and tests --- src/__tests__/oauth-e2e.test.ts | 4 +--- src/__tests__/oauth-handlers.test.ts | 8 ++++---- src/__tests__/oauth-secret.test.ts | 5 +---- src/oauth/handlers.ts | 8 ++------ src/oauth/rate-limiter.ts | 5 ++++- 5 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/__tests__/oauth-e2e.test.ts b/src/__tests__/oauth-e2e.test.ts index 5a1fa48..9d1eb95 100644 --- a/src/__tests__/oauth-e2e.test.ts +++ b/src/__tests__/oauth-e2e.test.ts @@ -104,9 +104,7 @@ describe("OAuth 2.1 end-to-end ceremonial flow", () => { // 2. Generate PKCE pair const verifier = base64url(randomBytes(32)); - const challenge = base64url( - createHash("sha256").update(verifier).digest(), - ); + const challenge = base64url(createHash("sha256").update(verifier).digest()); // 3. GET /authorize const authorizeUrl = new URL(`${baseUrl}/authorize`); diff --git a/src/__tests__/oauth-handlers.test.ts b/src/__tests__/oauth-handlers.test.ts index c657c2f..d8ad92e 100644 --- a/src/__tests__/oauth-handlers.test.ts +++ b/src/__tests__/oauth-handlers.test.ts @@ -39,7 +39,9 @@ import { import { clientStore, codeStore } from "../oauth/store.js"; import { signJWT } from "../oauth/jwt.js"; -function mockReq(overrides: Record = {}): Record { +function mockReq( + overrides: Record = {}, +): Record { return { headers: { host: "mcp.example.com", @@ -122,9 +124,7 @@ describe("authorizationServerHandler", () => { "https://mcp.example.com/authorize", ); expect(body.token_endpoint).toBe("https://mcp.example.com/token"); - expect(body.registration_endpoint).toBe( - "https://mcp.example.com/register", - ); + expect(body.registration_endpoint).toBe("https://mcp.example.com/register"); expect(body.response_types_supported).toContain("code"); expect(body.grant_types_supported).toContain("authorization_code"); expect(body.code_challenge_methods_supported).toContain("S256"); diff --git a/src/__tests__/oauth-secret.test.ts b/src/__tests__/oauth-secret.test.ts index 5aa8703..6ab0eca 100644 --- a/src/__tests__/oauth-secret.test.ts +++ b/src/__tests__/oauth-secret.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { - resolveJwtSecret, - resetJwtSecretCache, -} from "../oauth/secret.js"; +import { resolveJwtSecret, resetJwtSecretCache } from "../oauth/secret.js"; describe("resolveJwtSecret", () => { const savedSecret = process.env.MCP_JWT_SECRET; diff --git a/src/oauth/handlers.ts b/src/oauth/handlers.ts index cb0aef9..2914c66 100644 --- a/src/oauth/handlers.ts +++ b/src/oauth/handlers.ts @@ -261,8 +261,7 @@ export function tokenHandler(req: Request, res: Response): void { ); const a = Buffer.from(expectedChallenge); const b = Buffer.from(record.codeChallenge); - const pkceOk = - a.length === b.length && timingSafeEqual(a, b); + const pkceOk = a.length === b.length && timingSafeEqual(a, b); if (!pkceOk) { res.status(400).json({ error: "invalid_grant", @@ -356,9 +355,6 @@ export function bearerMiddleware( } function unauthorized(res: Response, error: string): void { - res.setHeader( - "WWW-Authenticate", - `Bearer realm="mcp", error="${error}"`, - ); + res.setHeader("WWW-Authenticate", `Bearer realm="mcp", error="${error}"`); res.status(401).json({ error }); } diff --git a/src/oauth/rate-limiter.ts b/src/oauth/rate-limiter.ts index 6b7098d..8a0ad9a 100644 --- a/src/oauth/rate-limiter.ts +++ b/src/oauth/rate-limiter.ts @@ -35,7 +35,10 @@ export class OAuthRateLimiter { } const elapsed = now - state.windowStart; - const retryAfterSec = Math.max(1, Math.ceil((this.windowMs - elapsed) / 1000)); + const retryAfterSec = Math.max( + 1, + Math.ceil((this.windowMs - elapsed) / 1000), + ); return { ok: false, retryAfterSec }; } }