lib/mcp-oauth-resource.ts
Rama main · réplica de lectura del repositorio original.
import { createPublicKey, verify } from "node:crypto";
import { assertSafeUpstreamUrl, readBoundedResponseText } from "./runtime/network-policy";
export type AuthorizationServerMetadata = { issuer: string; jwks_uri: string; authorization_endpoint: string; token_endpoint: string; code_challenge_methods_supported?: string[]; grant_types_supported?: string[]; registration_endpoint?: string };
const cache = new Map<string, { value: unknown; expires: number }>();
function configuredIssuer() { return process.env.MCP_AUTHORIZATION_SERVER_ISSUER?.replace(/\/$/, "") ?? null; }
export function mcpResourceIdentifier(origin: string) { return process.env.MCP_RESOURCE_IDENTIFIER?.replace(/\/$/, "") ?? `${origin.replace(/\/$/, "")}/api/mcp`; }
export function protectedResourceMetadata(origin: string) {
const issuer = configuredIssuer();
if (!issuer) return null;
return { resource: mcpResourceIdentifier(origin), resource_name: "Astrail MCP Runtime", authorization_servers: [issuer], scopes_supported: (process.env.MCP_SCOPES_SUPPORTED ?? "mcp:tools").split(/[ ,]+/).filter(Boolean), bearer_methods_supported: ["header"], resource_documentation: `${origin}/docs` };
}
export function bearerChallenge(origin: string, error?: string, description?: string) {
const metadata = `${origin}/.well-known/oauth-protected-resource`;
const safe = (value: string) => value.replace(/["\\\r\n]/g, " ").slice(0, 160);
return `Bearer realm="astrail-mcp", resource_metadata="${metadata}"${error ? `, error="${safe(error)}"` : ""}${description ? `, error_description="${safe(description)}"` : ""}`;
}
async function json(url: string) {
const hit = cache.get(url); if (hit && hit.expires > Date.now()) return hit.value;
const parsed = new URL(url); if (parsed.protocol !== "https:") throw new Error("OAuth metadata must use HTTPS.");
await assertSafeUpstreamUrl(parsed); const response = await fetch(parsed, { redirect: "error", signal: AbortSignal.timeout(10_000) });
const text = await readBoundedResponseText(response, 256_000, "OAuth metadata"); if (!response.ok) throw new Error("OAuth metadata discovery failed.");
const value = JSON.parse(text); cache.set(url, { value, expires: Date.now() + 300_000 }); return value;
}
export async function discoverAuthorizationServer(): Promise<AuthorizationServerMetadata> {
const issuer = configuredIssuer(); if (!issuer) throw new Error("MCP authorization server is not configured.");
const issuerUrl = new URL(issuer); const issuerPath = issuerUrl.pathname === "/" ? "" : issuerUrl.pathname.replace(/\/$/, "");
const candidates = [`${issuerUrl.origin}/.well-known/oauth-authorization-server${issuerPath}`, `${issuerUrl.origin}/.well-known/openid-configuration${issuerPath}`];
let metadata: AuthorizationServerMetadata | null = null;
for (const candidate of candidates) { try { metadata = await json(candidate) as AuthorizationServerMetadata; break; } catch {} }
if (!metadata) throw new Error("Authorization-server metadata unavailable.");
validateAuthorizationServerMetadata(metadata, issuer);
return metadata;
}
export function validateAuthorizationServerMetadata(metadata: AuthorizationServerMetadata, issuer: string) {
if (metadata.issuer !== issuer) throw new Error("Authorization-server issuer mismatch.");
for (const endpoint of [metadata.jwks_uri, metadata.authorization_endpoint, metadata.token_endpoint]) {
const url = new URL(endpoint); if (url.protocol !== "https:" || url.origin !== new URL(issuer).origin) throw new Error("Authorization-server metadata host mismatch.");
}
if (!metadata.code_challenge_methods_supported?.includes("S256")) throw new Error("Authorization server does not advertise S256 PKCE.");
if (metadata.grant_types_supported && !metadata.grant_types_supported.includes("authorization_code")) throw new Error("Authorization server does not support authorization_code.");
}
function decode(value: string) { return JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record<string, unknown>; }
export async function validateMcpAccessToken(token: string, origin: string) {
const parts = token.split("."); if (parts.length !== 3) throw new Error("Opaque tokens require configured introspection; JWT expected.");
const header = decode(parts[0]), claims = decode(parts[1]); const metadata = await discoverAuthorizationServer();
validateTokenEnvelopeClaims(claims, metadata.issuer, mcpResourceIdentifier(origin));
if (header.alg !== "RS256" || typeof header.kid !== "string") throw new Error("Unsupported token signature.");
const jwks = await json(metadata.jwks_uri) as { keys?: Array<Record<string, unknown>> }; const jwk = jwks.keys?.find((key) => key.kid === header.kid && key.alg === "RS256"); if (!jwk) throw new Error("Signing key not found.");
const valid = verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), createPublicKey({ key: jwk as import("node:crypto").JsonWebKey, format: "jwk" }), Buffer.from(parts[2], "base64url")); if (!valid) throw new Error("Invalid token signature.");
return claims;
}
export function validateTokenEnvelopeClaims(claims: Record<string,unknown>,issuer:string,resource:string,now=Date.now()){
if(claims.iss!==issuer)throw new Error("Token issuer mismatch.");
const audience=Array.isArray(claims.aud)?claims.aud:[claims.aud];if(audience.length!==1||audience[0]!==resource)throw new Error("Token audience mismatch.");
if(typeof claims.exp!=="number"||claims.exp*1000<=now)throw new Error("Token expired.");
if(typeof claims.nbf==="number"&&claims.nbf*1000>now+30_000)throw new Error("Token not active.");
}
export function validateMcpTokenClaims(claims: Record<string, unknown>, tenantId: string) {
const tenantClaim = process.env.MCP_TENANT_CLAIM ?? "astrail_tenant_id";
if (claims[tenantClaim] !== tenantId) throw new Error("Token tenant mismatch.");
if (typeof claims.sub !== "string" || !claims.sub.trim() || claims.sub.length > 256) throw new Error("Token subject missing.");
const scope = typeof claims.scope === "string" ? claims.scope.split(/\s+/).filter(Boolean) : [];
if (!scope.includes("mcp:tools")) throw new Error("Token scope insufficient.");
return { subject: claims.sub, scopes: scope, clientId: typeof claims.client_id === "string" ? claims.client_id : null };
}
export function clearMcpOAuthCacheForTests() { cache.clear(); }