diff --git a/.changeset/mcp-multi-tenant-entra.md b/.changeset/mcp-multi-tenant-entra.md new file mode 100644 index 00000000..3b984c12 --- /dev/null +++ b/.changeset/mcp-multi-tenant-entra.md @@ -0,0 +1,5 @@ +--- +"@upstash/context7-mcp": minor +--- + +Add multi-tenant Microsoft Entra ID validation for MCP tokens. The server now detects inbound Entra v2 tokens by issuer pattern, fetches per-teamspace configuration (`tenantId`, `audience`, `requiredScope`) from the Context7 app, and verifies the token against the matching tenant's JWKS, enforcing the required scope claim when configured. User resolution happens downstream in the Context7 app against a pre-provisioned user mapping table — the MCP server only validates. Per-tenant JWKS cache and a 5-minute in-memory config cache keyed by JWT audience reduce overhead under load. diff --git a/packages/mcp/src/lib/jwt.ts b/packages/mcp/src/lib/jwt.ts index beacb113..2695c461 100644 --- a/packages/mcp/src/lib/jwt.ts +++ b/packages/mcp/src/lib/jwt.ts @@ -1,10 +1,58 @@ import * as jose from "jose"; -import { CLERK_DOMAIN } from "./constants.js"; +import { CLERK_DOMAIN, CONTEXT7_API_BASE_URL } from "./constants.js"; -const JWKS_URL = `https://${CLERK_DOMAIN}/.well-known/jwks.json`; -const ISSUER = `https://${CLERK_DOMAIN}`; +const CLERK_ISSUER = `https://${CLERK_DOMAIN}`; +const clerkJwks = jose.createRemoteJWKSet(new URL(`https://${CLERK_DOMAIN}/.well-known/jwks.json`)); -const jwks = jose.createRemoteJWKSet(new URL(JWKS_URL)); +const ENTRA_V2_ISSUER_RE = /^https:\/\/login\.microsoftonline\.com\/[0-9a-f-]{36}\/v2\.0$/; + +const jwksByTenant = new Map>(); +function entraJwks(tenantId: string) { + let jwks = jwksByTenant.get(tenantId); + if (!jwks) { + jwks = jose.createRemoteJWKSet( + new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`) + ); + jwksByTenant.set(tenantId, jwks); + } + return jwks; +} + +interface EntraConfig { + teamspaceId: string; + tenantId: string; + requiredScope: string | null; +} + +const CONFIG_TTL_MS = 5 * 60 * 1000; +const configByAudience = new Map(); + +async function fetchEntraConfig(audience: string): Promise { + const now = Date.now(); + const cached = configByAudience.get(audience); + if (cached && cached.expiresAt > now) return cached.value; + + try { + const res = await fetch( + `${CONTEXT7_API_BASE_URL}/v2/entra/config/${encodeURIComponent(audience)}` + ); + if (res.ok) { + const value = (await res.json()) as EntraConfig; + configByAudience.set(audience, { value, expiresAt: now + CONFIG_TTL_MS }); + return value; + } + if (res.status === 404) { + // Authoritative "not configured" response — safe to cache the miss so we + // don't hammer the app on every token verification. + configByAudience.set(audience, { value: null, expiresAt: now + CONFIG_TTL_MS }); + return null; + } + } catch { + // Network or JSON parse error: transient. Fall through without caching so + // the next request retries. + } + return null; +} export interface JWTValidationResult { valid: boolean; @@ -12,16 +60,37 @@ export interface JWTValidationResult { } export function isJWT(token: string): boolean { - const parts = token.split("."); - return parts.length === 3; + return token.split(".").length === 3; } export async function validateJWT(token: string): Promise { try { - await jose.jwtVerify(token, jwks, { - issuer: ISSUER, - }); + const decoded = jose.decodeJwt(token); + const iss = typeof decoded.iss === "string" ? decoded.iss : ""; + + if (ENTRA_V2_ISSUER_RE.test(iss)) { + const audience = typeof decoded.aud === "string" ? decoded.aud : ""; + if (!audience) return { valid: false, error: "Missing audience" }; + + const config = await fetchEntraConfig(audience); + if (!config) return { valid: false, error: "Unknown audience" }; + + const { payload } = await jose.jwtVerify(token, entraJwks(config.tenantId), { + issuer: `https://login.microsoftonline.com/${config.tenantId}/v2.0`, + audience, + }); + + if (config.requiredScope) { + const scopes = String(payload.scp ?? "").split(" "); + if (!scopes.includes(config.requiredScope)) { + return { valid: false, error: "Missing required scope" }; + } + } + + return { valid: true }; + } + await jose.jwtVerify(token, clerkJwks, { issuer: CLERK_ISSUER }); return { valid: true }; } catch (error) { if (error instanceof jose.errors.JWTExpired) { diff --git a/packages/mcp/test/jwt.test.ts b/packages/mcp/test/jwt.test.ts new file mode 100644 index 00000000..4abd8cc0 --- /dev/null +++ b/packages/mcp/test/jwt.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as jose from "jose"; + +vi.mock("jose", async () => { + const actual = await vi.importActual("jose"); + return { + ...actual, + createRemoteJWKSet: vi.fn( + () => "fake-jwks" as unknown as ReturnType + ), + jwtVerify: vi.fn(), + }; +}); + +const TENANT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const ENTRA_ISSUER = `https://login.microsoftonline.com/${TENANT_ID}/v2.0`; +const AUDIENCE = "6ff6a635-03d9-472d-a7f1-dc98a4e5fde2"; + +async function loadModule() { + vi.resetModules(); + return import("../src/lib/jwt.js"); +} + +function makeFetchResponse(init: Partial & { jsonData?: unknown }): Response { + const { jsonData, ...rest } = init; + return { + ok: true, + status: 200, + json: async () => jsonData, + ...rest, + } as Response; +} + +function makeEntraToken(payload: jose.JWTPayload): string { + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("isJWT", () => { + test("returns true for 3-part dotted strings", async () => { + const { isJWT } = await loadModule(); + expect(isJWT("a.b.c")).toBe(true); + }); + + test("returns false for non-JWT strings", async () => { + const { isJWT } = await loadModule(); + expect(isJWT("not-a-jwt")).toBe(false); + expect(isJWT("only.two")).toBe(false); + expect(isJWT("a.b.c.d")).toBe(false); + }); +}); + +describe("validateJWT - Entra path", () => { + test("validates token after fetching tenant config", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce( + makeFetchResponse({ + jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: "mcp.access" }, + }) + ); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { oid: "user-oid", scp: "mcp.access" }, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE })); + + expect(result.valid).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toContain(`/v2/entra/config/${AUDIENCE}`); + }); + + test("returns 'Unknown audience' when config endpoint returns 404", async () => { + vi.mocked(fetch).mockResolvedValue(makeFetchResponse({ ok: false, status: 404 })); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: "unknown-aud" })); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Unknown audience"); + expect(jose.jwtVerify).not.toHaveBeenCalled(); + }); + + test("returns 'Missing required scope' when scp claim lacks the configured scope", async () => { + vi.mocked(fetch).mockResolvedValue( + makeFetchResponse({ + jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: "mcp.access" }, + }) + ); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { scp: "other.scope" }, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE })); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Missing required scope"); + }); + + test("returns 'Missing audience' for Entra issuer with no aud claim", async () => { + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER })); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Missing audience"); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("caches config across repeated audiences", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue( + makeFetchResponse({ + jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: null }, + }) + ); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { oid: "u" }, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const token = makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE }); + await validateJWT(token); + await validateJWT(token); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("does not cache transient 500 errors — next request retries", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce(makeFetchResponse({ ok: false, status: 500 })); + fetchMock.mockResolvedValueOnce( + makeFetchResponse({ + jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: null }, + }) + ); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { oid: "u" }, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const token = makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE }); + + const first = await validateJWT(token); + expect(first.valid).toBe(false); + expect(first.error).toBe("Unknown audience"); + + const second = await validateJWT(token); + expect(second.valid).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("validateJWT - Clerk path", () => { + test("verifies against Clerk JWKS for non-Entra issuers", async () => { + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: {}, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: "https://clerk.context7.com" })); + + expect(result.valid).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("returns 'Token expired' when jwtVerify throws JWTExpired", async () => { + vi.mocked(jose.jwtVerify).mockRejectedValue( + new jose.errors.JWTExpired("expired", { payload: {}, protectedHeader: { alg: "RS256" } }) + ); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: "https://clerk.context7.com" })); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token expired"); + }); +});