feat: mcp with oauth

This commit is contained in:
Nevo David 2026-04-03 19:17:19 +07:00
parent 507a006b9f
commit ac109bf564
3 changed files with 432 additions and 0 deletions

View file

@ -0,0 +1,251 @@
/**
* OAuth Middleware for MCP Server
*
* Implements OAuth 2.0 Protected Resource support per RFC 9728 for MCP servers.
* Based on Mastra's implementation at commit 27c37ca.
*
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
* @see https://www.rfc-editor.org/rfc/rfc9728.html
*/
import type * as http from 'node:http';
import type { MCPServerOAuthConfig, TokenValidationResult } from './oauth-types';
import {
generateProtectedResourceMetadata,
generateWWWAuthenticateHeader,
extractBearerToken,
} from './oauth-types';
interface OAuthMiddlewareLogger {
debug?: (message: string, ...args: unknown[]) => void;
}
export interface OAuthMiddlewareOptions {
oauth: MCPServerOAuthConfig;
mcpPath?: string;
logger?: OAuthMiddlewareLogger;
}
export interface OAuthMiddlewareResult {
proceed: boolean;
handled: boolean;
tokenValidation?: TokenValidationResult;
}
export function createOAuthMiddleware(options: OAuthMiddlewareOptions) {
const { oauth, mcpPath = '/mcp', logger } = options;
const protectedResourceMetadata = generateProtectedResourceMetadata(oauth);
const wellKnownPath = '/.well-known/oauth-protected-resource';
const resourceMetadataUrl = new URL(wellKnownPath, oauth.resource).toString();
return async function oauthMiddleware(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): Promise<OAuthMiddlewareResult> {
logger?.debug?.(`OAuth middleware: ${req.method} ${url.pathname}`);
// Handle Protected Resource Metadata endpoint (RFC 9728)
if (url.pathname === wellKnownPath && req.method === 'GET') {
logger?.debug?.('OAuth middleware: Serving Protected Resource Metadata');
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=3600',
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify(protectedResourceMetadata));
return { proceed: false, handled: true };
}
// Handle CORS preflight for metadata endpoint
if (url.pathname === wellKnownPath && req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
});
res.end();
return { proceed: false, handled: true };
}
// Only protect the MCP endpoint
if (!url.pathname.startsWith(mcpPath)) {
return { proceed: true, handled: false };
}
// Extract and validate bearer token
const authHeader = req.headers['authorization'];
const token = extractBearerToken(authHeader as string | undefined);
if (!token) {
logger?.debug?.('OAuth middleware: No bearer token provided');
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': generateWWWAuthenticateHeader({ resourceMetadataUrl }),
});
res.end(
JSON.stringify({
error: 'unauthorized',
error_description: 'Bearer token required',
}),
);
return { proceed: false, handled: true };
}
// Validate the token
if (oauth.validateToken) {
logger?.debug?.('OAuth middleware: Validating token');
const validationResult = await oauth.validateToken(token, oauth.resource);
if (!validationResult.valid) {
logger?.debug?.(`OAuth middleware: Token validation failed: ${validationResult.error}`);
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': generateWWWAuthenticateHeader({
resourceMetadataUrl,
additionalParams: {
error: validationResult.error || 'invalid_token',
...(validationResult.errorDescription && {
error_description: validationResult.errorDescription,
}),
},
}),
});
res.end(
JSON.stringify({
error: validationResult.error || 'invalid_token',
error_description: validationResult.errorDescription || 'Token validation failed',
}),
);
return { proceed: false, handled: true, tokenValidation: validationResult };
}
logger?.debug?.('OAuth middleware: Token validated successfully');
return { proceed: true, handled: false, tokenValidation: validationResult };
}
// If no validateToken function provided, accept the token
logger?.debug?.('OAuth middleware: No token validation configured, accepting token');
return {
proceed: true,
handled: false,
tokenValidation: { valid: true },
};
};
}
export function createStaticTokenValidator(validTokens: string[]): MCPServerOAuthConfig['validateToken'] {
const tokenSet = new Set(validTokens);
return async (token: string): Promise<TokenValidationResult> => {
if (tokenSet.has(token)) {
return { valid: true, scopes: ['mcp:read', 'mcp:write'] };
}
return {
valid: false,
error: 'invalid_token',
errorDescription: 'Token not recognized',
};
};
}
interface IntrospectionResponse {
active: boolean;
scope?: string;
client_id?: string;
username?: string;
token_type?: string;
exp?: number;
iat?: number;
nbf?: number;
sub?: string;
aud?: string | string[];
iss?: string;
jti?: string;
[key: string]: unknown;
}
export function createIntrospectionValidator(
introspectionEndpoint: string,
clientCredentials?: { clientId: string; clientSecret: string },
): MCPServerOAuthConfig['validateToken'] {
return async (token: string, resource: string): Promise<TokenValidationResult> => {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
};
if (clientCredentials) {
if (clientCredentials.clientId.includes(':')) {
return {
valid: false,
error: 'invalid_request',
errorDescription: 'clientId cannot contain a colon character per RFC 7617',
};
}
const credentials = Buffer.from(`${clientCredentials.clientId}:${clientCredentials.clientSecret}`).toString(
'base64',
);
headers['Authorization'] = `Basic ${credentials}`;
}
const response = await fetch(introspectionEndpoint, {
method: 'POST',
headers,
body: new URLSearchParams({
token,
token_type_hint: 'access_token',
}),
});
if (!response.ok) {
return {
valid: false,
error: 'server_error',
errorDescription: `Introspection failed: ${response.status}`,
};
}
const data = (await response.json()) as IntrospectionResponse;
if (!data.active) {
return {
valid: false,
error: 'invalid_token',
errorDescription: 'Token is not active',
};
}
if (data.aud) {
const audiences = Array.isArray(data.aud) ? data.aud : [data.aud];
if (!audiences.includes(resource)) {
return {
valid: false,
error: 'invalid_token',
errorDescription: 'Token audience does not match this resource',
};
}
}
return {
valid: true,
scopes:
data.scope
?.trim()
.split(' ')
.filter(s => s !== '') || [],
subject: data.sub,
expiresAt: data.exp,
claims: data as Record<string, unknown>,
};
} catch (error) {
return {
valid: false,
error: 'server_error',
errorDescription: error instanceof Error ? error.message : 'Introspection failed',
};
}
};
}

View file

@ -0,0 +1,104 @@
/**
* OAuth Types for MCP Authentication
*
* Standalone types and helpers for OAuth-protected MCP servers.
* Based on Mastra's implementation at commit 27c37ca.
*
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
* @see https://www.rfc-editor.org/rfc/rfc9728.html
*/
import type * as http from 'node:http';
/**
* Configuration for OAuth-protected MCP server.
*/
export interface MCPServerOAuthConfig {
resource: string;
authorizationServers: string[];
scopesSupported?: string[];
resourceName?: string;
resourceDocumentation?: string;
validateToken?: (token: string, resource: string) => Promise<TokenValidationResult>;
}
/**
* Result of token validation.
*/
export interface TokenValidationResult {
valid: boolean;
error?: string;
errorDescription?: string;
scopes?: string[];
subject?: string;
expiresAt?: number;
claims?: Record<string, unknown>;
}
/**
* Options for OAuth-related HTTP responses.
*/
export interface OAuthResponseOptions {
resourceMetadataUrl?: string;
additionalParams?: Record<string, string>;
}
/**
* Protected Resource Metadata per RFC 9728.
*/
export interface OAuthProtectedResourceMetadata {
resource: string;
authorization_servers: string[];
scopes_supported?: string[];
bearer_methods_supported?: string[];
resource_name?: string;
resource_documentation?: string;
}
function escapeHeaderValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function generateWWWAuthenticateHeader(options: OAuthResponseOptions = {}): string {
const params: string[] = [];
if (options.resourceMetadataUrl) {
params.push(`resource_metadata="${escapeHeaderValue(options.resourceMetadataUrl)}"`);
}
if (options.additionalParams) {
for (const [key, value] of Object.entries(options.additionalParams)) {
params.push(`${key}="${escapeHeaderValue(value)}"`);
}
}
if (params.length === 0) {
return 'Bearer';
}
return `Bearer ${params.join(', ')}`;
}
export function generateProtectedResourceMetadata(config: MCPServerOAuthConfig): OAuthProtectedResourceMetadata {
return {
resource: config.resource,
authorization_servers: config.authorizationServers,
scopes_supported: config.scopesSupported ?? ['mcp:read', 'mcp:write'],
bearer_methods_supported: ['header'],
...(config.resourceName && { resource_name: config.resourceName }),
...(config.resourceDocumentation && {
resource_documentation: config.resourceDocumentation,
}),
};
}
export function extractBearerToken(authHeader: string | null | undefined): string | undefined {
if (!authHeader) return undefined;
const prefix = 'bearer ';
if (authHeader.length <= prefix.length) return undefined;
if (authHeader.slice(0, prefix.length).toLowerCase() !== prefix) return undefined;
const token = authHeader.slice(prefix.length).trim();
return token || undefined;
}

View file

@ -6,6 +6,7 @@ import { randomUUID } from 'crypto';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { runWithContext } from './async.storage';
import { createOAuthMiddleware } from './oauth-middleware';
export const startMcp = async (app: INestApplication) => {
const mastraService = app.get(MastraService, { strict: false });
const organizationService = app.get(OrganizationService, { strict: false });
@ -33,6 +34,82 @@ export const startMcp = async (app: INestApplication) => {
const server = new MCPServer(serverConfig);
const oauthMiddleware = createOAuthMiddleware({
oauth: {
resource: new URL('/mcp-oauth', process.env.NEXT_PUBLIC_BACKEND_URL!).toString(),
authorizationServers: [process.env.NEXT_PUBLIC_BACKEND_URL!],
validateToken: async (token: string) => {
const org = await resolveAuth(token);
if (!org) {
return { valid: false, error: 'invalid_token', errorDescription: 'Invalid API Key or OAuth token' };
}
return { valid: true, subject: token };
},
},
mcpPath: '/mcp-oauth',
});
app.use('/.well-known/oauth-protected-resource', async (req: Request, res: Response) => {
const url = new URL('/.well-known/oauth-protected-resource', process.env.NEXT_PUBLIC_BACKEND_URL);
await oauthMiddleware(req, res, url);
});
app.use('/.well-known/oauth-authorization-server', async (req: Request, res: Response) => {
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.writeHead(204);
res.end();
return;
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'max-age=3600');
res.json({
issuer: process.env.NEXT_PUBLIC_BACKEND_URL,
authorization_endpoint: `${process.env.FRONTEND_URL}/oauth/authorize`,
token_endpoint: `${process.env.NEXT_PUBLIC_BACKEND_URL}/oauth/token`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['mcp:read', 'mcp:write'],
});
});
app.use('/mcp-oauth', async (req: Request, res: Response, next: () => void) => {
// Skip if this is the /mcp/:id route
if (req.path !== '/' && req.path !== '') {
next();
return;
}
const url = new URL('/mcp-oauth', process.env.NEXT_PUBLIC_BACKEND_URL);
const result = await oauthMiddleware(req, res, url);
if (!result.proceed) return;
const token = result.tokenValidation?.subject;
const auth = await resolveAuth(token!);
if (!auth) {
res.status(401).json({ error: 'invalid_token', error_description: 'Could not resolve organization' });
return;
}
await runWithContext({ requestId: token!, auth }, async () => {
await server.startHTTP({
url: url,
httpPath: url.pathname,
options: {
sessionIdGenerator: () => {
return randomUUID();
},
},
req,
res,
});
});
});
app.use('/mcp', async (req: Request, res: Response, next: () => void) => {
// Skip if this is the /mcp/:id route
if (req.path !== '/' && req.path !== '') {