diff --git a/.env.example b/.env.example index fef8ef38a..4a1be5293 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ CONTEXT7_API_URL=https://context7.com/api # MCP HTTP server RESOURCE_URL= +OAUTH_AUTH_SERVER_URL= +OAUTH_JWKS_URL= +EMA_ISSUER= AUTH_SERVER_URL= OPENAI_APPS_CHALLENGE_TOKEN= CLIENT_IP_ENCRYPTION_KEY= diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index fc2301da3..4f8347614 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -19,7 +19,8 @@ import { randomUUID } from "node:crypto"; import { SERVER_VERSION, RESOURCE_URL, - AUTH_SERVER_URL, + OAUTH_AUTH_SERVER_URL, + EMA_ISSUER, OPENAI_APPS_CHALLENGE_TOKEN, } from "./lib/constants.js"; import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js"; @@ -473,7 +474,10 @@ async function main() { (_req: express.Request, res: express.Response) => { res.json({ resource: RESOURCE_URL, - authorization_servers: [AUTH_SERVER_URL], + // Each entry is an independent authorization server. Clerk handles + // regular authorization-code flows; Context7 handles only the + // enterprise-managed id-jag exchange. + authorization_servers: Array.from(new Set([OAUTH_AUTH_SERVER_URL, EMA_ISSUER])), scopes_supported: ["profile", "email"], bearer_methods_supported: ["header"], }); @@ -483,7 +487,7 @@ async function main() { app.get( "/.well-known/oauth-authorization-server", async (_req: express.Request, res: express.Response) => { - const authServerUrl = AUTH_SERVER_URL; + const authServerUrl = OAUTH_AUTH_SERVER_URL; try { const response = await fetch(`${authServerUrl}/.well-known/oauth-authorization-server`); diff --git a/packages/mcp/src/lib/constants.ts b/packages/mcp/src/lib/constants.ts index fa99be62e..f22cc9c38 100644 --- a/packages/mcp/src/lib/constants.ts +++ b/packages/mcp/src/lib/constants.ts @@ -9,14 +9,27 @@ export const SERVER_VERSION: string = pkg.version; const CONTEXT7_BASE_URL = "https://context7.com"; const MCP_RESOURCE_URL = "https://mcp.context7.com"; +const DEFAULT_OAUTH_AUTH_SERVER_URL = "https://clerk.context7.com"; -export const CLERK_DOMAIN = "clerk.context7.com"; export const CONTEXT7_API_BASE_URL = process.env.CONTEXT7_API_URL || `${CONTEXT7_BASE_URL}/api`; export const RESOURCE_URL = process.env.RESOURCE_URL || MCP_RESOURCE_URL; -export const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || CONTEXT7_BASE_URL; + +// Clerk owns the interactive OAuth flow and is the issuer returned in the +// authorization response. Advertising Clerk directly keeps RFC 8414 discovery +// and RFC 9207 response-issuer validation on the same authorization-server +// identity. +export const OAUTH_AUTH_SERVER_URL = ( + process.env.OAUTH_AUTH_SERVER_URL || DEFAULT_OAUTH_AUTH_SERVER_URL +).replace(/\/+$/, ""); +export const OAUTH_JWKS_URL = + process.env.OAUTH_JWKS_URL || `${OAUTH_AUTH_SERVER_URL}/.well-known/jwks.json`; // Enterprise-Managed Auth (id-jag): access tokens minted by the Context7 // authorization server, validated against its public JWKS. -export const EMA_ISSUER = AUTH_SERVER_URL; +// AUTH_SERVER_URL remains a backwards-compatible alias for local EMA setups; +// it does not move interactive user OAuth. Local end-to-end OAuth environments +// must set OAUTH_AUTH_SERVER_URL separately when Clerk is not the intended issuer. +export const EMA_ISSUER = + process.env.EMA_ISSUER || process.env.AUTH_SERVER_URL || CONTEXT7_BASE_URL; export const EMA_JWKS_URL = process.env.EMA_JWKS_URL || `${CONTEXT7_API_BASE_URL}/oauth/ema-jwks`; export const OPENAI_APPS_CHALLENGE_TOKEN = process.env.OPENAI_APPS_CHALLENGE_TOKEN; diff --git a/packages/mcp/src/lib/jwt.ts b/packages/mcp/src/lib/jwt.ts index a1bce84cf..6c461c4cc 100644 --- a/packages/mcp/src/lib/jwt.ts +++ b/packages/mcp/src/lib/jwt.ts @@ -1,14 +1,14 @@ import * as jose from "jose"; import { - CLERK_DOMAIN, CONTEXT7_API_BASE_URL, EMA_ISSUER, EMA_JWKS_URL, + OAUTH_AUTH_SERVER_URL, + OAUTH_JWKS_URL, RESOURCE_URL, } from "./constants.js"; -const CLERK_ISSUER = `https://${CLERK_DOMAIN}`; -const clerkJwks = jose.createRemoteJWKSet(new URL(`https://${CLERK_DOMAIN}/.well-known/jwks.json`)); +const oauthJwks = jose.createRemoteJWKSet(new URL(OAUTH_JWKS_URL)); const emaJwks = jose.createRemoteJWKSet(new URL(EMA_JWKS_URL)); @@ -103,7 +103,7 @@ export async function validateJWT(token: string): Promise { return { valid: true }; } - await jose.jwtVerify(token, clerkJwks, { issuer: CLERK_ISSUER }); + await jose.jwtVerify(token, oauthJwks, { issuer: OAUTH_AUTH_SERVER_URL }); return { valid: true }; } catch (error) { if (error instanceof jose.errors.JWTExpired) { diff --git a/packages/mcp/test/integration.test.ts b/packages/mcp/test/integration.test.ts index 0c9d68401..06d2a9ccf 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -122,6 +122,19 @@ async function connect(transportKind: "http" | "stdio", era: "modern" | "legacy" return client; } +describe("OAuth discovery", () => { + test("advertises Clerk for user OAuth and Context7 for enterprise auth", async () => { + const metadataUrl = new URL("/.well-known/oauth-protected-resource", httpUrl); + const response = await fetch(metadataUrl); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + resource: "https://mcp.context7.com", + authorization_servers: ["https://clerk.context7.com", "https://context7.com"], + }); + }); +}); + describe.each([ ["http", "modern"], ["http", "legacy"], diff --git a/packages/mcp/test/jwt.test.ts b/packages/mcp/test/jwt.test.ts index 4abd8cc06..d03183b61 100644 --- a/packages/mcp/test/jwt.test.ts +++ b/packages/mcp/test/jwt.test.ts @@ -15,6 +15,8 @@ vi.mock("jose", async () => { 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"; +const originalOAuthAuthServerUrl = process.env.OAUTH_AUTH_SERVER_URL; +const originalOAuthJwksUrl = process.env.OAUTH_JWKS_URL; async function loadModule() { vi.resetModules(); @@ -42,6 +44,16 @@ beforeEach(() => { }); afterEach(() => { + if (originalOAuthAuthServerUrl === undefined) { + delete process.env.OAUTH_AUTH_SERVER_URL; + } else { + process.env.OAUTH_AUTH_SERVER_URL = originalOAuthAuthServerUrl; + } + if (originalOAuthJwksUrl === undefined) { + delete process.env.OAUTH_JWKS_URL; + } else { + process.env.OAUTH_JWKS_URL = originalOAuthJwksUrl; + } vi.unstubAllGlobals(); vi.clearAllMocks(); }); @@ -190,4 +202,36 @@ describe("validateJWT - Clerk path", () => { expect(result.valid).toBe(false); expect(result.error).toBe("Token expired"); }); + + test("uses the configured OAuth issuer and its JWKS for verification", async () => { + process.env.OAUTH_AUTH_SERVER_URL = "https://supreme-foal-19.clerk.accounts.dev/"; + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: {}, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const result = await validateJWT( + makeEntraToken({ iss: "https://supreme-foal-19.clerk.accounts.dev" }) + ); + + expect(result.valid).toBe(true); + expect(jose.createRemoteJWKSet).toHaveBeenCalledWith( + new URL("https://supreme-foal-19.clerk.accounts.dev/.well-known/jwks.json") + ); + expect(jose.jwtVerify).toHaveBeenCalledWith(expect.any(String), "fake-jwks", { + issuer: "https://supreme-foal-19.clerk.accounts.dev", + }); + }); + + test("allows an explicit JWKS URL without changing the OAuth issuer", async () => { + process.env.OAUTH_AUTH_SERVER_URL = "https://oauth.example.com"; + process.env.OAUTH_JWKS_URL = "https://keys.example.com/oauth/jwks.json"; + + await loadModule(); + + expect(jose.createRemoteJWKSet).toHaveBeenCalledWith( + new URL("https://keys.example.com/oauth/jwks.json") + ); + }); });