-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(mcp): multi-tenant Entra ID validation #2629
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
fahreddinozcan
wants to merge
3
commits into
master
Choose a base branch
from
ctx7-1655-azure-apim-integration
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+276
−9
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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