@upstash/context7-mcp
Version:
MCP server for Context7
194 lines (193 loc) • 9.22 kB
JavaScript
import * as jose from "jose";
import { CONTEXT7_API_BASE_URL, EMA_ISSUER, EMA_JWKS_URL, OAUTH_AUTH_SERVER_URL, OAUTH_JWKS_URL, RESOURCE_URL, } from "./constants.js";
import { checkOAuthAudience, FOREIGN_OAUTH_AUDIENCE_MESSAGE } from "./oauth-audience.js";
import { isVercelMarketplaceIssuer, validateVercelMarketplaceJwt } from "./vercelMarketplaceJwt.js";
const oauthJwks = jose.createRemoteJWKSet(new URL(OAUTH_JWKS_URL));
const emaJwks = jose.createRemoteJWKSet(new URL(EMA_JWKS_URL));
const ENTRA_V2_ISSUER_RE = /^https:\/\/login\.microsoftonline\.com\/([0-9a-f-]{36})\/v2\.0$/;
/** RFC 9068 media type of a JWT OAuth access token. */
const OAUTH_ACCESS_TOKEN_TYP = "at+jwt";
/** Allowed skew between Clerk's clock and ours when checking `exp`/`nbf`. */
const OAUTH_CLOCK_TOLERANCE_SECONDS = 60;
// Microsoft's tenant-independent key set. It holds the keys of every tenant-specific set
// plus the personal-account (MSA) keys, so each key's `issuer` limits which tokens it may sign.
// Apps with custom signing keys (claims mapping, `?appid=` key set) are not supported.
const entraJwks = jose.createRemoteJWKSet(new URL("https://login.microsoftonline.com/common/discovery/v2.0/keys"));
function entraKeyMayIssue(kid, issuer, tenantId) {
const key = entraJwks.jwks()?.keys.find((k) => k.kid === kid);
return typeof key?.issuer === "string" && key.issuer.replace("{tenantid}", tenantId) === issuer;
}
const CONFIG_TTL_MS = 5 * 60 * 1000;
const CONFIG_CACHE_MAX = 1000;
const configByAudience = new Map();
function cacheEntraConfig(audience, value, now) {
// Evicts the oldest insert (Map order), not the least recently used; enough for a size cap.
if (configByAudience.size >= CONFIG_CACHE_MAX) {
configByAudience.delete(configByAudience.keys().next().value);
}
configByAudience.set(audience, { value, expiresAt: now + CONFIG_TTL_MS });
}
async function fetchEntraConfig(audience) {
const now = Date.now();
const cached = configByAudience.get(audience);
if (cached) {
if (cached.expiresAt > now)
return cached.value;
configByAudience.delete(audience);
}
try {
const res = await fetch(`${CONTEXT7_API_BASE_URL}/v2/entra/config/${encodeURIComponent(audience)}`);
if (res.ok) {
const value = (await res.json());
cacheEntraConfig(audience, value, now);
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.
cacheEntraConfig(audience, null, now);
return null;
}
}
catch {
// Network or JSON parse error: transient. Fall through without caching so
// the next request retries.
}
return null;
}
export function isJWT(token) {
return token.split(".").length === 3;
}
/** jose compares `typ` the same way: case-insensitive, `application/` prefix optional. */
function normalizeTyp(typ) {
return typ.toLowerCase().replace(/^application\//, "");
}
/**
* Whether the token looks like an OAuth access token minted by Clerk: three
* segments, a `typ` header of `at+jwt` (or `application/at+jwt`), and the
* configured authorization server as its unsigned `iss`. This only routes the
* token to the Clerk branch and classifies it for telemetry; trust comes from
* `jwtVerify`, which checks the signed header and claims.
*/
export function isClerkOAuthJwt(token) {
if (typeof token !== "string" || !isJWT(token))
return false;
try {
const { typ } = jose.decodeProtectedHeader(token);
if (typeof typ !== "string" || normalizeTyp(typ) !== OAUTH_ACCESS_TOKEN_TYP)
return false;
return jose.decodeJwt(token).iss === OAUTH_AUTH_SERVER_URL;
}
catch {
return false;
}
}
/** The unverified `client_id` claim of a JWT, if it is a non-empty string. */
export function oauthClientId(token) {
try {
const { client_id: clientId } = jose.decodeJwt(token);
return typeof clientId === "string" && clientId ? clientId : undefined;
}
catch {
return undefined;
}
}
/**
* Clerk signs ID tokens and session JWTs with the same issuer and JWKS as its
* OAuth access tokens, so issuer and signature alone cannot tell them apart.
* Only an access token (`typ: at+jwt`, RFC 9068) is a credential for this
* resource server: an ID token is addressed to the OAuth client, and a session
* JWT carries the user's whole browser session, which the user never granted
* to an MCP client and which names no audience at all. Both are rejected with
* 401 in every audience-enforcement mode; `observe` and `foreign` only relax
* the `aud` comparison of a genuine access token.
*/
async function validateClerkOAuthJwt(token) {
if (!isClerkOAuthJwt(token)) {
return { valid: false, error: "Not an OAuth access token" };
}
const { payload } = await jose.jwtVerify(token, oauthJwks, {
issuer: OAUTH_AUTH_SERVER_URL,
typ: OAUTH_ACCESS_TOKEN_TYP,
clockTolerance: OAUTH_CLOCK_TOLERANCE_SECONDS,
});
const audience = checkOAuthAudience(payload.aud);
if (audience.allowed)
return { valid: true };
// The token is genuine; is it ours? A token issued for another resource
// (or for none) is a foreign MCP server's token until the audience check
// says otherwise. Whether that rejects it is the enforcement mode's call.
const clientId = typeof payload.client_id === "string" && payload.client_id ? payload.client_id : "unknown";
const warning = `audienceMismatch clientId=${clientId} aud=${audience.aud} enforcement=${audience.enforcement} outcome=${audience.outcome}`;
if (audience.outcome === "rejected") {
// A new token from the same client would be rejected the same way, so
// "re-authenticate" would only send it in a loop; the message names the
// way out instead.
return { valid: false, error: FOREIGN_OAUTH_AUDIENCE_MESSAGE, warning };
}
return { valid: true, warning };
}
export async function validateJWT(token) {
try {
const decoded = jose.decodeJwt(token);
const iss = typeof decoded.iss === "string" ? decoded.iss : "";
const entraTenantId = ENTRA_V2_ISSUER_RE.exec(iss)?.[1];
if (entraTenantId) {
const audience = typeof decoded.aud === "string" ? decoded.aud : "";
if (!audience)
return { valid: false, error: "Missing audience" };
// The claims are untrusted until Microsoft's signature is verified, so no backend lookup before it.
const issuer = `https://login.microsoftonline.com/${entraTenantId}/v2.0`;
const { payload, protectedHeader } = await jose.jwtVerify(token, entraJwks, {
issuer,
audience,
algorithms: ["RS256"],
});
// Microsoft's multi-tenant rule: the signed tid must be the issuer's tenant, and the
// key's {tenantid} template is filled from tid, not from the issuer we just matched.
if (payload.tid !== entraTenantId)
return { valid: false, error: "Invalid token claims" };
if (!entraKeyMayIssue(protectedHeader.kid, issuer, payload.tid)) {
return { valid: false, error: "Invalid signature" };
}
const config = await fetchEntraConfig(audience);
if (!config)
return { valid: false, error: "Unknown audience" };
if (config.tenantId !== entraTenantId)
return { valid: false, error: "Invalid token claims" };
if (config.requiredScope) {
const scopes = String(payload.scp ?? "").split(" ");
if (!scopes.includes(config.requiredScope)) {
return { valid: false, error: "Missing required scope" };
}
}
return { valid: true };
}
if (iss === EMA_ISSUER) {
await jose.jwtVerify(token, emaJwks, { issuer: EMA_ISSUER, audience: RESOURCE_URL });
return { valid: true };
}
if (isVercelMarketplaceIssuer(iss)) {
return validateVercelMarketplaceJwt(token, iss);
}
if (iss === OAUTH_AUTH_SERVER_URL) {
return await validateClerkOAuthJwt(token);
}
// No trusted issuer claims the token, so no JWKS could verify it; the
// Clerk branch used to be the fallback and failed on the issuer claim
// after a key lookup.
return { valid: false, error: "Untrusted issuer" };
}
catch (error) {
if (error instanceof jose.errors.JWTExpired) {
return { valid: false, error: "Token expired" };
}
if (error instanceof jose.errors.JWTClaimValidationFailed) {
return { valid: false, error: "Invalid token claims" };
}
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
return { valid: false, error: "Invalid signature" };
}
return { valid: false, error: "Invalid token" };
}
}