UNPKG

buroventures-harald-code-core

Version:

Harald Code Core - Core functionality for AI-powered coding assistant

471 lines 21 kB
/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as http from 'node:http'; import * as crypto from 'node:crypto'; import { URL } from 'node:url'; import { openBrowserSecurely } from '../utils/secure-browser-launcher.js'; import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; import { getErrorMessage } from '../utils/errors.js'; import { OAuthUtils } from './oauth-utils.js'; /** * Provider for handling OAuth authentication for MCP servers. */ export class MCPOAuthProvider { static REDIRECT_PORT = 7777; static REDIRECT_PATH = '/oauth/callback'; static HTTP_OK = 200; static HTTP_REDIRECT = 302; /** * Register a client dynamically with the OAuth server. * * @param registrationUrl The client registration endpoint URL * @param config OAuth configuration * @returns The registered client information */ static async registerClient(registrationUrl, config) { const redirectUri = config.redirectUri || `http://localhost:${this.REDIRECT_PORT}${this.REDIRECT_PATH}`; const registrationRequest = { client_name: 'Gemini CLI (Google ADC)', redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', // Public client code_challenge_method: ['S256'], scope: config.scopes?.join(' ') || '', }; const response = await fetch(registrationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(registrationRequest), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Client registration failed: ${response.status} ${response.statusText} - ${errorText}`); } return (await response.json()); } /** * Discover OAuth configuration from an MCP server URL. * * @param mcpServerUrl The MCP server URL * @returns OAuth configuration if discovered, null otherwise */ static async discoverOAuthFromMCPServer(mcpServerUrl) { const baseUrl = OAuthUtils.extractBaseUrl(mcpServerUrl); return OAuthUtils.discoverOAuthConfig(baseUrl); } /** * Generate PKCE parameters for OAuth flow. * * @returns PKCE parameters including code verifier, challenge, and state */ static generatePKCEParams() { // Generate code verifier (43-128 characters) const codeVerifier = crypto.randomBytes(32).toString('base64url'); // Generate code challenge using SHA256 const codeChallenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url'); // Generate state for CSRF protection const state = crypto.randomBytes(16).toString('base64url'); return { codeVerifier, codeChallenge, state }; } /** * Start a local HTTP server to handle OAuth callback. * * @param expectedState The state parameter to validate * @returns Promise that resolves with the authorization code */ static async startCallbackServer(expectedState) { return new Promise((resolve, reject) => { const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, `http://localhost:${this.REDIRECT_PORT}`); if (url.pathname !== this.REDIRECT_PATH) { res.writeHead(404); res.end('Not found'); return; } const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { res.writeHead(this.HTTP_OK, { 'Content-Type': 'text/html' }); res.end(` <html> <body> <h1>Authentication Failed</h1> <p>Error: ${error.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p> <p>${(url.searchParams.get('error_description') || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p> <p>You can close this window.</p> </body> </html> `); server.close(); reject(new Error(`OAuth error: ${error}`)); return; } if (!code || !state) { res.writeHead(400); res.end('Missing code or state parameter'); return; } if (state !== expectedState) { res.writeHead(400); res.end('Invalid state parameter'); server.close(); reject(new Error('State mismatch - possible CSRF attack')); return; } // Send success response to browser res.writeHead(this.HTTP_OK, { 'Content-Type': 'text/html' }); res.end(` <html> <body> <h1>Authentication Successful!</h1> <p>You can close this window and return to Gemini CLI.</p> <script>window.close();</script> </body> </html> `); server.close(); resolve({ code, state }); } catch (error) { server.close(); reject(error); } }); server.on('error', reject); server.listen(this.REDIRECT_PORT, () => { console.log(`OAuth callback server listening on port ${this.REDIRECT_PORT}`); }); // Timeout after 5 minutes setTimeout(() => { server.close(); reject(new Error('OAuth callback timeout')); }, 5 * 60 * 1000); }); } /** * Build the authorization URL with PKCE parameters. * * @param config OAuth configuration * @param pkceParams PKCE parameters * @param mcpServerUrl The MCP server URL to use as the resource parameter * @returns The authorization URL */ static buildAuthorizationUrl(config, pkceParams, mcpServerUrl) { const redirectUri = config.redirectUri || `http://localhost:${this.REDIRECT_PORT}${this.REDIRECT_PATH}`; const params = new URLSearchParams({ client_id: config.clientId, response_type: 'code', redirect_uri: redirectUri, state: pkceParams.state, code_challenge: pkceParams.codeChallenge, code_challenge_method: 'S256', }); if (config.scopes && config.scopes.length > 0) { params.append('scope', config.scopes.join(' ')); } // Add resource parameter for MCP OAuth spec compliance // Use the MCP server URL if provided, otherwise fall back to authorization URL const resourceUrl = mcpServerUrl || config.authorizationUrl; try { params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl)); } catch (error) { throw new Error(`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`); } return `${config.authorizationUrl}?${params.toString()}`; } /** * Exchange authorization code for tokens. * * @param config OAuth configuration * @param code Authorization code * @param codeVerifier PKCE code verifier * @param mcpServerUrl The MCP server URL to use as the resource parameter * @returns The token response */ static async exchangeCodeForToken(config, code, codeVerifier, mcpServerUrl) { const redirectUri = config.redirectUri || `http://localhost:${this.REDIRECT_PORT}${this.REDIRECT_PATH}`; const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: codeVerifier, client_id: config.clientId, }); if (config.clientSecret) { params.append('client_secret', config.clientSecret); } // Add resource parameter for MCP OAuth spec compliance // Use the MCP server URL if provided, otherwise fall back to token URL const resourceUrl = mcpServerUrl || config.tokenUrl; try { params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl)); } catch (error) { throw new Error(`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`); } const response = await fetch(config.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: params.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} - ${errorText}`); } return (await response.json()); } /** * Refresh an access token using a refresh token. * * @param config OAuth configuration * @param refreshToken The refresh token * @param tokenUrl The token endpoint URL * @param mcpServerUrl The MCP server URL to use as the resource parameter * @returns The new token response */ static async refreshAccessToken(config, refreshToken, tokenUrl, mcpServerUrl) { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: config.clientId, }); if (config.clientSecret) { params.append('client_secret', config.clientSecret); } if (config.scopes && config.scopes.length > 0) { params.append('scope', config.scopes.join(' ')); } // Add resource parameter for MCP OAuth spec compliance // Use the MCP server URL if provided, otherwise fall back to token URL const resourceUrl = mcpServerUrl || tokenUrl; try { params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl)); } catch (error) { throw new Error(`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`); } const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: params.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token refresh failed: ${response.status} - ${errorText}`); } return (await response.json()); } /** * Perform the full OAuth authorization code flow with PKCE. * * @param serverName The name of the MCP server * @param config OAuth configuration * @param mcpServerUrl Optional MCP server URL for OAuth discovery * @returns The obtained OAuth token */ static async authenticate(serverName, config, mcpServerUrl) { // If no authorization URL is provided, try to discover OAuth configuration if (!config.authorizationUrl && mcpServerUrl) { console.log('No authorization URL provided, attempting OAuth discovery...'); // For SSE URLs, first check if authentication is required if (OAuthUtils.isSSEEndpoint(mcpServerUrl)) { try { const response = await fetch(mcpServerUrl, { method: 'HEAD', headers: { Accept: 'text/event-stream', }, }); if (response.status === 401 || response.status === 307) { const wwwAuthenticate = response.headers.get('www-authenticate'); if (wwwAuthenticate) { const discoveredConfig = await OAuthUtils.discoverOAuthFromWWWAuthenticate(wwwAuthenticate); if (discoveredConfig) { config = { ...config, ...discoveredConfig, scopes: discoveredConfig.scopes || config.scopes || [], }; } } } } catch (error) { console.debug(`Failed to check SSE endpoint for authentication requirements: ${getErrorMessage(error)}`); } } // If we still don't have OAuth config, try the standard discovery if (!config.authorizationUrl) { const discoveredConfig = await this.discoverOAuthFromMCPServer(mcpServerUrl); if (discoveredConfig) { config = { ...config, ...discoveredConfig }; console.log('OAuth configuration discovered successfully'); } else { throw new Error('Failed to discover OAuth configuration from MCP server'); } } } // If no client ID is provided, try dynamic client registration if (!config.clientId) { // Extract server URL from authorization URL if (!config.authorizationUrl) { throw new Error('Cannot perform dynamic registration without authorization URL'); } const authUrl = new URL(config.authorizationUrl); const serverUrl = `${authUrl.protocol}//${authUrl.host}`; console.log('No client ID provided, attempting dynamic client registration...'); // Get the authorization server metadata for registration const authServerMetadataUrl = new URL('/.well-known/oauth-authorization-server', serverUrl).toString(); const authServerMetadata = await OAuthUtils.fetchAuthorizationServerMetadata(authServerMetadataUrl); if (!authServerMetadata) { throw new Error('Failed to fetch authorization server metadata for client registration'); } // Register client if registration endpoint is available if (authServerMetadata.registration_endpoint) { const clientRegistration = await this.registerClient(authServerMetadata.registration_endpoint, config); config.clientId = clientRegistration.client_id; if (clientRegistration.client_secret) { config.clientSecret = clientRegistration.client_secret; } console.log('Dynamic client registration successful'); } else { throw new Error('No client ID provided and dynamic registration not supported'); } } // Validate configuration if (!config.clientId || !config.authorizationUrl || !config.tokenUrl) { throw new Error('Missing required OAuth configuration after discovery and registration'); } // Generate PKCE parameters const pkceParams = this.generatePKCEParams(); // Build authorization URL const authUrl = this.buildAuthorizationUrl(config, pkceParams, mcpServerUrl); console.log('\nOpening browser for OAuth authentication...'); console.log('If the browser does not open, please visit:'); console.log(''); // Get terminal width or default to 80 const terminalWidth = process.stdout.columns || 80; const separatorLength = Math.min(terminalWidth - 2, 80); const separator = '━'.repeat(separatorLength); console.log(separator); console.log('COPY THE ENTIRE URL BELOW (select all text between the lines):'); console.log(separator); console.log(authUrl); console.log(separator); console.log(''); console.log('💡 TIP: Triple-click to select the entire URL, then copy and paste it into your browser.'); console.log('⚠️ Make sure to copy the COMPLETE URL - it may wrap across multiple lines.'); console.log(''); // Start callback server const callbackPromise = this.startCallbackServer(pkceParams.state); // Open browser securely try { await openBrowserSecurely(authUrl); } catch (error) { console.warn('Failed to open browser automatically:', getErrorMessage(error)); } // Wait for callback const { code } = await callbackPromise; console.log('\nAuthorization code received, exchanging for tokens...'); // Exchange code for tokens const tokenResponse = await this.exchangeCodeForToken(config, code, pkceParams.codeVerifier, mcpServerUrl); // Convert to our token format const token = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type, refreshToken: tokenResponse.refresh_token, scope: tokenResponse.scope, }; if (tokenResponse.expires_in) { token.expiresAt = Date.now() + tokenResponse.expires_in * 1000; } // Save token try { await MCPOAuthTokenStorage.saveToken(serverName, token, config.clientId, config.tokenUrl, mcpServerUrl); console.log('Authentication successful! Token saved.'); // Verify token was saved const savedToken = await MCPOAuthTokenStorage.getToken(serverName); if (savedToken) { console.log(`Token verification successful: ${savedToken.token.accessToken.substring(0, 20)}...`); } else { console.error('Token verification failed: token not found after save'); } } catch (saveError) { console.error(`Failed to save token: ${getErrorMessage(saveError)}`); throw saveError; } return token; } /** * Get a valid access token for an MCP server, refreshing if necessary. * * @param serverName The name of the MCP server * @param config OAuth configuration * @returns A valid access token or null if not authenticated */ static async getValidToken(serverName, config) { console.debug(`Getting valid token for server: ${serverName}`); const credentials = await MCPOAuthTokenStorage.getToken(serverName); if (!credentials) { console.debug(`No credentials found for server: ${serverName}`); return null; } const { token } = credentials; console.debug(`Found token for server: ${serverName}, expired: ${MCPOAuthTokenStorage.isTokenExpired(token)}`); // Check if token is expired if (!MCPOAuthTokenStorage.isTokenExpired(token)) { console.debug(`Returning valid token for server: ${serverName}`); return token.accessToken; } // Try to refresh if we have a refresh token if (token.refreshToken && config.clientId && credentials.tokenUrl) { try { console.log(`Refreshing expired token for MCP server: ${serverName}`); const newTokenResponse = await this.refreshAccessToken(config, token.refreshToken, credentials.tokenUrl, credentials.mcpServerUrl); // Update stored token const newToken = { accessToken: newTokenResponse.access_token, tokenType: newTokenResponse.token_type, refreshToken: newTokenResponse.refresh_token || token.refreshToken, scope: newTokenResponse.scope || token.scope, }; if (newTokenResponse.expires_in) { newToken.expiresAt = Date.now() + newTokenResponse.expires_in * 1000; } await MCPOAuthTokenStorage.saveToken(serverName, newToken, config.clientId, credentials.tokenUrl, credentials.mcpServerUrl); return newToken.accessToken; } catch (error) { console.error(`Failed to refresh token: ${getErrorMessage(error)}`); // Remove invalid token await MCPOAuthTokenStorage.removeToken(serverName); } } return null; } } //# sourceMappingURL=oauth-provider.js.map