All files / src/middleware oauth-auth.ts

100% Statements 132/132
100% Branches 55/55
100% Functions 25/25
100% Lines 122/122

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361                              48x 48x 48x 48x 48x 48x 48x           48x   48x 48x                       48x         42x 42x 15x             77x 77x 2x       75x 75x 24x     51x 1x 1x               50x   50x 1x       49x 49x 5x     44x 3x 3x       41x   41x 41x     2x 2x 2x                 39x 3x       36x   1x     35x 2x         33x 33x   3x 3x 2x 2x               1x 1x               30x 1x                       29x 29x 29x 29x 29x   29x 29x 29x   29x   29x           29x                 48x 17x 35x 35x 14x                         48x         10x 10x   1x 1x     9x 9x   2x 2x     7x 7x 1x 1x       6x 6x   1x 1x       5x 5x     1x 1x 1x   4x         3x 3x       1x 1x 1x 1x 1x   1x 1x 1x 1x   1x                     49x               66x       36x 36x 36x 56x           2x         2x       2x       2x                         40x             40x             40x 40x                 40x 40x       40x    
/**
 * OAuth Authentication Middleware
 *
 * Validates Bearer tokens on MCP endpoints and sets up the token context
 * for per-request GitLab API access.
 *
 * This middleware:
 * 1. Extracts and validates the Bearer token from Authorization header
 * 2. Verifies the JWT signature and expiration
 * 3. Loads the associated session
 * 4. Refreshes GitLab token if needed
 * 5. Sets up token context for the request
 */
 
import { Request, Response, NextFunction, RequestHandler } from 'express';
import { loadOAuthConfig } from '../oauth/config';
import { sessionStore } from '../oauth/session-store';
import { isPastAgeLimit } from '../oauth/storage/types';
import { verifyMCPToken } from '../oauth/token-utils';
import { withFreshGitLabToken, GitLabGrantRevokedError } from '../oauth/gitlab-token-refresh';
import { getBaseUrl } from '../oauth/endpoints/metadata';
import {
  MCP_SCOPES,
  isProtectedResource,
  resourceForPath,
  resourceMetadataUrl,
} from '../oauth/resource';
import { logWarn, logError, logDebug, truncateId } from '../logger';
import { MCPTokenPayload, OAuthErrorResponse } from '../oauth/types';
import { getMinimalRequestContext } from '../utils/request-logger';
import { GITLAB_BASE_URL } from '../config';
 
/**
 * OAuth authentication middleware for Express
 *
 * Apply this middleware to routes that require OAuth authentication.
 * It validates the Bearer token and sets up the token context.
 *
 * @param req - Express request
 * @param res - Express response
 * @param next - Express next function
 */
export async function oauthAuthMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): Promise<void> {
  const reject = await authenticateRequest(req, res);
  if (reject) reject();
  else next();
}
 
/** Authenticate once; defer error responses until the caller has applied its quota.
 * A signed token still needs a session lookup to detect revocation before choosing its quota.
 */
async function authenticateRequest(req: Request, res: Response): Promise<(() => void) | undefined> {
  const config = loadOAuthConfig();
  if (!config) {
    return () => sendUnauthorized(req, res, 'server_error', 'OAuth not configured');
  }
 
  // Extract Bearer token from Authorization header
  const authHeader = req.headers.authorization;
  if (!authHeader) {
    return () => sendUnauthorized(req, res, 'unauthorized', 'Missing Authorization header');
  }
 
  if (!authHeader.startsWith('Bearer ')) {
    return () =>
      sendUnauthorized(
        req,
        res,
        'unauthorized',
        'Invalid Authorization header format. Expected: Bearer <token>',
      );
  }
 
  const token = authHeader.slice(7); // Remove "Bearer " prefix
 
  if (!token) {
    return () => sendUnauthorized(req, res, 'unauthorized', 'Empty Bearer token');
  }
 
  // Verify JWT token
  const payload = verifyMCPToken(token, config.sessionSecret);
  if (!payload) {
    return () => sendUnauthorized(req, res, 'invalid_token', 'Token is invalid or expired');
  }
 
  if (!isIssuedForThisServer(config.issuer, payload)) {
    return () =>
      sendUnauthorized(req, res, 'invalid_token', 'Token was not issued for this server');
  }
 
  // Get session from token
  const sessionId = payload.sid;
  let session;
  try {
    session = await sessionStore.getSession(sessionId);
  } catch (error: unknown) {
    // Storage outage: the credentials cannot be verified, which is not a bad token.
    logError('Failed to load OAuth session', { err: error as Error });
    return () => {
      res.status(503).json({
        error: 'temporarily_unavailable',
        error_description: 'Session storage is unavailable',
      });
    };
  }
 
  // A session past its age limit stays stored until cleanup ends its grants, and an access
  // token can outlive that limit; it authenticates nothing either way.
  if (!session || isPastAgeLimit(session)) {
    return () => sendUnauthorized(req, res, 'invalid_token', 'Session not found or expired');
  }
 
  // Verify token matches session
  if (session.mcpAccessToken !== token) {
    // Token might have been rotated
    return () => sendUnauthorized(req, res, 'invalid_token', 'Token has been superseded');
  }
 
  if (!hasGrantedScope(payload.scope, session.scopes)) {
    return () => sendInsufficientScope(req, res);
  }
 
  // Refresh GitLab token if it's expiring soon (5 minute buffer)
  let updatedSession;
  try {
    updatedSession = await withFreshGitLabToken(session, config);
  } catch (error: unknown) {
    logError('Failed to refresh GitLab token during request', { err: error as Error });
    if (error instanceof GitLabGrantRevokedError) {
      return () =>
        sendUnauthorized(
          req,
          res,
          'invalid_token',
          'GitLab token refresh failed. Please re-authenticate.',
        );
    } else {
      // A GitLab outage is not a bad token: the account stays linked, the client retries.
      return () => {
        res.status(503).json({
          error: 'temporarily_unavailable',
          error_description: 'GitLab is temporarily unavailable',
        });
      };
    }
  }
 
  if (!updatedSession) {
    return () => sendUnauthorized(req, res, 'invalid_token', 'Session lost during token refresh');
  }
 
  // Store OAuth session info in res.locals for route handlers
  // This is used by:
  // 1. Transport handlers to associate MCP sessions with OAuth sessions
  // 2. Route handlers to set up token context around transport.handleRequest()
  //
  // NOTE: We do NOT use runWithTokenContext here because middleware's next() chain
  // breaks AsyncLocalStorage propagation to MCP SDK's internal handlers.
  // Instead, route handlers must wrap transport.handleRequest() with runWithTokenContext()
  // using the data stored here.
  res.locals.oauthSessionId = updatedSession.id;
  res.locals.gitlabToken = updatedSession.gitlabAccessToken;
  res.locals.gitlabUserId = updatedSession.gitlabUserId;
  res.locals.gitlabUsername = updatedSession.gitlabUsername;
  res.locals.gitlabScopes = updatedSession.gitlabScopes;
  // Multi-instance support: use session's API URL or fallback to global config
  res.locals.gitlabApiUrl = updatedSession.gitlabApiUrl ?? GITLAB_BASE_URL;
  res.locals.instanceLabel = updatedSession.instanceLabel;
  res.locals.mcpResource = resourceForPath(config.issuer, req.path);
  // Tool dispatch requires mcp:tools among them.
  res.locals.mcpScopes = tokenScopes(payload.scope);
 
  logDebug('OAuth session validated, passing to route handler', {
    sessionId: truncateId(updatedSession.id),
    method: req.method,
    path: req.path,
  });
 
  return undefined;
}
 
/**
 * Create OAuth middleware for specific routes
 *
 * Apply the request quota after OAuth classification, including rejected requests.
 * A live transport ID does not authenticate a caller; the quota uses verified OAuth locals.
 */
export function createOAuthMiddleware(rateLimit: RequestHandler): RequestHandler {
  return async (req, res, next): Promise<void> => {
    const reject = await authenticateRequest(req, res);
    if (reject) return rateLimit(req, res, reject);
    return rateLimit(req, res, next);
  };
}
 
/**
 * Optional OAuth middleware
 *
 * Like oauthAuthMiddleware, but doesn't require authentication.
 * If a valid token is provided, sets up res.locals with session info.
 * If no token or invalid token, continues without setting res.locals.
 *
 * Useful for endpoints that work with or without authentication.
 */
export async function optionalOAuthMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): Promise<void> {
  const config = loadOAuthConfig();
  if (!config) {
    // OAuth not configured, continue without context
    next();
    return;
  }
 
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    // No token provided, continue without context
    next();
    return;
  }
 
  const token = authHeader.slice(7);
  if (!token) {
    next();
    return;
  }
 
  // Try to validate token
  const payload = verifyMCPToken(token, config.sessionSecret);
  if (!payload || !isIssuedForThisServer(config.issuer, payload)) {
    // Invalid token, but this is optional auth, so continue
    next();
    return;
  }
 
  let session;
  try {
    session = await sessionStore.getSession(payload.sid);
  } catch (error: unknown) {
    // Optional auth: an unreadable session store means no authenticated context.
    logError('Failed to load OAuth session', { err: error as Error });
    next();
    return;
  }
  if (
    session?.mcpAccessToken !== token ||
    isPastAgeLimit(session) ||
    !hasGrantedScope(payload.scope, session.scopes)
  ) {
    next();
    return;
  }
 
  // Valid token - store session info in res.locals for route handler
  res.locals.oauthSessionId = session.id;
  res.locals.gitlabToken = session.gitlabAccessToken;
  res.locals.gitlabUserId = session.gitlabUserId;
  res.locals.gitlabUsername = session.gitlabUsername;
  res.locals.gitlabScopes = session.gitlabScopes;
  // Multi-instance support: use session's API URL or fallback to global config
  res.locals.gitlabApiUrl = session.gitlabApiUrl ?? GITLAB_BASE_URL;
  res.locals.instanceLabel = session.instanceLabel;
  res.locals.mcpResource = resourceForPath(config.issuer, req.path);
  res.locals.mcpScopes = tokenScopes(payload.scope);
 
  next();
}
 
/**
 * `iss` is our issuer and `aud` one of our resources (RFC 9068 section 4). Either resource
 * is accepted on either path on purpose: the root and `/mcp` are the same server with the
 * same tools and grants, so audience binding guards against tokens of other resource
 * servers, not between these two. Clients also legitimately cross them: ChatGPT requests
 * tokens for the root resource and calls `/mcp`.
 */
function isIssuedForThisServer(issuer: string, payload: MCPTokenPayload): boolean {
  return payload.iss === issuer && isProtectedResource(issuer, payload.aud);
}
 
/**
 * The token carries at least one MCP scope and nothing beyond the session's grant, so a
 * token minted before a narrower grant cannot outlive it.
 */
function tokenScopes(tokenScope: string): string[] {
  return tokenScope.split(' ').filter(Boolean);
}
 
function hasGrantedScope(tokenScope: string, grantedScopes: string[]): boolean {
  const scopes = tokenScopes(tokenScope);
  return (
    scopes.some((scope) => MCP_SCOPES.includes(scope)) &&
    scopes.every((scope) => grantedScopes.includes(scope))
  );
}
 
/** RFC 6750 section 3.1: a valid token without the needed scope gets 403. */
function sendInsufficientScope(req: Request, res: Response): void {
  logWarn('Authentication rejected', {
    event: 'auth_rejected',
    ...getMinimalRequestContext(req),
    reason: 'insufficient_scope',
  });
  res.setHeader(
    'WWW-Authenticate',
    `Bearer realm="gitlab-mcp", error="insufficient_scope", scope="${MCP_SCOPES.join(' ')}"`,
  );
  const response: OAuthErrorResponse = {
    error: 'insufficient_scope',
    error_description: 'Token lacks the scope this server requires',
  };
  res.status(403).json(response);
}
 
/**
 * Send unauthorized response with OAuth error format
 *
 * Includes WWW-Authenticate header with resource parameter (RFC 9470)
 * to help clients discover the authorization server.
 *
 * Also logs the auth rejection for debugging and security monitoring.
 */
function sendUnauthorized(req: Request, res: Response, error: string, description: string): void {
  // Log auth rejection with structured context
  logWarn('Authentication rejected', {
    event: 'auth_rejected',
    ...getMinimalRequestContext(req),
    reason: error,
    description,
  });
 
  const response: OAuthErrorResponse = {
    error,
    error_description: description,
  };
 
  // resource_metadata points at the metadata of the endpoint that was called (RFC 9728
  // section 5.1), built from the configured issuer rather than request headers.
  const config = loadOAuthConfig();
  const metadataUrl = config
    ? resourceMetadataUrl(resourceForPath(config.issuer, req.path))
    : `${getBaseUrl(req)}/.well-known/oauth-protected-resource`;
 
  // Set WWW-Authenticate header with resource_metadata parameter
  // Points to Protected Resource Metadata document per MCP spec. A rejected token also
  // names the error (RFC 6750 section 3); a request without credentials does not
  // (RFC 6750 section 3.1).
  const tokenError =
    error === 'invalid_token' ? `, error="invalid_token", error_description="${description}"` : '';
  res.setHeader(
    'WWW-Authenticate',
    `Bearer realm="gitlab-mcp", resource_metadata="${metadataUrl}"${tokenError}`,
  );
  res.status(401).json(response);
}