mcpresso-oauth-server
Version:
Production-ready OAuth 2.1 server implementation for Model Context Protocol (MCP) with PKCE support
1 lines • 104 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/oauth-server.ts","../src/http-server.ts","../src/storage/memory-storage.ts","../src/utils/pkce.ts","../src/utils/tokens.ts"],"sourcesContent":["/**\n * Production-ready OAuth 2.1 Server Package\n * \n * A complete OAuth 2.1 implementation with PKCE support for Model Context Protocol (MCP)\n * \n * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13\n * @see https://modelcontextprotocol.io/specification/draft/basic/authorization\n */\n\n// Core OAuth server\nexport { MCPOAuthServer } from './oauth-server.js'\nexport { MCPOAuthHttpServer, registerOAuthEndpoints } from './http-server.js'\n\n// Storage implementations\nexport { MemoryStorage } from './storage/memory-storage.js'\n\n// Utilities\nexport * from './utils/pkce.js'\nexport * from './utils/tokens.js'\n\n// Types and interfaces\nexport * from './types.js'\n\n// Default configuration for production use\nexport const DEFAULT_OAUTH_CONFIG = {\n // Server configuration\n issuer: process.env.OAUTH_ISSUER || 'http://localhost:3000',\n serverUrl: process.env.OAUTH_SERVER_URL || 'http://localhost:3000',\n \n // Endpoints\n authorizationEndpoint: '/authorize',\n tokenEndpoint: '/token',\n userinfoEndpoint: '/userinfo',\n jwksEndpoint: '/.well-known/jwks.json',\n revocationEndpoint: '/revoke',\n introspectionEndpoint: '/introspect',\n \n // MCP-specific settings\n requireResourceIndicator: false, // Made optional for easier development\n requirePkce: true,\n allowRefreshTokens: true,\n \n // Dynamic client registration\n allowDynamicClientRegistration: true,\n \n // Token lifetimes (in seconds)\n accessTokenLifetime: 3600, // 1 hour\n refreshTokenLifetime: 2592000, // 30 days\n authorizationCodeLifetime: 600, // 10 minutes\n \n // Supported features\n supportedGrantTypes: [\n 'authorization_code',\n 'refresh_token',\n 'client_credentials'\n ],\n supportedResponseTypes: ['code'],\n supportedScopes: [\n 'read',\n 'write',\n 'admin',\n 'openid',\n 'profile',\n 'email'\n ],\n supportedCodeChallengeMethods: ['S256', 'plain'],\n \n // Security\n jwtSecret: process.env.OAUTH_JWT_SECRET || 'your-super-secret-jwt-key-change-in-production',\n jwtAlgorithm: 'HS256',\n \n // HTTP server configuration\n http: {\n cors: {\n origin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : true,\n credentials: true,\n exposedHeaders: [\"mcp-session-id\"],\n allowedHeaders: [\"Content-Type\", \"mcp-session-id\", \"accept\", \"last-event-id\", \"Authorization\"],\n methods: [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"]\n },\n trustProxy: process.env.TRUST_PROXY === 'true',\n jsonLimit: '10mb',\n urlencodedLimit: '10mb',\n enableCompression: true,\n enableHelmet: true,\n enableRateLimit: true,\n rateLimitConfig: {\n windowMs: 15 * 60 * 1000, // 15 minutes\n max: 100, // limit each IP to 100 requests per windowMs\n message: 'Too many requests from this IP, please try again later.',\n standardHeaders: true,\n legacyHeaders: false\n }\n }\n}\n\n/**\n * Create a production-ready OAuth server with proper configuration\n */\nexport function createProductionOAuthServer(config: Partial<typeof DEFAULT_OAUTH_CONFIG> = {}) {\n const finalConfig = { ...DEFAULT_OAUTH_CONFIG, ...config }\n \n // Ensure serverUrl is properly set\n if (!finalConfig.serverUrl) {\n finalConfig.serverUrl = finalConfig.issuer\n }\n \n // Build full endpoint URLs\n const baseUrl = finalConfig.serverUrl.replace(/\\/$/, '')\n finalConfig.authorizationEndpoint = `${baseUrl}${finalConfig.authorizationEndpoint}`\n finalConfig.tokenEndpoint = `${baseUrl}${finalConfig.tokenEndpoint}`\n finalConfig.userinfoEndpoint = `${baseUrl}${finalConfig.userinfoEndpoint}`\n finalConfig.jwksEndpoint = `${baseUrl}${finalConfig.jwksEndpoint}`\n finalConfig.revocationEndpoint = `${baseUrl}${finalConfig.revocationEndpoint}`\n finalConfig.introspectionEndpoint = `${baseUrl}${finalConfig.introspectionEndpoint}`\n \n return finalConfig\n}\n\n/**\n * Create a demo client for testing\n */\nexport function createDemoClient() {\n return {\n id: 'demo-client',\n secret: 'demo-secret',\n name: 'Demo Client',\n type: 'confidential' as const,\n redirectUris: ['http://localhost:3001/callback'],\n scopes: ['read', 'write', 'admin'],\n grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'],\n createdAt: new Date(),\n updatedAt: new Date()\n }\n} ","import { SignJWT, jwtVerify } from 'jose'\nimport { randomBytes, createHash } from 'crypto'\nimport type { \n MCPOAuthConfig, \n MCPOAuthConfigInput, \n MCPOAuthStorage,\n OAuthClient,\n OAuthUser,\n AuthorizationCode,\n AccessToken,\n RefreshToken,\n AuthorizationRequest,\n TokenRequest,\n TokenResponse,\n TokenIntrospectionResponse,\n UserInfoResponse,\n OAuthError,\n MCPProtectedResourceMetadata,\n MCPAuthorizationServerMetadata,\n ClientRegistrationRequest,\n ClientRegistrationResponse,\n UserAuthContext,\n UserAuthCallbacks\n} from './types.js'\n\nconst DEFAULTS = {\n supportedScopes: ['read', 'write', 'openid', 'profile', 'email'],\n supportedGrantTypes: ['authorization_code', 'refresh_token', 'client_credentials'],\n supportedResponseTypes: ['code'],\n supportedCodeChallengeMethods: ['S256', 'plain'],\n};\n\nfunction normalizeConfig(config: any) {\n return {\n ...config,\n supportedScopes: config.supportedScopes ?? DEFAULTS.supportedScopes,\n supportedGrantTypes: config.supportedGrantTypes ?? DEFAULTS.supportedGrantTypes,\n supportedResponseTypes: config.supportedResponseTypes ?? DEFAULTS.supportedResponseTypes,\n supportedCodeChallengeMethods: config.supportedCodeChallengeMethods ?? DEFAULTS.supportedCodeChallengeMethods,\n };\n}\n\nexport class MCPOAuthServer {\n private config: MCPOAuthConfig\n private storage: MCPOAuthStorage\n\n constructor(config: MCPOAuthConfigInput, storage: MCPOAuthStorage) {\n // Fill in required fields with defaults if missing\n const baseServerUrl = config.serverUrl || config.issuer;\n const fullConfig: MCPOAuthConfig = normalizeConfig({\n ...config,\n serverUrl: baseServerUrl,\n authorizationEndpoint: config.authorizationEndpoint ?? config.issuer + \"/authorize\",\n tokenEndpoint: config.tokenEndpoint ?? config.issuer + \"/token\",\n userinfoEndpoint: config.userinfoEndpoint ?? config.issuer + \"/userinfo\",\n jwksEndpoint: config.jwksEndpoint ?? config.issuer + \"/.well-known/jwks.json\",\n introspectionEndpoint: config.introspectionEndpoint ?? config.issuer + \"/introspect\",\n revocationEndpoint: config.revocationEndpoint ?? config.issuer + \"/revoke\",\n requireResourceIndicator: config.requireResourceIndicator ?? false,\n requirePkce: config.requirePkce ?? false,\n allowRefreshTokens: config.allowRefreshTokens ?? false,\n allowDynamicClientRegistration: config.allowDynamicClientRegistration ?? false,\n accessTokenLifetime: config.accessTokenLifetime ?? 3600,\n refreshTokenLifetime: config.refreshTokenLifetime ?? 3600,\n authorizationCodeLifetime: config.authorizationCodeLifetime ?? 600,\n supportedGrantTypes: config.supportedGrantTypes ?? DEFAULTS.supportedGrantTypes,\n supportedResponseTypes: config.supportedResponseTypes ?? DEFAULTS.supportedResponseTypes,\n supportedScopes: config.supportedScopes ?? DEFAULTS.supportedScopes,\n supportedCodeChallengeMethods: config.supportedCodeChallengeMethods ?? DEFAULTS.supportedCodeChallengeMethods,\n jwtAlgorithm: config.jwtAlgorithm ?? 'HS256',\n http: config.http,\n });\n this.config = fullConfig;\n this.storage = storage;\n }\n\n // ===== USER AUTHENTICATION =====\n\n /**\n * Handles user authentication during the authorization flow.\n * This method should be called before generating authorization codes.\n */\n async authenticateUserForAuthFlow(\n credentials: { username: string; password: string } | null,\n sessionData: any,\n context: UserAuthContext\n ): Promise<OAuthUser | null> {\n // If custom authentication callbacks are provided, use them\n if (this.config.auth) {\n // First, try to get current user from session\n if (this.config.auth.getCurrentUser) {\n const currentUser = await this.config.auth.getCurrentUser(sessionData, context)\n if (currentUser) {\n return currentUser\n }\n }\n\n // If credentials provided, try to authenticate\n if (credentials && this.config.auth.authenticateUser) {\n return await this.config.auth.authenticateUser(credentials, context)\n }\n }\n\n // Fallback: For demo/development purposes, return demo user if no auth callbacks\n // In production, this should require proper authentication\n if (!this.config.auth && credentials?.username === 'demo@example.com') {\n const demoUser = await this.storage.getUser('demo-user')\n return demoUser\n }\n\n return null\n }\n\n /**\n * Renders the login page for user authentication.\n */\n async renderLoginPage(context: UserAuthContext, error?: string): Promise<string> {\n if (this.config.auth?.renderLoginPage) {\n const result = await this.config.auth.renderLoginPage(context, error)\n if (typeof result === 'string') {\n return result\n }\n // Handle redirect case in the HTTP layer\n }\n\n // Default basic login page\n return this.generateDefaultLoginPage(context, error)\n }\n\n /**\n * Handles consent/authorization for authenticated users.\n */\n async handleUserConsent(user: OAuthUser, context: UserAuthContext): Promise<boolean> {\n if (this.config.auth?.renderConsentPage) {\n const result = await this.config.auth.renderConsentPage(user, context)\n if (typeof result === 'boolean') {\n return result\n }\n // Handle custom page/redirect case in the HTTP layer\n return true // Default to approved for now\n }\n\n // Default: auto-approve consent\n return true\n }\n\n private generateDefaultLoginPage(context: UserAuthContext, error?: string): string {\n const errorHtml = error ? `<div style=\"color: red; margin-bottom: 16px;\">${error}</div>` : ''\n \n return `\n <!DOCTYPE html>\n <html>\n <head>\n <title>Login - OAuth Authorization</title>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <style>\n body { font-family: system-ui, sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }\n .form-group { margin-bottom: 16px; }\n label { display: block; margin-bottom: 4px; font-weight: bold; }\n input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }\n button { background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }\n button:hover { background: #0056b3; }\n .client-info { background: #f8f9fa; padding: 16px; border-radius: 4px; margin-bottom: 20px; }\n </style>\n </head>\n <body>\n <div class=\"client-info\">\n <h3>Authorization Request</h3>\n <p><strong>Client:</strong> ${context.clientId}</p>\n <p><strong>Scope:</strong> ${context.scope || 'Default'}</p>\n </div>\n \n ${errorHtml}\n \n <form method=\"POST\" action=\"/authorize\">\n <input type=\"hidden\" name=\"response_type\" value=\"code\">\n <input type=\"hidden\" name=\"client_id\" value=\"${context.clientId}\">\n <input type=\"hidden\" name=\"redirect_uri\" value=\"${context.redirectUri}\">\n <input type=\"hidden\" name=\"scope\" value=\"${context.scope || ''}\">\n <input type=\"hidden\" name=\"resource\" value=\"${context.resource || ''}\">\n \n <div class=\"form-group\">\n <label for=\"username\">Username or Email:</label>\n <input type=\"text\" id=\"username\" name=\"username\" required>\n </div>\n \n <div class=\"form-group\">\n <label for=\"password\">Password:</label>\n <input type=\"password\" id=\"password\" name=\"password\" required>\n </div>\n \n <button type=\"submit\">Login & Authorize</button>\n </form>\n </body>\n </html>\n `\n }\n\n // ===== AUTHORIZATION ENDPOINT =====\n \n async handleAuthorizationRequest(\n params: AuthorizationRequest, \n credentials?: { username: string; password: string },\n sessionData?: any,\n requestContext?: { ipAddress: string; userAgent?: string }\n ): Promise<{ redirectUrl: string } | { loginPage: string } | OAuthError> {\n try {\n // Validate required MCP parameters\n if (this.config.requireResourceIndicator && !params.resource) {\n return { error: 'invalid_request', error_description: 'resource parameter is required' }\n }\n\n // Use default resource if not provided and not required\n if (!params.resource && !this.config.requireResourceIndicator) {\n params.resource = this.config.serverUrl\n }\n\n if (this.config.requirePkce && !params.code_challenge) {\n return { error: 'invalid_request', error_description: 'code_challenge parameter is required' }\n }\n\n // Validate client\n const client = await this.storage.getClient(params.client_id)\n if (!client) {\n return { error: 'invalid_client', error_description: 'Client not found' }\n }\n\n // Validate redirect URI (allow sub-paths)\n if (\n client.redirectUris.length > 0 &&\n !client.redirectUris.some((base) => params.redirect_uri.startsWith(base))\n ) {\n return {\n error: 'invalid_request',\n error_description: 'Invalid redirect URI',\n };\n }\n\n // Validate grant type support\n if (!client.grantTypes.includes('authorization_code')) {\n return { error: 'unauthorized_client', error_description: 'Client not authorized for authorization_code grant' }\n }\n\n // Validate scope\n if (params.scope && !this.validateScope(params.scope, client.scopes)) {\n return { error: 'invalid_scope', error_description: 'Invalid scope' }\n }\n\n // Create authentication context\n const authContext: UserAuthContext = {\n clientId: params.client_id,\n scope: params.scope,\n resource: params.resource,\n redirectUri: params.redirect_uri,\n ipAddress: requestContext?.ipAddress || '0.0.0.0',\n userAgent: requestContext?.userAgent\n }\n\n // Authenticate user\n const user = await this.authenticateUserForAuthFlow(credentials || null, sessionData, authContext)\n \n if (!user) {\n // User not authenticated, return login page\n const loginPage = await this.renderLoginPage(authContext)\n return { loginPage }\n }\n\n // Check user consent\n const hasConsent = await this.handleUserConsent(user, authContext)\n if (!hasConsent) {\n return { error: 'access_denied', error_description: 'User denied authorization' }\n }\n\n // Generate authorization code\n const code = this.generateCode()\n const expiresAt = new Date(Date.now() + this.config.authorizationCodeLifetime * 1000)\n\n const authCode: AuthorizationCode = {\n code,\n clientId: params.client_id,\n userId: user.id, // Now using the actual authenticated user ID\n redirectUri: params.redirect_uri,\n scope: params.scope || 'read',\n resource: params.resource,\n codeChallenge: params.code_challenge,\n codeChallengeMethod: params.code_challenge_method,\n expiresAt,\n createdAt: new Date()\n }\n\n await this.storage.createAuthorizationCode(authCode)\n\n // Build redirect URL\n const redirectUrl = new URL(params.redirect_uri)\n redirectUrl.searchParams.set('code', code)\n if (params.state) {\n redirectUrl.searchParams.set('state', params.state)\n }\n\n return { redirectUrl: redirectUrl.toString() }\n } catch (error) {\n console.error('Authorization request error:', error)\n return { error: 'server_error', error_description: 'Internal server error' }\n }\n }\n\n // ===== TOKEN ENDPOINT =====\n\n async handleTokenRequest(params: TokenRequest): Promise<TokenResponse | OAuthError> {\n try {\n // Validate required MCP parameters\n if (this.config.requireResourceIndicator && !params.resource) {\n return { error: 'invalid_request', error_description: 'resource parameter is required' }\n }\n\n // Use default resource if not provided and not required\n if (!params.resource && !this.config.requireResourceIndicator) {\n params.resource = this.config.serverUrl\n }\n\n switch (params.grant_type) {\n case 'authorization_code':\n return this.handleAuthorizationCodeGrant(params)\n case 'refresh_token':\n return this.handleRefreshTokenGrant(params)\n case 'client_credentials':\n return this.handleClientCredentialsGrant(params)\n default:\n return { error: 'unsupported_grant_type', error_description: 'Grant type not supported' }\n }\n } catch (error) {\n console.error('Token request error:', error)\n return { error: 'server_error', error_description: 'Internal server error' }\n }\n }\n\n private async handleAuthorizationCodeGrant(params: TokenRequest): Promise<TokenResponse | OAuthError> {\n if (!params.code) {\n return { error: 'invalid_request', error_description: 'code parameter is required' }\n }\n\n if (this.config.requirePkce && !params.code_verifier) {\n return { error: 'invalid_request', error_description: 'code_verifier parameter is required' }\n }\n\n // Get and validate authorization code\n const authCode = await this.storage.getAuthorizationCode(params.code)\n if (!authCode) {\n return { error: 'invalid_grant', error_description: 'Invalid authorization code' }\n }\n\n if (authCode.expiresAt < new Date()) {\n await this.storage.deleteAuthorizationCode(params.code)\n return { error: 'invalid_grant', error_description: 'Authorization code expired' }\n }\n\n // Validate client\n const client = await this.storage.getClient(authCode.clientId)\n if (!client) {\n return { error: 'invalid_client', error_description: 'Client not found' }\n }\n\n // Validate PKCE\n if (authCode.codeChallenge) {\n if (!params.code_verifier) {\n return { error: 'invalid_request', error_description: 'code_verifier is required' }\n }\n\n const expectedChallenge = authCode.codeChallengeMethod === 'S256' \n ? this.generateCodeChallenge(params.code_verifier)\n : params.code_verifier\n\n if (authCode.codeChallenge !== expectedChallenge) {\n return { error: 'invalid_grant', error_description: 'Invalid code verifier' }\n }\n }\n\n // Validate redirect URI if provided\n if (params.redirect_uri && params.redirect_uri !== authCode.redirectUri) {\n return { error: 'invalid_grant', error_description: 'Redirect URI mismatch' }\n }\n\n // Clean up authorization code\n await this.storage.deleteAuthorizationCode(params.code)\n\n // Generate access token\n const audience = params.resource || authCode.resource || this.config.serverUrl;\n const accessToken = await this.generateAccessToken(client.id, authCode.userId, authCode.scope, audience)\n \n // Generate refresh token if enabled\n let refreshToken: string | undefined\n if (this.config.allowRefreshTokens) {\n refreshToken = await this.generateRefreshToken(accessToken.token, client.id, authCode.userId, authCode.scope, audience)\n }\n\n return {\n access_token: accessToken.token,\n token_type: 'Bearer',\n expires_in: this.config.accessTokenLifetime,\n refresh_token: refreshToken,\n scope: authCode.scope\n }\n }\n\n private async handleRefreshTokenGrant(params: TokenRequest): Promise<TokenResponse | OAuthError> {\n if (!params.refresh_token) {\n return { error: 'invalid_request', error_description: 'refresh_token parameter is required' }\n }\n\n // Get and validate refresh token\n const refreshToken = await this.storage.getRefreshToken(params.refresh_token)\n if (!refreshToken) {\n return { error: 'invalid_grant', error_description: 'Invalid refresh token' }\n }\n\n if (refreshToken.expiresAt < new Date()) {\n await this.storage.deleteRefreshToken(params.refresh_token)\n return { error: 'invalid_grant', error_description: 'Refresh token expired' }\n }\n\n // Validate client\n const client = await this.storage.getClient(refreshToken.clientId)\n if (!client) {\n return { error: 'invalid_client', error_description: 'Client not found' }\n }\n\n // Validate resource indicator\n if (params.resource && refreshToken.audience && params.resource !== refreshToken.audience) {\n return { error: 'invalid_grant', error_description: 'Resource indicator mismatch' }\n }\n\n // Clean up old tokens\n await this.storage.deleteRefreshToken(params.refresh_token)\n await this.storage.deleteRefreshTokensByAccessToken(refreshToken.accessTokenId)\n\n // Generate new tokens\n const accessToken = await this.generateAccessToken(client.id, refreshToken.userId, refreshToken.scope, params.resource)\n \n let newRefreshToken: string | undefined\n if (this.config.allowRefreshTokens) {\n newRefreshToken = await this.generateRefreshToken(accessToken.token, client.id, refreshToken.userId, refreshToken.scope, params.resource)\n }\n\n return {\n access_token: accessToken.token,\n token_type: 'Bearer',\n expires_in: this.config.accessTokenLifetime,\n refresh_token: newRefreshToken,\n scope: refreshToken.scope\n }\n }\n\n private async handleClientCredentialsGrant(params: TokenRequest): Promise<TokenResponse | OAuthError> {\n // Validate client credentials\n const client = await this.storage.getClient(params.client_id)\n if (!client) {\n return { error: 'invalid_client', error_description: 'Client not found' }\n }\n\n if (client.type === 'confidential' && client.secret !== params.client_secret) {\n return { error: 'invalid_client', error_description: 'Invalid client secret' }\n }\n\n if (!client.grantTypes.includes('client_credentials')) {\n return { error: 'unauthorized_client', error_description: 'Client not authorized for client_credentials grant' }\n }\n\n // Generate access token (no refresh token for client credentials)\n const accessToken = await this.generateAccessToken(client.id, undefined, params.scope || 'read', params.resource)\n\n return {\n access_token: accessToken.token,\n token_type: 'Bearer',\n expires_in: this.config.accessTokenLifetime,\n scope: accessToken.scope\n }\n }\n\n // ===== TOKEN INTROSPECTION =====\n\n async introspectToken(token: string): Promise<TokenIntrospectionResponse> {\n try {\n const accessToken = await this.storage.getAccessToken(token)\n if (!accessToken || accessToken.expiresAt < new Date()) {\n return { active: false }\n }\n\n const client = await this.storage.getClient(accessToken.clientId)\n const user = accessToken.userId ? await this.storage.getUser(accessToken.userId) : undefined\n\n return {\n active: true,\n scope: accessToken.scope,\n client_id: accessToken.clientId,\n username: user?.username,\n exp: Math.floor(accessToken.expiresAt.getTime() / 1000),\n aud: accessToken.audience // MCP-specific: audience for validation\n }\n } catch (error) {\n console.error('Token introspection error:', error)\n return { active: false }\n }\n }\n\n // ===== TOKEN REVOCATION =====\n\n async revokeToken(token: string, clientId: string): Promise<{ success: boolean }> {\n try {\n const accessToken = await this.storage.getAccessToken(token)\n if (!accessToken || accessToken.clientId !== clientId) {\n return { success: false }\n }\n\n await this.storage.deleteAccessToken(token)\n await this.storage.deleteRefreshTokensByAccessToken(token)\n\n return { success: true }\n } catch (error) {\n console.error('Token revocation error:', error)\n return { success: false }\n }\n }\n\n // ===== USER INFO =====\n\n async getUserInfo(token: string): Promise<UserInfoResponse | OAuthError> {\n try {\n const accessToken = await this.storage.getAccessToken(token)\n if (!accessToken || accessToken.expiresAt < new Date()) {\n return { error: 'invalid_token', error_description: 'Invalid or expired token' }\n }\n\n if (!accessToken.userId) {\n return { error: 'invalid_token', error_description: 'Token does not contain user information' }\n }\n\n const user = await this.storage.getUser(accessToken.userId)\n if (!user) {\n return { error: 'invalid_token', error_description: 'User not found' }\n }\n\n return {\n sub: user.id,\n name: user.username,\n email: user.email,\n scope: accessToken.scope\n }\n } catch (error) {\n console.error('User info error:', error)\n return { error: 'server_error', error_description: 'Internal server error' }\n }\n }\n\n // ===== DYNAMIC CLIENT REGISTRATION (RFC 7591) =====\n\n async registerClient(request: ClientRegistrationRequest): Promise<ClientRegistrationResponse | OAuthError> {\n try {\n if (!this.config.allowDynamicClientRegistration) {\n return { error: 'access_denied', error_description: 'Dynamic client registration is not supported' }\n }\n\n // Validate required fields\n if (!request.redirect_uris || request.redirect_uris.length === 0) {\n return { error: 'invalid_redirect_uri', error_description: 'redirect_uris is required' }\n }\n\n // Generate client ID and secret\n const clientId = this.generateClientId()\n const clientSecret = this.generateClientSecret()\n\n // Create client object\n const client: OAuthClient = {\n id: clientId,\n secret: clientSecret,\n name: request.client_name || `Dynamic Client ${clientId}`,\n type: 'confidential', // Default to confidential for dynamic registration\n redirectUris: request.redirect_uris,\n scopes: request.scope ? request.scope.split(' ') : ['read'],\n grantTypes: request.grant_types || ['authorization_code'],\n createdAt: new Date(),\n updatedAt: new Date()\n }\n\n // Store client\n await this.storage.createClient(client)\n\n // Return registration response\n const response: ClientRegistrationResponse = {\n client_id: clientId,\n client_secret: clientSecret,\n client_id_issued_at: Math.floor(Date.now() / 1000),\n client_secret_expires_at: 0, // No expiration for now\n redirect_uris: request.redirect_uris,\n client_name: request.client_name,\n client_uri: request.client_uri,\n logo_uri: request.logo_uri,\n scope: request.scope,\n grant_types: request.grant_types,\n response_types: request.response_types,\n token_endpoint_auth_method: request.token_endpoint_auth_method,\n token_endpoint_auth_signing_alg: request.token_endpoint_auth_signing_alg,\n contacts: request.contacts,\n policy_uri: request.policy_uri,\n terms_of_service_uri: request.terms_of_service_uri,\n jwks_uri: request.jwks_uri,\n jwks: request.jwks,\n software_id: request.software_id,\n software_version: request.software_version\n }\n\n return response\n } catch (error) {\n console.error('Client registration error:', error)\n return { error: 'server_error', error_description: 'Internal server error' }\n }\n }\n\n // ===== MCP METADATA ENDPOINTS =====\n\n getProtectedResourceMetadata(): MCPProtectedResourceMetadata {\n return {\n resource: this.config.serverUrl,\n authorization_servers: [this.config.issuer],\n scopes_supported: [...this.config.supportedScopes],\n bearer_methods_supported: ['Authorization header']\n }\n }\n\n getAuthorizationServerMetadata(): MCPAuthorizationServerMetadata {\n return {\n issuer: this.config.issuer,\n authorization_endpoint: this.config.authorizationEndpoint,\n token_endpoint: this.config.tokenEndpoint,\n userinfo_endpoint: this.config.userinfoEndpoint,\n jwks_uri: this.config.jwksEndpoint,\n revocation_endpoint: this.config.revocationEndpoint,\n introspection_endpoint: this.config.introspectionEndpoint,\n registration_endpoint: this.config.allowDynamicClientRegistration ? `${this.config.issuer}/register` : undefined,\n grant_types_supported: [...this.config.supportedGrantTypes],\n response_types_supported: [...this.config.supportedResponseTypes],\n scopes_supported: [...this.config.supportedScopes],\n token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],\n code_challenge_methods_supported: [...this.config.supportedCodeChallengeMethods],\n resource_indicators_supported: this.config.requireResourceIndicator\n }\n }\n\n // ===== UTILITY METHODS =====\n\n private generateCode(): string {\n return randomBytes(32).toString('base64url')\n }\n\n private generateCodeChallenge(verifier: string): string {\n return createHash('sha256').update(verifier).digest('base64url')\n }\n\n private generateClientId(): string {\n return `client_${randomBytes(16).toString('hex')}`\n }\n\n private generateClientSecret(): string {\n return randomBytes(32).toString('base64url')\n }\n\n private async generateAccessToken(clientId: string, userId?: string, scope?: string, audience?: string): Promise<AccessToken> {\n const now = Math.floor(Date.now() / 1000)\n\n const payload: Record<string, any> = {\n iss: this.config.issuer,\n sub: userId || clientId,\n aud: audience || this.config.serverUrl,\n iat: now,\n exp: now + this.config.accessTokenLifetime,\n scope: scope || 'read',\n client_id: clientId,\n }\n\n const secret = new TextEncoder().encode(this.config.jwtSecret)\n\n const jwt = await new SignJWT(payload)\n .setProtectedHeader({ alg: this.config.jwtAlgorithm || 'HS256' })\n .sign(secret)\n\n const accessToken: AccessToken = {\n token: jwt,\n clientId,\n userId,\n scope: payload.scope,\n expiresAt: new Date((now + this.config.accessTokenLifetime) * 1000),\n createdAt: new Date(),\n audience: payload.aud,\n }\n\n await this.storage.createAccessToken(accessToken)\n return accessToken\n }\n\n private async generateRefreshToken(accessTokenId: string, clientId: string, userId?: string, scope?: string, audience?: string): Promise<string> {\n const token = randomBytes(32).toString('base64url')\n const expiresAt = new Date(Date.now() + this.config.refreshTokenLifetime * 1000)\n\n const refreshToken: RefreshToken = {\n token,\n accessTokenId,\n clientId,\n userId,\n scope: scope || 'read',\n expiresAt,\n createdAt: new Date(),\n audience // MCP-specific: store audience for validation\n }\n\n await this.storage.createRefreshToken(refreshToken)\n return token\n }\n\n private validateScope(requestedScope: string, allowedScopes: string[]): boolean {\n const requestedScopes = requestedScope.split(' ')\n return requestedScopes.every(scope => allowedScopes.includes(scope))\n }\n\n // ===== CLEANUP =====\n\n async cleanup(): Promise<void> {\n await Promise.all([\n this.storage.cleanupExpiredCodes(),\n this.storage.cleanupExpiredTokens(),\n this.storage.cleanupExpiredRefreshTokens()\n ])\n }\n\n // ===== ADMIN METHODS =====\n\n async getStats() {\n return this.storage.getStats()\n }\n\n // Public methods for admin access\n async listClients() {\n return this.storage.listClients()\n }\n\n async listUsers() {\n return this.storage.listUsers()\n }\n} ","import express from 'express'\nimport cors, { CorsOptions } from 'cors'\nimport compression from 'compression'\nimport helmet from 'helmet'\nimport rateLimit from 'express-rate-limit'\nimport { MCPOAuthServer } from './oauth-server.js'\nimport type { MCPOAuthConfig, HTTPServerConfig } from './types.js'\n\nexport function registerOAuthEndpoints(app: express.Application, oauthServer: MCPOAuthServer, basePath = \"\") {\n const renderSuccessPage = (redirectUrl: string) => `<!DOCTYPE html>\n <html>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Authorization successful</title>\n <meta http-equiv=\"refresh\" content=\"1;url=${redirectUrl}\">\n <style>\n :root { --primary:#2563eb; --bg:#f9fafb; --card-bg:#ffffff; --border:#e5e7eb; --radius:10px; --font:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }\n body { margin:0; background:var(--bg); display:flex; align-items:center; justify-content:center; height:100vh; font-family:var(--font); }\n .card { width:100%; max-width:480px; background:var(--card-bg); padding:28px 32px; border:1px solid var(--border); border-radius:var(--radius); box-shadow:0 10px 30px rgba(0,0,0,0.06); text-align:center; }\n h1 { margin:0 0 8px; font-size:22px; }\n p { color:#4b5563; font-size:14px; margin:0 0 14px; }\n a { color:var(--primary); text-decoration:none; }\n </style>\n </head>\n <body>\n <div class=\"card\">\n <h1>Authorization successful</h1>\n <p>You can close this window. Redirecting you back to your application…</p>\n <p><a href=\"${redirectUrl}\">Continue</a></p>\n </div>\n <script>setTimeout(function(){ location.replace(${JSON.stringify(redirectUrl)}); }, 900);</script>\n </body>\n </html>`\n // Authorization endpoint (GET - show login page or redirect)\n app.get(`${basePath}/authorize`, async (req, res) => {\n try {\n const q = req.query as Record<string, any>\n const first = (v: any) => Array.isArray(v) ? v[0] : v\n const params = {\n response_type: first(q.response_type) as 'code',\n client_id: first(q.client_id) as string,\n redirect_uri: first(q.redirect_uri) as string,\n scope: first(q.scope) as string,\n state: first(q.state) as string,\n resource: first(q.resource) as string,\n code_challenge: first(q.code_challenge) as string,\n code_challenge_method: first(q.code_challenge_method) as 'S256' | 'plain'\n }\n\n const requestContext = {\n ipAddress: (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || (req.connection as any).remoteAddress || '0.0.0.0',\n userAgent: req.headers['user-agent']\n }\n\n const result = await oauthServer.handleAuthorizationRequest(params, undefined, (req as any).session, requestContext)\n \n if ('error' in result) {\n return res.status(400).json(result)\n }\n \n if ('loginPage' in result) {\n return res.type('html').send(result.loginPage)\n }\n \n if ('redirectUrl' in result) {\n res.type('html').send(renderSuccessPage(result.redirectUrl))\n }\n } catch (error) {\n console.error('Authorization error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // Authorization endpoint (POST - handle login form submission)\n app.post(`${basePath}/authorize`, async (req, res) => {\n try {\n const b = req.body as Record<string, any>\n const first = (v: any) => Array.isArray(v) ? v[0] : v\n const params = {\n response_type: first(b.response_type) as 'code',\n client_id: first(b.client_id) as string,\n redirect_uri: first(b.redirect_uri) as string,\n scope: first(b.scope) as string,\n state: first(b.state) as string,\n resource: first(b.resource) as string,\n code_challenge: first(b.code_challenge) as string,\n code_challenge_method: first(b.code_challenge_method) as 'S256' | 'plain'\n }\n\n const credentials = first(b.username) && first(b.password) ? {\n username: first(b.username) as string,\n password: first(b.password) as string\n } : undefined\n\n const requestContext = {\n ipAddress: (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || (req.connection as any).remoteAddress || '0.0.0.0',\n userAgent: req.headers['user-agent']\n }\n\n const result = await oauthServer.handleAuthorizationRequest(params, credentials, (req as any).session, requestContext)\n \n if ('error' in result) {\n return res.status(400).json(result)\n }\n \n if ('loginPage' in result) {\n return res.type('html').send(result.loginPage)\n }\n \n if ('redirectUrl' in result) {\n res.type('html').send(renderSuccessPage(result.redirectUrl))\n }\n } catch (error) {\n console.error('Authorization error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // Token endpoint\n app.post(`${basePath}/token`, async (req, res) => {\n try {\n const params = {\n grant_type: req.body.grant_type as 'authorization_code' | 'refresh_token' | 'client_credentials',\n client_id: req.body.client_id as string,\n client_secret: req.body.client_secret as string,\n code: req.body.code as string,\n redirect_uri: req.body.redirect_uri as string,\n refresh_token: req.body.refresh_token as string,\n scope: req.body.scope as string,\n resource: req.body.resource as string,\n code_verifier: req.body.code_verifier as string\n }\n const result = await oauthServer.handleTokenRequest(params)\n if ('error' in result) {\n return res.status(400).json(result)\n }\n res.json(result)\n } catch (error) {\n console.error('Token error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // Token introspection endpoint\n app.post(`${basePath}/introspect`, async (req, res) => {\n try {\n const { token } = req.body\n if (!token) {\n return res.status(400).json({ error: 'invalid_request', error_description: 'token parameter is required' })\n }\n\n const result = await oauthServer.introspectToken(token)\n res.json(result)\n } catch (error) {\n console.error('Introspection error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // Token revocation endpoint\n app.post(`${basePath}/revoke`, async (req, res) => {\n try {\n const { token, client_id } = req.body\n if (!token || !client_id) {\n return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id parameters are required' })\n }\n\n const result = await oauthServer.revokeToken(token, client_id)\n res.json(result)\n } catch (error) {\n console.error('Revocation error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // User info endpoint\n app.get(`${basePath}/userinfo`, async (req, res) => {\n try {\n const authHeader = req.headers.authorization\n if (!authHeader || !authHeader.startsWith('Bearer ')) {\n return res.status(401).json({ error: 'invalid_token', error_description: 'Bearer token required' })\n }\n\n const token = authHeader.substring(7)\n const result = await oauthServer.getUserInfo(token)\n \n if ('error' in result) {\n return res.status(401).json(result)\n }\n\n res.json(result)\n } catch (error) {\n console.error('User info error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // Dynamic client registration endpoint (RFC 7591)\n app.post(`${basePath}/register`, async (req, res) => {\n try {\n const result = await oauthServer.registerClient(req.body)\n \n if ('error' in result) {\n return res.status(400).json(result)\n }\n\n res.status(201).json(result)\n } catch (error) {\n console.error('Client registration error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // ===== DISCOVERY ENDPOINTS =====\n\n // OAuth Authorization Server Metadata (RFC 8414)\n app.get(`${basePath}/.well-known/oauth-authorization-server`, (req, res) => {\n const metadata = oauthServer.getAuthorizationServerMetadata()\n res.json(metadata)\n })\n\n // JWKS endpoint\n app.get(`${basePath}/.well-known/jwks.json`, (req, res) => {\n // For now, return empty JWKS since we're using HMAC\n // In production, you'd use RSA keys\n res.json({ keys: [] })\n })\n\n // MCP Protected Resource Metadata (RFC 9728) - FIXED ENDPOINT\n app.get(`${basePath}/.well-known/oauth-protected-resource`, (req, res) => {\n const metadata = oauthServer.getProtectedResourceMetadata()\n res.json(metadata)\n })\n\n // ===== ADMIN ENDPOINTS =====\n\n // Health check\n app.get(`${basePath}/health`, (req, res) => {\n res.json({\n status: 'ok',\n service: 'mcp-oauth-server',\n version: '1.0.0',\n timestamp: new Date().toISOString()\n })\n })\n\n // List clients\n app.get(`${basePath}/admin/clients`, async (req, res) => {\n try {\n const clients = await oauthServer.listClients()\n res.json(clients)\n } catch (error) {\n console.error('List clients error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // List users\n app.get(`${basePath}/admin/users`, async (req, res) => {\n try {\n const users = await oauthServer.listUsers()\n res.json(users)\n } catch (error) {\n console.error('List users error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // Server stats\n app.get(`${basePath}/admin/stats`, async (req, res) => {\n try {\n const stats = await oauthServer.getStats()\n res.json(stats)\n } catch (error) {\n console.error('Stats error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // 404 handler\n // app.use(`${basePath}/*`, (req, res) => {\n // res.status(404).json({ \n // error: 'not_found', \n // error_description: 'Endpoint not found',\n // path: req.originalUrl\n // })\n // })\n}\n\nexport class MCPOAuthHttpServer {\n private app: express.Application\n private oauthServer: MCPOAuthServer\n private config: MCPOAuthConfig\n\n constructor(oauthServer: MCPOAuthServer, config: MCPOAuthConfig) {\n this.oauthServer = oauthServer\n this.config = config\n this.app = express()\n this.setupMiddleware()\n this.setupRoutes()\n this.setupErrorHandling()\n }\n\n private setupMiddleware(): void {\n const httpConfig = this.config.http || {}\n \n // Trust proxy (important for production behind load balancers)\n if (httpConfig.trustProxy !== undefined) {\n this.app.set('trust proxy', httpConfig.trustProxy)\n }\n\n // Security headers\n if (httpConfig.enableHelmet !== false) {\n this.app.use(helmet({\n contentSecurityPolicy: {\n directives: {\n defaultSrc: [\"'self'\"],\n styleSrc: [\"'self'\", \"'unsafe-inline'\"],\n scriptSrc: [\"'self'\"],\n imgSrc: [\"'self'\", \"data:\", \"https:\"],\n },\n },\n }))\n }\n\n // Compression\n if (httpConfig.enableCompression !== false) {\n this.app.use(compression())\n }\n\n // Rate limiting\n if (httpConfig.enableRateLimit !== false) {\n const rateLimitConfig = httpConfig.rateLimitConfig || {}\n const limiter = rateLimit({\n windowMs: rateLimitConfig.windowMs || 15 * 60 * 1000, // 15 minutes\n max: rateLimitConfig.max || 100, // limit each IP to 100 requests per windowMs\n message: rateLimitConfig.message || 'Too many requests from this IP, please try again later.',\n standardHeaders: rateLimitConfig.standardHeaders !== false, // Return rate limit info in the `RateLimit-*` headers\n legacyHeaders: rateLimitConfig.legacyHeaders !== false, // Disable the `X-RateLimit-*` headers\n })\n this.app.use(limiter)\n }\n\n // CORS configuration - use provided config or sensible defaults\n const corsConfig: CorsOptions = httpConfig.cors || {\n origin: true, // Allow all origins by default\n credentials: true,\n exposedHeaders: [\"mcp-session-id\"],\n allowedHeaders: [\"Content-Type\", \"mcp-session-id\", \"accept\", \"last-event-id\", \"Authorization\"],\n methods: [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"]\n }\n this.app.use(cors(corsConfig))\n\n // JSON parsing with configurable limits\n this.app.use(express.json({ \n limit: httpConfig.jsonLimit || '10mb' \n }))\n this.app.use(express.urlencoded({ \n extended: true, \n limit: httpConfig.urlencodedLimit || '10mb' \n }))\n }\n\n private setupRoutes(): void {\n // ===== OAUTH 2.1 ENDPOINTS =====\n\n // Authorization endpoint\n this.app.get('/authorize', async (req, res) => {\n try {\n const params = {\n response_type: req.query.response_type as 'code',\n client_id: req.query.client_id as string,\n redirect_uri: req.query.redirect_uri as string,\n scope: req.query.scope as string,\n state: req.query.state as string,\n resource: req.query.resource as string,\n code_challenge: req.query.code_challenge as string,\n code_challenge_method: req.query.code_challenge_method as 'S256' | 'plain'\n }\n\n const requestContext = {\n ipAddress: req.ip || req.connection.remoteAddress || '0.0.0.0',\n userAgent: req.headers['user-agent']\n }\n\n const result = await this.oauthServer.handleAuthorizationRequest(params, undefined, (req as any).session, requestContext)\n \n if ('error' in result) {\n return res.status(400).json(result)\n }\n \n if ('loginPage' in result) {\n return res.type('html').send(result.loginPage)\n }\n \n if ('redirectUrl' in result) {\n res.redirect(result.redirectUrl)\n }\n } catch (error) {\n console.error('Authorization error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // Authorization endpoint (POST - handle login form submission)\n this.app.post('/authorize', async (req, res) => {\n try {\n const params = {\n response_type: req.body.response_type as 'code',\n client_id: req.body.client_id as string,\n redirect_uri: req.body.redirect_uri as string,\n scope: req.body.scope as string,\n state: req.body.state as string,\n resource: req.body.resource as string,\n code_challenge: req.body.code_challenge as string,\n code_challenge_method: req.body.code_challenge_method as 'S256' | 'plain'\n }\n\n const credentials = req.body.username && req.body.password ? {\n username: req.body.username as string,\n password: req.body.password as string\n } : undefined\n\n const requestContext = {\n ipAddress: req.ip || req.connection.remoteAddress || '0.0.0.0',\n userAgent: req.headers['user-agent']\n }\n\n const result = await this.oauthServer.handleAuthorizationRequest(params, credentials, (req as any).session, requestContext)\n \n if ('error' in result) {\n return res.status(400).json(result)\n }\n \n if ('loginPage' in result) {\n return res.type('html').send(result.loginPage)\n }\n \n if ('redirectUrl' in result) {\n res.redirect(result.redirectUrl)\n }\n } catch (error) {\n console.error('Authorization error:', error)\n res.status(500).json({ error: 'server_error', error_description: 'Internal server error' })\n }\n })\n\n // Token endpoint\n this.app.post('/token', async (req, res) => {\n try {\n const params = {\n grant_type: req.body.grant_type as 'authorization_code' | 'refresh_token' | 'client_credentials',\n client_id: req.body.client_id as string,\n client_secret: req.body.client_secret as string,\n code: req.body.code as string,\n redirect_uri: req.body.redirect_uri as string,\n refresh_token: req.body.refresh_token as string,\n