Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.
87 changes: 78 additions & 9 deletions packages/mcp/src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,96 @@
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;

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;
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
193 changes: 193 additions & 0 deletions packages/mcp/test/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -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<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);
});

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

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<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