UNPKG

@upstash/context7-mcp

Version:
188 lines (187 loc) • 8.16 kB
import { EMA_ISSUER, OAUTH_AUTH_SERVER_URL, RESOURCE_URL } from "./constants.js"; import { isClerkOAuthJwt, isJWT, oauthClientId, validateJWT } from "./jwt.js"; import { createPerTokenLogWindow } from "./log-throttle.js"; import { EXPIRED_OAUTH_TOKEN_ERROR, isOAuthTokenRememberedInvalid, isOpaqueOAuthToken, isOpaqueOAuthTokenValidationEnabled, validateOpaqueOAuthToken, } from "./oauth-token-validation.js"; // A JWT from a grant authorized before Clerk's "Include Audience" setting was // on has no `aud` on every refresh, so its warning would repeat on every MCP // request (millions of lines a day). One line per token per window is enough // to collect the (clientId, aud, outcome) triples; a Clerk access token lives // about an hour, so an hour's window is about one line per token per pod. export const JWT_WARNING_WINDOW_MS = 60 * 60_000; const jwtWarningLog = createPerTokenLogWindow(JWT_WARNING_WINDOW_MS); /** Test hook: forget which tokens have had their warning logged. */ export function resetJwtWarningLog() { jwtWarningLog.reset(); } /** * A Clerk OAuth JWT counts as `oauth` like the opaque `oat_` token it replaces, * so OAuth telemetry stays continuous across the switch to JWT access tokens. * `jwt` is left for Entra, EMA, Vercel Marketplace and unrecognised JWTs. */ export function classifyAuthMethod(token) { if (!token) return "none"; if (isOpaqueOAuthToken(token) || isClerkOAuthJwt(token)) return "oauth"; if (isJWT(token)) return "jwt"; return "api_key"; } /** * How an OAuth client registered, read from the unverified `client_id` claim * of a Clerk OAuth JWT. Telemetry only: it tracks the move from Dynamic Client * Registration to Client ID Metadata Documents. Undefined for non-OAuth tokens. */ export function classifyOAuthClientRegistration(token) { if (!token) return undefined; if (isOpaqueOAuthToken(token)) return "unknown"; if (!isClerkOAuthJwt(token)) return undefined; const clientId = oauthClientId(token); if (!clientId) return "unknown"; return clientId.startsWith("https://") ? "cimd" : "registered"; } const UNRESOLVED_PLACEHOLDER = /^\$(\{[^}]*\}|[A-Za-z_]\w*)$/; /** * The trimmed key, or undefined for an empty value or an unexpanded `${VAR}` / * `$VAR`. Gemini CLI passes `${CONTEXT7_API_KEY}` through unchanged when the * user skips the extension's API key prompt; sent as a key, it gets a 401 on * every call. */ export function usableApiKey(value) { const key = value?.trim(); return key && !UNRESOLVED_PLACEHOLDER.test(key) ? key : undefined; } export function parseMcpAuthMode(raw = process.env.MCP_AUTH_ENFORCEMENT) { const value = raw?.trim().toLowerCase(); if (!value) return "required"; if (value === "observe" || value === "required") return value; throw new Error(`MCP_AUTH_ENFORCEMENT must be "observe" or "required"; received "${raw}"`); } /** * Clients of `/mcp/oauth` and the Claude Code plugin start OAuth only after a * 401, so they keep that challenge in observe mode and observation covers only * the plain `/mcp` route. An empty plugin Authorization header comes from older * plugin versions that expanded an unset API key; it stays anonymous until * enforcement is required. */ export function effectiveMcpAuthMode(mode, endpoint, plugin, authorizationHeader) { if (mode === "required" || endpoint === "/mcp/oauth") return "required"; return plugin && authorizationHeader !== "" ? "required" : "observe"; } /** * Decide whether the HTTP request may reach the MCP transport. JWTs are * verified at this boundary. Opaque OAuth tokens are checked against the * Context7 API (cached per token) so an expired sign-in gets the HTTP 401 that * makes MCP clients refresh it; the check fails open. A Clerk OAuth JWT is * verified locally (signature, issuer, type, audience) with no API call, and * only a rejection the Context7 API returned on an earlier data request can * still turn it away. API keys are recorded as present, then authoritatively * validated by the Context7 API before data is returned. */ export async function evaluateMcpAuthentication(token, mode, now = Date.now()) { const method = classifyAuthMethod(token); if (!token) { return mode === "observe" ? { allowed: true, method, event: "credential_missing", outcome: "missing" } : { allowed: false, method, error: "Authentication required. Please authenticate to use this MCP server.", event: "challenge_issued", outcome: "missing", }; } if (isOpaqueOAuthToken(token) && isOpaqueOAuthTokenValidationEnabled()) { const verdict = await validateOpaqueOAuthToken(token); if (verdict === "invalid") { return { allowed: false, method, error: EXPIRED_OAUTH_TOKEN_ERROR, event: "credential_rejected", outcome: "expired", }; } return verdict === "valid" ? { allowed: true, method, event: "credential_validated", outcome: "accepted" } : { allowed: true, method, event: "credential_present", outcome: "unverified" }; } if (!isJWT(token)) { return { allowed: true, method, event: "credential_present", outcome: "accepted", }; } if (isClerkOAuthJwt(token) && isOAuthTokenRememberedInvalid(token)) { return { allowed: false, method, error: EXPIRED_OAUTH_TOKEN_ERROR, event: "credential_rejected", outcome: "expired", }; } const validation = await validateJWT(token); if (validation.warning) { // Names the OAuth client and the audience, never the token. The verdict // below is still applied on every request; only the line is deduplicated. const suppressed = jwtWarningLog.admit(token, now); if (suppressed !== undefined) { console.error(`[Context7] ${validation.warning} suppressedSincePrevious=${suppressed}`); } } if (!validation.valid) { return { allowed: false, method, error: validation.error || "Invalid token. Please re-authenticate.", event: "credential_rejected", // An expired Clerk access token is the JWT form of an expired `oat_`: // the client refreshes it after this 401. outcome: method === "oauth" && validation.error === "Token expired" ? "expired" : "invalid", }; } return { allowed: true, method, event: "credential_validated", outcome: "accepted", }; } export function canonicalMcpEndpoint(req) { return `${req.baseUrl}${req.path}`.replace(/\/$/, "") === "/mcp/oauth" ? "/mcp/oauth" : "/mcp"; } export function protectedResourceUrl(endpoint) { return new URL(endpoint, new URL(RESOURCE_URL).origin).toString(); } function protectedResourceMetadataUrl(endpoint) { return new URL(`/.well-known/oauth-protected-resource${endpoint}`, RESOURCE_URL).toString(); } export function setBearerChallenge(res, endpoint, errorDescription) { const parameters = [`resource_metadata="${protectedResourceMetadataUrl(endpoint)}"`]; if (errorDescription) { const description = errorDescription.replace(/["\\]/g, ""); parameters.unshift(`error="invalid_token"`, `error_description="${description}"`); } res.set("WWW-Authenticate", `Bearer ${parameters.join(", ")}`); } export function protectedResourceMetadata(resource) { return { resource, // 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"], }; }