mcpresso
Version:
TypeScript package for Model Context Protocol (MCP) utilities and tools
386 lines (385 loc) ⢠19.1 kB
JavaScript
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { URL } from 'url';
/**
* Creates an Express middleware for validating MCP access tokens.
* This middleware implements the MCP authorization specification:
* https://modelcontextprotocol.io/specification/draft/basic/authorization
*
* @param authConfig The MCP authentication configuration.
* @param serverUrl The canonical URL of this MCP server, used as the required 'audience'.
* @returns An Express middleware function.
*/
export function createMCPAuthMiddleware(authConfig, serverUrl) {
// console.log("createMCPAuthMiddleware", authConfig, serverUrl);
// Validate that we have either issuer, oauth server, or bearer token
if (!authConfig.issuer && !authConfig.oauth && !authConfig.bearerToken) {
throw new Error("MCPAuthConfig must provide either 'issuer' (for external OAuth), 'oauth' (for integrated OAuth), or 'bearerToken' (for bearer token auth)");
}
// Use provided serverUrl or fallback to config
const canonicalServerUrl = serverUrl || authConfig.serverUrl;
// Check if this is a bearer token authentication
if (authConfig.bearerToken) {
const { headerName = "Authorization", token, userProfile } = authConfig.bearerToken;
return async (req, res, next) => {
console.log("\n" + "=".repeat(60));
console.log("š BEARER TOKEN AUTH MIDDLEWARE DEBUG");
console.log("=".repeat(60));
console.log("š Request Details:");
console.log(" URL:", req.url);
console.log(" Method:", req.method);
console.log(" IP:", req.ip || req.connection.remoteAddress);
console.log(" User-Agent:", req.headers['user-agent']);
console.log("\nš Authentication Headers:");
console.log(` ${headerName}:`, req.headers[headerName.toLowerCase()] || "ā MISSING");
console.log(" MCP-Session-ID:", req.headers['mcp-session-id'] || "ā MISSING");
console.log("\nšØ All Request Headers:");
Object.entries(req.headers).forEach(([key, value]) => {
console.log(` ${key}: ${value}`);
});
console.log("\nš¦ Request Body (if any):");
if (req.body && Object.keys(req.body).length > 0) {
console.log(" ", JSON.stringify(req.body, null, 2));
}
else {
console.log(" ā No body");
}
console.log("=".repeat(60));
const authHeader = req.headers[headerName.toLowerCase()];
// Check for Bearer token
if (!authHeader || !(typeof authHeader === 'string' ? authHeader.toLowerCase().startsWith('bearer ') : authHeader[0].toLowerCase().startsWith('bearer '))) {
console.log("\nā AUTHENTICATION FAILED");
console.log("š« Reason: Missing or invalid Authorization header");
console.log(`š Expected: '${headerName}: Bearer <token>'`);
console.log("š Received:", authHeader || "undefined");
console.log("š¤ Sending 401 Unauthorized response");
console.log("=".repeat(60) + "\n");
return res
.status(401)
.header('WWW-Authenticate', `Bearer`)
.json({
error: 'missing_or_invalid_authorization_header',
error_description: 'Authorization header with Bearer token is required',
details: `${headerName} header must start with "Bearer "`
});
}
const receivedToken = typeof authHeader === 'string' ? authHeader.substring(7) : authHeader[0].substring(7);
// Validate the bearer token
if (receivedToken !== token) {
console.log("\nā AUTHENTICATION FAILED");
console.log("š« Reason: Invalid bearer token");
console.log("š¤ Sending 401 Unauthorized response");
console.log("=".repeat(60) + "\n");
return res
.status(401)
.header('WWW-Authenticate', `Bearer error="invalid_token"`)
.json({
error: 'invalid_token',
error_description: 'Invalid bearer token'
});
}
// Create user profile
const profile = userProfile || {
id: "bearer-user",
username: "api-client",
email: "api@example.com",
scopes: ["read", "write"]
};
// Attach the user profile to the request
req.auth = profile;
console.log("\nā
BEARER TOKEN AUTHENTICATION SUCCESSFUL");
console.log("š¤ User Profile:");
console.log(" ID:", profile.id);
console.log(" Username:", profile.username || "N/A");
console.log(" Email:", profile.email || "N/A");
console.log(" Scopes:", profile.scopes || "N/A");
console.log("š¤ Proceeding to next middleware/handler");
console.log("=".repeat(60) + "\n");
next();
};
}
// Get issuer from config or OAuth server
const issuer = authConfig.issuer || authConfig.oauth?.config?.issuer;
if (!issuer) {
throw new Error("Unable to determine issuer URL from auth config");
}
// Decide verification method: JWKS (default) or shared secret (HS256)
const jwtSecret = authConfig.jwtSecret || authConfig.oauth?.config?.jwtSecret;
const useSharedSecret = !!jwtSecret;
let JWKS;
if (useSharedSecret) {
JWKS = new TextEncoder().encode(jwtSecret);
}
else {
const jwksEndpoint = authConfig.jwksEndpoint || `/.well-known/jwks.json`;
const jwksUrl = new URL(jwksEndpoint, issuer);
JWKS = createRemoteJWKSet(jwksUrl);
}
// Construct the metadata URL for WWW-Authenticate headers
let metadataUrl;
try {
if (authConfig.metadataEndpoint) {
// Use custom metadata endpoint path
if (canonicalServerUrl) {
metadataUrl = new URL(authConfig.metadataEndpoint, canonicalServerUrl).href;
}
else {
metadataUrl = authConfig.metadataEndpoint;
}
}
else if (!canonicalServerUrl) {
metadataUrl = "/.well-known/oauth-protected-resource-metadata";
}
else {
metadataUrl = new URL('/.well-known/oauth-protected-resource-metadata', canonicalServerUrl).href;
}
}
catch (error) {
metadataUrl = "/.well-known/oauth-protected-resource-metadata";
}
// Get default values for configuration
const config = {
requireResourceIndicator: authConfig.requireResourceIndicator ?? true,
validateAudience: authConfig.validateAudience ?? true,
errorHandling: {
includeDetails: authConfig.errorHandling?.includeDetails ?? true,
messages: {
missingToken: authConfig.errorHandling?.messages?.missingToken ?? 'Authorization header with Bearer token is required',
invalidToken: authConfig.errorHandling?.messages?.invalidToken ?? 'Invalid token',
expiredToken: authConfig.errorHandling?.messages?.expiredToken ?? 'Token has expired',
audienceMismatch: authConfig.errorHandling?.messages?.audienceMismatch ?? 'Token audience mismatch',
signatureFailure: authConfig.errorHandling?.messages?.signatureFailure ?? 'Token signature verification failed',
}
},
logging: {
logSuccess: authConfig.logging?.logSuccess ?? true,
logFailures: authConfig.logging?.logFailures ?? true,
logValidation: authConfig.logging?.logValidation ?? true,
}
};
return async (req, res, next) => {
console.log("\n" + "=".repeat(60));
console.log("š AUTH MIDDLEWARE DEBUG");
console.log("=".repeat(60));
console.log("š Request Details:");
console.log(" URL:", req.url);
console.log(" Method:", req.method);
console.log(" IP:", req.ip || req.connection.remoteAddress);
console.log(" User-Agent:", req.headers['user-agent']);
console.log("\nš Authentication Headers:");
console.log(" Authorization:", req.headers.authorization || "ā MISSING");
console.log(" MCP-Session-ID:", req.headers['mcp-session-id'] || "ā MISSING");
console.log("\nšØ All Request Headers:");
Object.entries(req.headers).forEach(([key, value]) => {
console.log(` ${key}: ${value}`);
});
console.log("\nš¦ Request Body (if any):");
if (req.body && Object.keys(req.body).length > 0) {
console.log(" ", JSON.stringify(req.body, null, 2));
}
else {
console.log(" ā No body");
}
console.log("=".repeat(60));
const authHeader = req.headers.authorization;
// Check for Bearer token
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) {
console.log("\nā AUTHENTICATION FAILED");
console.log("š« Reason: Missing or invalid Authorization header");
console.log("š Expected: 'Authorization: Bearer <token>'");
console.log("š Received:", authHeader || "undefined");
console.log("š¤ Sending 401 Unauthorized response");
console.log("=".repeat(60) + "\n");
if (config.logging.logFailures) {
console.log(`[AUTH] Missing or invalid authorization header from ${req.ip}`);
}
return res
.status(401)
.header('WWW-Authenticate', `Bearer, resource_metadata_uri="${metadataUrl}"`)
.json({
error: 'missing_or_invalid_authorization_header',
error_description: config.errorHandling.messages.missingToken,
...(config.errorHandling.includeDetails && { details: 'Authorization header must start with "Bearer "' })
});
}
const token = authHeader.substring(7);
try {
// Prepare JWT verification options
const jwtVerifyOptions = {
issuer: authConfig.jwtOptions?.issuer || issuer,
audience: authConfig.jwtOptions?.audience || (canonicalServerUrl ? [canonicalServerUrl.replace(/\/+$/, ''), canonicalServerUrl.replace(/\/+$/, '') + '/'] : undefined),
};
// Add clock tolerance if specified
if (authConfig.jwtOptions?.clockTolerance) {
jwtVerifyOptions.clockTolerance = authConfig.jwtOptions.clockTolerance;
}
// Add max token age if specified
if (authConfig.jwtOptions?.maxTokenAge) {
jwtVerifyOptions.maxTokenAge = authConfig.jwtOptions.maxTokenAge;
}
if (config.logging.logValidation) {
console.log(`[AUTH] Validating token with options:`, {
issuer: jwtVerifyOptions.issuer,
audience: jwtVerifyOptions.audience,
clockTolerance: jwtVerifyOptions.clockTolerance,
maxTokenAge: jwtVerifyOptions.maxTokenAge
});
}
// Get JWT algorithm
const jwtAlgorithm = authConfig.jwtAlgorithm || authConfig.oauth?.config?.jwtAlgorithm || 'HS256';
// Validate JWT token
const { payload } = useSharedSecret
? await jwtVerify(token, JWKS, {
algorithms: [jwtAlgorithm],
issuer: jwtVerifyOptions.issuer,
})
: await jwtVerify(token, JWKS, jwtVerifyOptions);
if (config.logging.logValidation) {
console.log('[AUTH] Token payload aud:', payload.aud);
}
// MCP-specific: Validate audience if required
if (config.validateAudience && canonicalServerUrl) {
const aud = payload.aud ? (Array.isArray(payload.aud) ? payload.aud.map((a) => (typeof a === 'string' ? a.replace(/\/+$/, '') : a)) : (typeof payload.aud === 'string' ? [payload.aud.replace(/\/+$/, '')] : [])) : undefined;
const audiences = aud;
const canonicalNormalized = canonicalServerUrl.replace(/\/+$/, '');
if (config.logging.logValidation) {
console.log('[AUTH] Normalized audiences:', audiences, ' Expected:', canonicalNormalized);
}
if (!audiences.includes(canonicalNormalized)) {
if (config.logging.logFailures) {
console.log(`[AUTH] Token audience mismatch from ${req.ip}. Expected: ${canonicalServerUrl}, Got: ${audiences.join(', ')}`);
}
return res
.status(401)
.header('WWW-Authenticate', `Bearer error="invalid_token", error_description="${config.errorHandling.messages.audienceMismatch}", resource_metadata_uri="${metadataUrl}"`)
.json({
error: 'invalid_token',
error_description: config.errorHandling.messages.audienceMismatch,
...(config.errorHandling.includeDetails && { details: `Expected audience: ${canonicalServerUrl}, Got: ${audiences.join(', ')}` })
});
}
}
// Log successful authentication if enabled
if (config.logging.logSuccess) {
console.log(`[AUTH] Successful authentication for user ${payload.sub} from ${req.ip}`);
}
// Fetch full user profile if userLookup function is provided
let userProfile = payload;
if (authConfig.userLookup) {
try {
const fetchedProfile = await authConfig.userLookup(payload);
if (fetchedProfile) {
userProfile = fetchedProfile;
}
else {
console.warn(`[AUTH] User lookup returned null for user ${payload.sub}`);
}
}
catch (error) {
console.error(`[AUTH] Error fetching user profile for ${payload.sub}:`, error);
// Continue with JWT payload as fallback
}
}
// Attach the user profile (or JWT payload as fallback) to the request for use in handlers
req.auth = userProfile;
console.log("\nā
AUTHENTICATION SUCCESSFUL");
console.log("š¤ User Profile:");
console.log(" ID:", userProfile.id || userProfile.sub);
console.log(" Username:", userProfile.username || "N/A");
console.log(" Email:", userProfile.email || "N/A");
console.log(" Scopes:", userProfile.scopes || userProfile.scope || "N/A");
console.log("š¤ Proceeding to next middleware/handler");
console.log("=".repeat(60) + "\n");
next();
}
catch (error) {
let message = config.errorHandling.messages.invalidToken;
let errorCode = 'invalid_token';
if (error.code === 'ERR_JWT_EXPIRED') {
message = config.errorHandling.messages.expiredToken;
errorCode = 'invalid_token';
}
else if (error.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
message = config.errorHandling.messages.signatureFailure;
errorCode = 'invalid_token';
}
else if (error.code === 'ERR_JWT_CLAIM_VALIDATION_FAILED') {
message = `Token claim validation failed: ${error.claim} ${error.reason}`;
errorCode = 'invalid_token';
}
else if (error.code === 'ERR_JWT_MALFORMED') {
message = 'Malformed token';
errorCode = 'invalid_token';
}
if (config.logging.logFailures) {
console.log(`[AUTH] Authentication failed from ${req.ip}: ${message}`, {
errorCode: error.code,
claim: error.claim,
reason: error.reason
});
}
return res
.status(401)
.header('WWW-Authenticate', `Bearer error="${errorCode}", error_description="${message}", resource_metadata_uri="${metadataUrl}"`)
.json({
error: errorCode,
error_description: message,
...(config.errorHandling.includeDetails && {
details: error.code === 'ERR_JWT_CLAIM_VALIDATION_FAILED'
? `Claim: ${error.claim}, Reason: ${error.reason}`
: `Error code: ${error.code}`
})
});
}
};
}
/**
* Creates the MCP Protected Resource Metadata endpoint.
* This implements RFC 9728 as required by the MCP specification.
*
* @param authConfig The MCP authentication configuration.
* @param serverUrl The canonical URL of this MCP server.
* @returns An Express route handler for the metadata endpoint.
*/
export function createMCPProtectedResourceMetadataHandler(authConfig, serverUrl) {
const canonicalServerUrl = serverUrl || authConfig.serverUrl;
return (req, res) => {
if (!canonicalServerUrl) {
return res.status(500).json({
error: 'server_configuration_error',
error_description: 'Server URL not configured'
});
}
// Handle bearer token authentication
if (authConfig.bearerToken) {
const metadata = {
resource: canonicalServerUrl,
authorization_servers: [],
scopes_supported: ['read', 'write', 'admin'],
bearer_methods_supported: ['Authorization header'],
auth_type: 'bearer_token'
};
return res.json(metadata);
}
// Handle OAuth authentication
const metadata = {
resource: canonicalServerUrl,
authorization_servers: [authConfig.issuer],
scopes_supported: ['read', 'write', 'admin'],
bearer_methods_supported: ['Authorization header']
};
res.json(metadata);
};
}
/**
* @deprecated Use createMCPAuthMiddleware instead
*/
export function createAuthMiddleware(authConfig, serverUrl) {
console.warn('createAuthMiddleware is deprecated. Use createMCPAuthMiddleware instead.');
const mcpConfig = {
issuer: authConfig.issuer,
serverUrl,
requireResourceIndicator: true,
validateAudience: true
};
return createMCPAuthMiddleware(mcpConfig, serverUrl);
}