Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-multi-tenant-entra.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 69 additions & 9 deletions packages/mcp/src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,87 @@
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<string, ReturnType<typeof jose.createRemoteJWKSet>>();
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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caching null results under the same 5-minute TTL means one transient failure (5xx, network blip, JSON parse error) locks every Entra token for that audience out of the server for 5 minutes with a misleading Unknown audience error.
Suggest only negative-caching on an explicit 404 - anything else should bypass the cache so the next request retries:
if (res.ok) {
value = (await res-json()) as EntraConfig;
} else if (res status != 404) {
return null; // transient - don't cache
}

In the app we have

  • 404 when getEntraConfigByAudience(audience) returns null — that's the authoritative "this audience is not configured" answer. Safe to cache.
  • 400 for missing audience (won't happen from the MCP server).
  • 500 in the catch block — any exception thrown by Redis or the service layer. That's the "I don't know" case. Not safe to cache.

const configByAudience = new Map<string, { value: EntraConfig | null; expiresAt: number }>();

async function fetchEntraConfig(audience: string): Promise<EntraConfig | null> {
const now = Date.now();
const cached = configByAudience.get(audience);
if (cached && cached.expiresAt > now) return cached.value;

let value: EntraConfig | null = null;
try {
const res = await fetch(
`${CONTEXT7_API_BASE_URL}/v2/entra/config/${encodeURIComponent(audience)}`
);
if (res.ok) value = (await res.json()) as EntraConfig;
} catch {
// network error: fall through and cache null briefly
}
configByAudience.set(audience, { value, expiresAt: now + CONFIG_TTL_MS });
return value;
}

export interface JWTValidationResult {
valid: boolean;
error?: string;
}

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<JWTValidationResult> {
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) {
Expand Down
168 changes: 168 additions & 0 deletions packages/mcp/test/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as jose from "jose";

vi.mock("jose", async () => {
const actual = await vi.importActual<typeof jose>("jose");
return {
...actual,
createRemoteJWKSet: vi.fn(
() => "fake-jwks" as unknown as ReturnType<typeof jose.createRemoteJWKSet>
),
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<Response> & { 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<ReturnType<typeof jose.jwtVerify>>);

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<ReturnType<typeof jose.jwtVerify>>);

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<ReturnType<typeof jose.jwtVerify>>);

const { validateJWT } = await loadModule();
const token = makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE });
await validateJWT(token);
await validateJWT(token);

expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

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<ReturnType<typeof jose.jwtVerify>>);

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");
});
});
Loading