perplexity-mcp-server
Version:
A Perplexity API Model Context Protocol (MCP) server that unlocks Perplexity's search-augmented AI capabilities for LLM agents. Features robust error handling, secure input validation, and transparent reasoning with the showThinking parameter. Built with
125 lines (124 loc) • 5.96 kB
JavaScript
/**
* @fileoverview Hono middleware for OAuth 2.1 Bearer Token validation.
* This middleware extracts a JWT from the Authorization header, validates it against
* a remote JWKS (JSON Web Key Set), and checks its issuer and audience claims.
* On success, it populates an AuthInfo object and stores it in an AsyncLocalStorage
* context for use in downstream handlers.
*
* @module src/mcp-server/transports/auth/strategies/oauth/oauthMiddleware
*/
import { createRemoteJWKSet, jwtVerify } from "jose";
import { config } from "../../../../../config/index.js";
import { BaseErrorCode, McpError } from "../../../../../types-global/errors.js";
import { logger, requestContextService } from "../../../../../utils/index.js";
import { ErrorHandler } from "../../../../../utils/internal/errorHandler.js";
import { authContext } from "../../core/authContext.js";
// --- Startup Validation ---
// Ensures that necessary OAuth configuration is present when the mode is 'oauth'.
if (config.mcpAuthMode === "oauth") {
if (!config.oauthIssuerUrl) {
throw new Error("OAUTH_ISSUER_URL must be set when MCP_AUTH_MODE is 'oauth'");
}
if (!config.oauthAudience) {
throw new Error("OAUTH_AUDIENCE must be set when MCP_AUTH_MODE is 'oauth'");
}
logger.info("OAuth 2.1 mode enabled. Verifying tokens against issuer.", requestContextService.createRequestContext({
issuer: config.oauthIssuerUrl,
audience: config.oauthAudience,
}));
}
// --- JWKS Client Initialization ---
// The remote JWK set is fetched and cached to avoid network calls on every request.
let jwks;
if (config.mcpAuthMode === "oauth" && config.oauthIssuerUrl) {
try {
const jwksUrl = new URL(config.oauthJwksUri ||
`${config.oauthIssuerUrl.replace(/\/$/, "")}/.well-known/jwks.json`);
jwks = createRemoteJWKSet(jwksUrl, {
cooldownDuration: 300000, // 5 minutes
timeoutDuration: 5000, // 5 seconds
});
logger.info(`JWKS client initialized for URL: ${jwksUrl.href}`, requestContextService.createRequestContext({
operation: "oauthMiddlewareSetup",
}));
}
catch (error) {
logger.fatal("Failed to initialize JWKS client.", error, requestContextService.createRequestContext({
operation: "oauthMiddlewareSetup",
}));
// Prevent server from starting if JWKS setup fails in oauth mode
process.exit(1);
}
}
/**
* Hono middleware for verifying OAuth 2.1 JWT Bearer tokens.
* It validates the token and uses AsyncLocalStorage to pass auth info.
* @param c - The Hono context object.
* @param next - The function to call to proceed to the next middleware.
*/
export async function oauthMiddleware(c, next) {
// If OAuth is not the configured auth mode, skip this middleware.
if (config.mcpAuthMode !== "oauth") {
return await next();
}
const context = requestContextService.createRequestContext({
operation: "oauthMiddleware",
httpMethod: c.req.method,
httpPath: c.req.path,
});
if (!jwks) {
// This should not happen if startup validation is correct, but it's a safeguard.
// This should not happen if startup validation is correct, but it's a safeguard.
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, "OAuth middleware is active, but JWKS client is not initialized.", context);
}
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new McpError(BaseErrorCode.UNAUTHORIZED, "Missing or invalid token format.");
}
const token = authHeader.substring(7);
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: config.oauthIssuerUrl,
audience: config.oauthAudience,
});
// The 'scope' claim is typically a space-delimited string in OAuth 2.1.
const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
if (scopes.length === 0) {
logger.warning("Authentication failed: Token contains no scopes, but scopes are required.", { ...context, jwtPayloadKeys: Object.keys(payload) });
throw new McpError(BaseErrorCode.UNAUTHORIZED, "Token must contain valid, non-empty scopes.");
}
const clientId = typeof payload.client_id === "string" ? payload.client_id : undefined;
if (!clientId) {
logger.warning("Authentication failed: OAuth token 'client_id' claim is missing or not a string.", { ...context, jwtPayloadKeys: Object.keys(payload) });
throw new McpError(BaseErrorCode.UNAUTHORIZED, "Invalid token, missing client identifier.");
}
const authInfo = {
token,
clientId,
scopes,
subject: typeof payload.sub === "string" ? payload.sub : undefined,
};
// Attach to the raw request for potential legacy compatibility and
// store in AsyncLocalStorage for modern, safe access in handlers.
c.env.incoming.auth = authInfo;
await authContext.run({ authInfo }, next);
}
catch (error) {
if (error instanceof Error && error.name === "JWTExpired") {
logger.warning("Authentication failed: OAuth token expired.", context);
throw new McpError(BaseErrorCode.UNAUTHORIZED, "Token expired.");
}
const handledError = ErrorHandler.handleError(error, {
operation: "oauthMiddleware",
context,
rethrow: false, // We will throw a new McpError below
});
// Ensure we always throw an McpError for consistency
if (handledError instanceof McpError) {
throw handledError;
}
else {
throw new McpError(BaseErrorCode.UNAUTHORIZED, `Unauthorized: ${handledError.message || "Invalid token"}`, { originalError: handledError.name });
}
}
}