UNPKG

@hellocoop/admin-mcp

Version:

Model Context Protocol (MCP) for Hellō Admin API.

346 lines (304 loc) 9.87 kB
// JWT validation module for MCP server // Validates JWT tokens without signature verification // Checks scope, issuer, audience, and expiration import jwt from 'jsonwebtoken' import { HELLO_DOMAIN, HELLO_ISSUER, HELLO_AUDIENCE } from './config.js'; const DOMAIN = HELLO_DOMAIN; // Expected values for JWT validation const EXPECTED_ISSUER = HELLO_ISSUER; const EXPECTED_AUDIENCE = HELLO_AUDIENCE; const REQUIRED_SCOPE = 'mcp' /** * Validates a JWT token without signature verification * @param {string} token - The JWT token to validate * @returns {Object} - Validation result with success/error details */ export const validateJWT = (token) => { try { // Decode token without verification const decoded = jwt.decode(token, { complete: true }) if (!decoded || !decoded.payload) { return { valid: false, error: 'invalid_token', error_description: 'Token is malformed or cannot be decoded' } } const { header, payload } = decoded // Basic header validation if (!header || header.typ !== 'JWT') { return { valid: false, error: 'invalid_token', error_description: 'Token type must be JWT' } } // Issuer validation if (!payload?.iss || payload.iss !== EXPECTED_ISSUER) { return { valid: false, error: 'invalid_token', error_description: `Invalid issuer. Expected: ${EXPECTED_ISSUER}, Got: ${payload.iss || 'none'}` } } // Audience validation if (!payload?.aud || payload.aud !== EXPECTED_AUDIENCE) { return { valid: false, error: 'invalid_token', error_description: `Invalid audience. Expected: ${EXPECTED_AUDIENCE}, Got: ${payload.aud || 'none'}` } } // Expiration validation const now = Math.floor(Date.now() / 1000) if (!payload.exp) { return { valid: false, error: 'invalid_token', error_description: 'Token missing expiration time' } } if (payload.exp <= now) { return { valid: false, error: 'invalid_token', error_description: 'Token has expired' } } // Scope validation if (!payload.scope || !Array.isArray(payload.scope)) { return { valid: false, error: 'insufficient_scope', error_description: 'Token missing scope' } } if (!payload.scope.includes(REQUIRED_SCOPE)) { return { valid: false, error: 'insufficient_scope', error_description: `Token missing required scope: ${REQUIRED_SCOPE}` } } // Basic required claims validation if (!payload.sub) { return { valid: false, error: 'invalid_token', error_description: 'Token missing subject (sub) claim' } } if (!payload.jti) { return { valid: false, error: 'invalid_token', error_description: 'Token missing JWT ID (jti) claim' } } // All validations passed return { valid: true, payload: payload, tokenInfo: { jti: payload.jti, sub: payload.sub, aud: payload.aud, iss: payload.iss, scope: payload.scope, exp: payload.exp, iat: payload.iat } } } catch (error) { return { valid: false, error: 'invalid_token', error_description: `Token validation error: ${error.message}` } } } /** * Creates a properly formatted WWW-Authenticate header * @param {Object} validationResult - Result from validateJWT * @returns {string} - Formatted WWW-Authenticate header value */ export const createWWWAuthenticateHeader = (validationResult) => { const realm = 'Hello MCP Server' const resourceMetadata = `https://admin-mcp.${DOMAIN}/.well-known/oauth-protected-resource` let headerParts = [ `realm="${realm}"`, `error="${validationResult.error}"`, `error_description="${validationResult.error_description}"`, `scope=mcp`, // MCP servers must access for `mcp` scope, but access_token has `quickstart` scope `resource_metadata="${resourceMetadata}"` ] return `Bearer ${headerParts.join(', ')}` } /** * Creates a standardized authentication error response * @param {Object} validationResult - Result from validateJWT * @param {string} requestId - Request ID for JSON-RPC response * @returns {Object} - Standardized error response */ export const createAuthErrorResponse = (validationResult, requestId = null) => { const statusCode = validationResult.error === 'insufficient_scope' ? 403 : 401 const wwwAuthenticate = createWWWAuthenticateHeader(validationResult) return { httpStatus: statusCode, wwwAuthenticate: wwwAuthenticate, jsonRpcResponse: { jsonrpc: '2.0', error: { code: validationResult.error === 'insufficient_scope' ? -32003 : -32001, message: validationResult.error === 'insufficient_scope' ? 'Insufficient scope' : 'Authentication required', data: { error: validationResult.error, error_description: validationResult.error_description } }, id: requestId } } } /** * Fastify plugin to validate JWT tokens * @param {Object} fastify - Fastify instance */ export const jwtValidationPlugin = async (fastify) => { fastify.addHook('onRequest', async (request, reply) => { // Skip validation for non-MCP endpoints if (request.routerPath !== '/' && request.url !== '/') { return } // Skip validation for GET requests (documentation redirects) if (request.method === 'GET') { return } // Extract Bearer token const authHeader = request.headers.authorization if (!authHeader) { const wwwAuthenticate = createWWWAuthenticateHeader({ error: 'invalid_request', error_description: 'Authorization header required' }) reply.code(401) reply.header('WWW-Authenticate', wwwAuthenticate) return reply.send({ jsonrpc: '2.0', error: { code: -32001, message: 'Authentication required', data: { error: 'invalid_request', error_description: 'Authorization header required' } }, id: request.body?.id || null }) } const bearerMatch = authHeader.match(/^bearer\s+(.+)$/i) if (!bearerMatch) { const wwwAuthenticate = createWWWAuthenticateHeader({ error: 'invalid_request', error_description: 'Bearer token required' }) reply.code(401) reply.header('WWW-Authenticate', wwwAuthenticate) return reply.send({ jsonrpc: '2.0', error: { code: -32001, message: 'Authentication required', data: { error: 'invalid_request', error_description: 'Bearer token required' } }, id: request.body?.id || null }) } const token = bearerMatch[1].trim() const validationResult = validateJWT(token) if (!validationResult.valid) { const errorResponse = createAuthErrorResponse(validationResult, request.body?.id) reply.code(errorResponse.httpStatus) reply.header('WWW-Authenticate', errorResponse.wwwAuthenticate) return reply.send(errorResponse.jsonRpcResponse) } // Add validated payload to request for logging and access request.jwtPayload = validationResult.payload request.tokenInfo = validationResult.tokenInfo }) } /** * Legacy Express middleware for backward compatibility * @param {Object} req - Express request object * @param {Object} res - Express response object * @param {Function} next - Express next function */ export const jwtValidationMiddleware = (req, res, next) => { console.warn('jwtValidationMiddleware is deprecated, use jwtValidationPlugin for Fastify instead') // Skip validation for non-MCP endpoints if (req.path !== '/') { return next() } // Skip validation for GET requests (documentation redirects) if (req.method === 'GET') { return next() } // Extract Bearer token const authHeader = req.headers.authorization if (!authHeader) { const wwwAuthenticate = createWWWAuthenticateHeader({ error: 'invalid_request', error_description: 'Authorization header required' }) res.status(401) res.header('WWW-Authenticate', wwwAuthenticate) return res.json({ jsonrpc: '2.0', error: { code: -32001, message: 'Authentication required', data: { error: 'invalid_request', error_description: 'Authorization header required' } }, id: req.body?.id || null }) } const bearerMatch = authHeader.match(/^bearer\s+(.+)$/i) if (!bearerMatch) { const wwwAuthenticate = createWWWAuthenticateHeader({ error: 'invalid_request', error_description: 'Bearer token required' }) res.status(401) res.header('WWW-Authenticate', wwwAuthenticate) return res.json({ jsonrpc: '2.0', error: { code: -32001, message: 'Authentication required', data: { error: 'invalid_request', error_description: 'Bearer token required' } }, id: req.body?.id || null }) } const token = bearerMatch[1].trim() const validationResult = validateJWT(token) if (!validationResult.valid) { const errorResponse = createAuthErrorResponse(validationResult, req.body?.id) res.status(errorResponse.httpStatus) res.header('WWW-Authenticate', errorResponse.wwwAuthenticate) return res.json(errorResponse.jsonRpcResponse) } // Add validated payload to request for logging and access req.jwtPayload = validationResult.payload req.tokenInfo = validationResult.tokenInfo next() }