|
1 | 1 | /**
|
2 |
| - * MCP Authentication Middleware: Bearer Token Validation (JWT). |
| 2 | + * @fileoverview MCP Authentication Middleware for Bearer Token Validation (JWT). |
3 | 3 | *
|
4 | 4 | * This middleware validates JSON Web Tokens (JWT) passed via the 'Authorization' header
|
5 | 5 | * using the 'Bearer' scheme (e.g., "Authorization: Bearer <your_token>").
|
6 | 6 | * It verifies the token's signature and expiration using the secret key defined
|
7 |
| - * in the configuration (MCP_AUTH_SECRET_KEY). |
| 7 | + * in the configuration (`config.mcpAuthSecretKey`). |
8 | 8 | *
|
9 |
| - * If the token is valid, the decoded payload is attached to `req.auth` for potential |
10 |
| - * use in downstream authorization logic (e.g., checking scopes or permissions). |
| 9 | + * If the token is valid, an object conforming to the MCP SDK's `AuthInfo` type |
| 10 | + * (expected to contain `token`, `clientId`, and `scopes`) is attached to `req.auth`. |
11 | 11 | * If the token is missing, invalid, or expired, it sends an HTTP 401 Unauthorized response.
|
12 | 12 | *
|
13 |
| - * --- Scope and Relation to MCP Authorization Spec (2025-03-26) --- |
14 |
| - * - This middleware handles the *validation* of an already obtained Bearer token, |
15 |
| - * as required by Section 2.6 of the MCP Auth Spec. |
16 |
| - * - It does *NOT* implement the full OAuth 2.1 authorization flows (e.g., Authorization |
17 |
| - * Code Grant with PKCE), token endpoints (/token), authorization endpoints (/authorize), |
18 |
| - * metadata discovery (/.well-known/oauth-authorization-server), or dynamic client |
19 |
| - * registration (/register) described in the specification. It assumes the client |
20 |
| - * obtained the JWT through an external process compliant with the spec or another |
21 |
| - * agreed-upon mechanism. |
22 |
| - * - It correctly returns HTTP 401 errors for invalid/missing tokens as per Section 2.8. |
23 |
| - * |
24 |
| - * --- Implementation Details & Requirements --- |
25 |
| - * - Requires the 'jsonwebtoken' package (`npm install jsonwebtoken @types/jsonwebtoken`). |
26 |
| - * - The `MCP_AUTH_SECRET_KEY` environment variable MUST be set to a strong, secret value |
27 |
| - * in production. The middleware includes a startup check for this. |
28 |
| - * - In non-production environments, if the secret key is missing, authentication checks |
29 |
| - * are bypassed for development convenience (a warning is logged). THIS IS INSECURE FOR PRODUCTION. |
30 |
| - * - The structure of the JWT payload (e.g., containing user ID, scopes) depends on the |
31 |
| - * token issuer and is not dictated by this middleware itself, but the payload is made |
32 |
| - * available on `req.auth`. |
33 |
| - * |
34 | 13 | * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/authorization.mdx | MCP Authorization Specification}
|
| 14 | + * @module src/mcp-server/transports/authentication/authMiddleware |
35 | 15 | */
|
36 | 16 |
|
37 |
| -import { NextFunction, Request, Response } from 'express'; |
38 |
| -import jwt from 'jsonwebtoken'; |
39 |
| -// Import config, environment constants, and logger |
40 |
| -import { config, environment } from '../../../config/index.js'; |
41 |
| -import { logger } from '../../../utils/index.js'; |
| 17 | +import { NextFunction, Request, Response } from "express"; |
| 18 | +import jwt from "jsonwebtoken"; |
| 19 | +import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; // Import from SDK |
| 20 | +import { config, environment } from "../../../config/index.js"; |
| 21 | +import { logger, requestContextService } from "../../../utils/index.js"; |
42 | 22 |
|
43 | 23 | // Extend the Express Request interface to include the optional 'auth' property
|
44 |
| -// This allows attaching the decoded JWT payload to the request object. |
| 24 | +// using the imported AuthInfo type from the SDK. |
45 | 25 | declare global {
|
46 | 26 | // eslint-disable-next-line @typescript-eslint/no-namespace
|
47 | 27 | namespace Express {
|
48 | 28 | interface Request {
|
49 |
| - /** Decoded JWT payload if authentication is successful, or a development mode indicator. */ |
50 |
| - auth?: jwt.JwtPayload | string | { devMode: boolean; warning: string }; |
| 29 | + /** Authentication information derived from the JWT, conforming to MCP SDK's AuthInfo. */ |
| 30 | + auth?: AuthInfo; |
51 | 31 | }
|
52 | 32 | }
|
53 | 33 | }
|
54 | 34 |
|
55 |
| -// --- Startup Validation --- |
56 |
| -// Validate secret key presence on module load (fail fast principle). |
57 |
| -// This prevents the server starting insecurely in production without the key. |
58 |
| -if (environment === 'production' && !config.security.mcpAuthSecretKey) { |
59 |
| - logger.fatal('CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment. Authentication cannot proceed securely.'); |
60 |
| - // Throwing an error here will typically stop the Node.js process. |
61 |
| - throw new Error('MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication.'); |
| 35 | +// Startup Validation: Validate secret key presence on module load. |
| 36 | +if (environment === "production" && !config.security.mcpAuthSecretKey) { |
| 37 | + logger.fatal( |
| 38 | + "CRITICAL: MCP_AUTH_SECRET_KEY is not set in production environment. Authentication cannot proceed securely.", |
| 39 | + ); |
| 40 | + throw new Error( |
| 41 | + "MCP_AUTH_SECRET_KEY must be set in production environment for JWT authentication.", |
| 42 | + ); |
62 | 43 | } else if (!config.security.mcpAuthSecretKey) {
|
63 |
| - // Log a clear warning if running without a key in non-production environments. |
64 |
| - logger.warning('MCP_AUTH_SECRET_KEY is not set. Authentication middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.'); |
| 44 | + logger.warning( |
| 45 | + "MCP_AUTH_SECRET_KEY is not set. Authentication middleware will bypass checks (DEVELOPMENT ONLY). This is insecure for production.", |
| 46 | + ); |
65 | 47 | }
|
66 | 48 |
|
67 | 49 | /**
|
68 |
| - * Express middleware function for verifying JWT Bearer token authentication. |
69 |
| - * Checks the `Authorization` header, verifies the token, and attaches the payload to `req.auth`. |
70 |
| - * |
71 |
| - * @param {Request} req - Express request object. |
72 |
| - * @param {Response} res - Express response object. |
73 |
| - * @param {NextFunction} next - Express next middleware function. |
| 50 | + * Express middleware for verifying JWT Bearer token authentication. |
74 | 51 | */
|
75 |
| -export function mcpAuthMiddleware(req: Request, res: Response, next: NextFunction): void { |
76 |
| - // Establish context for logging associated with this middleware execution. |
77 |
| - const context = { operation: 'mcpAuthMiddleware', method: req.method, path: req.path }; |
78 |
| - logger.debug('Running MCP Authentication Middleware (Bearer Token Validation)...', context); |
| 52 | +export function mcpAuthMiddleware( |
| 53 | + req: Request, |
| 54 | + res: Response, |
| 55 | + next: NextFunction, |
| 56 | +): void { |
| 57 | + const context = requestContextService.createRequestContext({ |
| 58 | + operation: "mcpAuthMiddleware", |
| 59 | + method: req.method, |
| 60 | + path: req.path, |
| 61 | + }); |
| 62 | + logger.debug( |
| 63 | + "Running MCP Authentication Middleware (Bearer Token Validation)...", |
| 64 | + context, |
| 65 | + ); |
79 | 66 |
|
80 |
| - // --- Development Mode Bypass --- |
81 |
| - // If the secret key is missing (and not in production), bypass authentication. |
| 67 | + // Development Mode Bypass |
82 | 68 | if (!config.security.mcpAuthSecretKey) {
|
83 |
| - // Double-check environment for safety, although the startup check should prevent this in prod. |
84 |
| - if (environment !== 'production') { |
85 |
| - logger.warning('Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).', context); |
86 |
| - // Attach a dummy auth object to indicate bypass for potential downstream checks. |
87 |
| - req.auth = { devMode: true, warning: 'Auth bypassed due to missing secret key' }; |
88 |
| - return next(); // Proceed without authentication. |
| 69 | + if (environment !== "production") { |
| 70 | + logger.warning( |
| 71 | + "Bypassing JWT authentication: MCP_AUTH_SECRET_KEY is not set (DEVELOPMENT ONLY).", |
| 72 | + context, |
| 73 | + ); |
| 74 | + // Populate req.auth strictly according to SDK's AuthInfo |
| 75 | + req.auth = { |
| 76 | + token: "dev-mode-placeholder-token", |
| 77 | + clientId: "dev-client-id", |
| 78 | + scopes: ["dev-scope"], |
| 79 | + }; |
| 80 | + // Log dev mode details separately, not attaching to req.auth if not part of AuthInfo |
| 81 | + logger.debug("Dev mode auth object created.", { |
| 82 | + ...context, |
| 83 | + authDetails: req.auth, |
| 84 | + }); |
| 85 | + return next(); |
89 | 86 | } else {
|
90 |
| - // Defensive coding: Should be caught by startup check, but handle anyway. |
91 |
| - logger.error('FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.', context); |
92 |
| - // Send a server error response as this indicates a critical configuration issue. |
93 |
| - res.status(500).json({ error: 'Server configuration error: Authentication key missing.' }); |
94 |
| - return; // Halt processing. |
| 87 | + logger.error( |
| 88 | + "FATAL: MCP_AUTH_SECRET_KEY is missing in production. Cannot bypass auth.", |
| 89 | + context, |
| 90 | + ); |
| 91 | + res.status(500).json({ |
| 92 | + error: "Server configuration error: Authentication key missing.", |
| 93 | + }); |
| 94 | + return; |
95 | 95 | }
|
96 | 96 | }
|
97 | 97 |
|
98 |
| - // --- Standard JWT Bearer Token Verification --- |
99 | 98 | const authHeader = req.headers.authorization;
|
100 |
| - logger.debug(`Authorization header present: ${!!authHeader}`, context); |
101 |
| - |
102 |
| - // Check for the presence and correct format ('Bearer <token>') of the Authorization header. |
103 |
| - if (!authHeader || !authHeader.startsWith('Bearer ')) { |
104 |
| - logger.warning('Authentication failed: Missing or malformed Authorization header (Bearer scheme required).', context); |
105 |
| - // Respond with 401 Unauthorized as per RFC 6750 (Bearer Token Usage). |
106 |
| - res.status(401).json({ error: 'Unauthorized: Missing or invalid authentication token format.' }); |
107 |
| - return; // Halt processing. |
| 99 | + if (!authHeader || !authHeader.startsWith("Bearer ")) { |
| 100 | + logger.warning( |
| 101 | + "Authentication failed: Missing or malformed Authorization header (Bearer scheme required).", |
| 102 | + context, |
| 103 | + ); |
| 104 | + res.status(401).json({ |
| 105 | + error: "Unauthorized: Missing or invalid authentication token format.", |
| 106 | + }); |
| 107 | + return; |
108 | 108 | }
|
109 | 109 |
|
110 |
| - // Extract the token part from the "Bearer <token>" string. |
111 |
| - const token = authHeader.split(' ')[1]; |
112 |
| - // Avoid logging the token itself for security reasons. |
113 |
| - logger.debug('Extracted token from Bearer header.', context); |
114 |
| - |
115 |
| - // Check if a token was actually present after the split. |
116 |
| - if (!token) { |
117 |
| - logger.warning('Authentication failed: Token missing after Bearer split (Malformed header).', context); |
118 |
| - res.status(401).json({ error: 'Unauthorized: Malformed authentication token.' }); |
119 |
| - return; // Halt processing. |
| 110 | + const tokenParts = authHeader.split(" "); |
| 111 | + if (tokenParts.length !== 2 || tokenParts[0] !== "Bearer" || !tokenParts[1]) { |
| 112 | + logger.warning("Authentication failed: Malformed Bearer token.", context); |
| 113 | + res |
| 114 | + .status(401) |
| 115 | + .json({ error: "Unauthorized: Malformed authentication token." }); |
| 116 | + return; |
120 | 117 | }
|
| 118 | + const rawToken = tokenParts[1]; |
121 | 119 |
|
122 | 120 | try {
|
123 |
| - // Verify the token's signature and expiration using the configured secret key. |
124 |
| - // `jwt.verify` throws errors for invalid signature, expiration, etc. |
125 |
| - const decoded = jwt.verify(token, config.security.mcpAuthSecretKey); |
126 |
| - // Avoid logging the decoded payload directly unless necessary for specific debugging, |
127 |
| - // as it might contain sensitive information. |
128 |
| - logger.debug('JWT verified successfully.', { ...context }); |
| 121 | + const decoded = jwt.verify(rawToken, config.security.mcpAuthSecretKey); |
| 122 | + |
| 123 | + if (typeof decoded === "string") { |
| 124 | + logger.warning( |
| 125 | + "Authentication failed: JWT decoded to a string, expected an object payload.", |
| 126 | + context, |
| 127 | + ); |
| 128 | + res |
| 129 | + .status(401) |
| 130 | + .json({ error: "Unauthorized: Invalid token payload format." }); |
| 131 | + return; |
| 132 | + } |
| 133 | + |
| 134 | + // Extract and validate fields for SDK's AuthInfo |
| 135 | + const clientIdFromToken = |
| 136 | + typeof decoded.cid === "string" |
| 137 | + ? decoded.cid |
| 138 | + : typeof decoded.client_id === "string" |
| 139 | + ? decoded.client_id |
| 140 | + : undefined; |
| 141 | + if (!clientIdFromToken) { |
| 142 | + logger.warning( |
| 143 | + "Authentication failed: JWT 'cid' or 'client_id' claim is missing or not a string.", |
| 144 | + { ...context, jwtPayloadKeys: Object.keys(decoded) }, |
| 145 | + ); |
| 146 | + res.status(401).json({ |
| 147 | + error: "Unauthorized: Invalid token, missing client identifier.", |
| 148 | + }); |
| 149 | + return; |
| 150 | + } |
| 151 | + |
| 152 | + let scopesFromToken: string[]; |
| 153 | + if ( |
| 154 | + Array.isArray(decoded.scp) && |
| 155 | + decoded.scp.every((s) => typeof s === "string") |
| 156 | + ) { |
| 157 | + scopesFromToken = decoded.scp as string[]; |
| 158 | + } else if ( |
| 159 | + typeof decoded.scope === "string" && |
| 160 | + decoded.scope.trim() !== "" |
| 161 | + ) { |
| 162 | + scopesFromToken = decoded.scope.split(" ").filter((s) => s); |
| 163 | + if (scopesFromToken.length === 0 && decoded.scope.trim() !== "") { |
| 164 | + // handles case " " -> [""] |
| 165 | + scopesFromToken = [decoded.scope.trim()]; |
| 166 | + } else if (scopesFromToken.length === 0 && decoded.scope.trim() === "") { |
| 167 | + // If scope is an empty string, treat as no scopes rather than erroring, or use a default. |
| 168 | + // Depending on strictness, could also error here. For now, allow empty array if scope was empty string. |
| 169 | + logger.debug( |
| 170 | + "JWT 'scope' claim was an empty string, resulting in empty scopes array.", |
| 171 | + context, |
| 172 | + ); |
| 173 | + } |
| 174 | + } else { |
| 175 | + // If scopes are strictly mandatory and not found or invalid format |
| 176 | + logger.warning( |
| 177 | + "Authentication failed: JWT 'scp' or 'scope' claim is missing, not an array of strings, or not a valid space-separated string. Assigning default empty array.", |
| 178 | + { ...context, jwtPayloadKeys: Object.keys(decoded) }, |
| 179 | + ); |
| 180 | + scopesFromToken = []; // Default to empty array if scopes are mandatory but not found/invalid |
| 181 | + // Or, if truly mandatory and must be non-empty: |
| 182 | + // res.status(401).json({ error: "Unauthorized: Invalid token, missing or invalid scopes." }); |
| 183 | + // return; |
| 184 | + } |
129 | 185 |
|
130 |
| - // Attach the decoded payload (which can be an object or string based on JWT content) |
131 |
| - // to the request object (`req.auth`) for use in subsequent middleware or route handlers |
132 |
| - // (e.g., for fine-grained authorization checks based on payload claims like user ID or scopes). |
133 |
| - req.auth = decoded; |
| 186 | + // Construct req.auth with only the properties defined in SDK's AuthInfo |
| 187 | + // All other claims from 'decoded' are not part of req.auth for type safety. |
| 188 | + req.auth = { |
| 189 | + token: rawToken, |
| 190 | + clientId: clientIdFromToken, |
| 191 | + scopes: scopesFromToken, |
| 192 | + }; |
134 | 193 |
|
135 |
| - // Authentication successful, proceed to the next middleware or the main route handler. |
| 194 | + // Log separately if other JWT claims like 'sub' (sessionId) are needed for app logic |
| 195 | + const subClaimForLogging = |
| 196 | + typeof decoded.sub === "string" ? decoded.sub : undefined; |
| 197 | + logger.debug("JWT verified successfully. AuthInfo attached to request.", { |
| 198 | + ...context, |
| 199 | + mcpSessionIdContext: subClaimForLogging, |
| 200 | + clientId: req.auth.clientId, |
| 201 | + scopes: req.auth.scopes, |
| 202 | + }); |
136 | 203 | next();
|
137 | 204 | } catch (error: unknown) {
|
138 |
| - // Handle errors thrown by `jwt.verify`. |
139 |
| - let errorMessage = 'Invalid token'; // Default error message. |
| 205 | + let errorMessage = "Invalid token"; |
140 | 206 | if (error instanceof jwt.TokenExpiredError) {
|
141 |
| - // Specific error for expired tokens. |
142 |
| - errorMessage = 'Token expired'; |
143 |
| - // After instanceof check, 'error' is typed as TokenExpiredError |
144 |
| - logger.warning('Authentication failed: Token expired.', { ...context, expiredAt: error.expiredAt }); // Log specific details here |
| 207 | + errorMessage = "Token expired"; |
| 208 | + logger.warning("Authentication failed: Token expired.", { |
| 209 | + ...context, |
| 210 | + expiredAt: error.expiredAt, |
| 211 | + }); |
145 | 212 | } else if (error instanceof jwt.JsonWebTokenError) {
|
146 |
| - // General JWT errors (e.g., invalid signature, malformed token). |
147 |
| - // After instanceof check, 'error' is typed as JsonWebTokenError |
148 |
| - errorMessage = `Invalid token: ${error.message}`; // Include specific JWT error message |
149 |
| - logger.warning(`Authentication failed: ${errorMessage}`, { ...context }); // Log specific details here |
| 213 | + errorMessage = `Invalid token: ${error.message}`; |
| 214 | + logger.warning(`Authentication failed: ${errorMessage}`, { ...context }); |
150 | 215 | } else if (error instanceof Error) {
|
151 |
| - // Handle other standard JavaScript errors |
152 |
| - errorMessage = `Verification error: ${error.message}`; |
153 |
| - logger.error('Authentication failed: Unexpected error during token verification.', { ...context, error: error.message }); // Log specific details here |
| 216 | + errorMessage = `Verification error: ${error.message}`; |
| 217 | + logger.error( |
| 218 | + "Authentication failed: Unexpected error during token verification.", |
| 219 | + { ...context, error: error.message }, |
| 220 | + ); |
154 | 221 | } else {
|
155 |
| - // Handle non-Error exceptions |
156 |
| - errorMessage = 'Unknown verification error'; |
157 |
| - logger.error('Authentication failed: Unexpected non-error exception during token verification.', { ...context, error }); |
| 222 | + errorMessage = "Unknown verification error"; |
| 223 | + logger.error( |
| 224 | + "Authentication failed: Unexpected non-error exception during token verification.", |
| 225 | + { ...context, error }, |
| 226 | + ); |
158 | 227 | }
|
159 |
| - // Respond with 401 Unauthorized for any token validation failure. |
160 | 228 | res.status(401).json({ error: `Unauthorized: ${errorMessage}.` });
|
161 |
| - // Do not call next() - halt processing for this request. |
162 | 229 | }
|
163 | 230 | }
|
0 commit comments