feat: mcp with oauth
This commit is contained in:
parent
507a006b9f
commit
ac109bf564
3 changed files with 432 additions and 0 deletions
251
libraries/nestjs-libraries/src/chat/oauth-middleware.ts
Normal file
251
libraries/nestjs-libraries/src/chat/oauth-middleware.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
104
libraries/nestjs-libraries/src/chat/oauth-types.ts
Normal file
104
libraries/nestjs-libraries/src/chat/oauth-types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 !== '') {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue