Skip to content

Commit 10013c6

Browse files
committed
refactor(auth): align authMiddleware with SDK AuthInfo and fix httpTransport type issue
- Refactors `authMiddleware.ts` to conform to the MCP SDK's `AuthInfo` type. This involves updated JWT claim handling for `clientId` (from `cid` or `client_id`) and `scopes` (from `scp` or `scope`). Missing `clientId` is now an error, and invalid/missing `scopes` default to an empty array. Logging and JSDoc have also been improved. - Adds a workaround in `httpTransport.ts` to sanitize `req.auth` for SDK compatibility by setting it to undefined if it's a string or devMode object, addressing potential type mismatches when passing to the SDK's `transport.handleRequest`.
1 parent fe04e26 commit 10013c6

File tree

2 files changed

+198
-113
lines changed

2 files changed

+198
-113
lines changed
Lines changed: 180 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,230 @@
11
/**
2-
* MCP Authentication Middleware: Bearer Token Validation (JWT).
2+
* @fileoverview MCP Authentication Middleware for Bearer Token Validation (JWT).
33
*
44
* This middleware validates JSON Web Tokens (JWT) passed via the 'Authorization' header
55
* using the 'Bearer' scheme (e.g., "Authorization: Bearer <your_token>").
66
* 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`).
88
*
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`.
1111
* If the token is missing, invalid, or expired, it sends an HTTP 401 Unauthorized response.
1212
*
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-
*
3413
* @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
3515
*/
3616

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";
4222

4323
// 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.
4525
declare global {
4626
// eslint-disable-next-line @typescript-eslint/no-namespace
4727
namespace Express {
4828
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;
5131
}
5232
}
5333
}
5434

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+
);
6243
} 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+
);
6547
}
6648

6749
/**
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.
7451
*/
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+
);
7966

80-
// --- Development Mode Bypass ---
81-
// If the secret key is missing (and not in production), bypass authentication.
67+
// Development Mode Bypass
8268
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();
8986
} 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;
9595
}
9696
}
9797

98-
// --- Standard JWT Bearer Token Verification ---
9998
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;
108108
}
109109

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;
120117
}
118+
const rawToken = tokenParts[1];
121119

122120
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+
}
129185

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+
};
134193

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+
});
136203
next();
137204
} catch (error: unknown) {
138-
// Handle errors thrown by `jwt.verify`.
139-
let errorMessage = 'Invalid token'; // Default error message.
205+
let errorMessage = "Invalid token";
140206
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+
});
145212
} 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 });
150215
} 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+
);
154221
} 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+
);
158227
}
159-
// Respond with 401 Unauthorized for any token validation failure.
160228
res.status(401).json({ error: `Unauthorized: ${errorMessage}.` });
161-
// Do not call next() - halt processing for this request.
162229
}
163230
}

src/mcp-server/transports/httpTransport.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,15 @@ export async function startHttpTransport(
379379
logger.debug(`Processing POST request content for session ${currentSessionId}...`, { ...basePostContext, sessionId: currentSessionId, isInitReq });
380380
// Delegate the actual handling (parsing, routing, response/SSE generation) to the SDK transport instance.
381381
// The SDK transport handles returning 202 for notification/response-only POSTs internally.
382+
383+
// --- Type modification for req.auth compatibility ---
384+
const tempReqPost = req as any; // Allow modification
385+
if (tempReqPost.auth && (typeof tempReqPost.auth === 'string' || (typeof tempReqPost.auth === 'object' && 'devMode' in tempReqPost.auth))) {
386+
logger.debug('Sanitizing req.auth for SDK compatibility (POST)', { ...basePostContext, sessionId: currentSessionId, originalAuthType: typeof tempReqPost.auth });
387+
tempReqPost.auth = undefined;
388+
}
389+
// --- End modification ---
390+
382391
await transport.handleRequest(req, res, req.body);
383392
logger.debug(`Finished processing POST request content for session ${currentSessionId}.`, { ...basePostContext, sessionId: currentSessionId });
384393

@@ -435,6 +444,15 @@ export async function startHttpTransport(
435444
// MCP Spec (GET): Client SHOULD include Last-Event-ID for resumption. Resumption handling depends on SDK transport.
436445
// MCP Spec (DELETE): Client SHOULD send DELETE to terminate. Server MAY respond 405 if not supported.
437446
// This implementation supports DELETE via the SDK transport's handleRequest.
447+
448+
// --- Type modification for req.auth compatibility ---
449+
const tempReqSession = req as any; // Allow modification
450+
if (tempReqSession.auth && (typeof tempReqSession.auth === 'string' || (typeof tempReqSession.auth === 'object' && 'devMode' in tempReqSession.auth))) {
451+
logger.debug(`Sanitizing req.auth for SDK compatibility (${method})`, { ...baseSessionReqContext, sessionId, originalAuthType: typeof tempReqSession.auth });
452+
tempReqSession.auth = undefined;
453+
}
454+
// --- End modification ---
455+
438456
await transport.handleRequest(req, res);
439457
logger.info(`Successfully handled ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId });
440458
// Note: For DELETE, the transport's handleRequest should trigger the 'onclose' handler for cleanup.

0 commit comments

Comments
 (0)