diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index caca5ddb39..f2ca1d6df8 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -3,6 +3,7 @@ import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotoc import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import ReconnectingEventSource from "reconnecting-eventsource" +import { createSSETransportWithOAuth, createStreamableHTTPTransportWithOAuth } from "./oauth/HttpTransportWithOAuth" import { CallToolResultSchema, ListResourcesResultSchema, @@ -734,12 +735,22 @@ export class McpHub { console.error(`No stderr stream for ${name}`) } } else if (configInjected.type === "streamable-http") { - // Streamable HTTP connection - transport = new StreamableHTTPClientTransport(new URL(configInjected.url), { - requestInit: { - headers: configInjected.headers, + // Streamable HTTP connection with OAuth support + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider not available for OAuth initialization") + } + + transport = createStreamableHTTPTransportWithOAuth( + new URL(configInjected.url), + { + requestInit: { + headers: configInjected.headers, + }, }, - }) + name, + provider.context, + ) // Set up Streamable HTTP specific error handling transport.onerror = async (error) => { @@ -760,7 +771,12 @@ export class McpHub { await this.notifyWebviewOfServerChanges() } } else if (configInjected.type === "sse") { - // SSE connection + // SSE connection with OAuth support + const provider = this.providerRef.deref() + if (!provider) { + throw new Error("Provider not available for OAuth initialization") + } + const sseOptions = { requestInit: { headers: configInjected.headers, @@ -779,10 +795,16 @@ export class McpHub { }, } global.EventSource = ReconnectingEventSource - transport = new SSEClientTransport(new URL(configInjected.url), { - ...sseOptions, - eventSourceInit: reconnectingEventSourceOptions, - }) + + transport = createSSETransportWithOAuth( + new URL(configInjected.url), + { + ...sseOptions, + eventSourceInit: reconnectingEventSourceOptions, + }, + name, + provider.context, + ) // Set up SSE specific error handling transport.onerror = async (error) => { diff --git a/src/services/mcp/oauth/HttpTransportWithOAuth.ts b/src/services/mcp/oauth/HttpTransportWithOAuth.ts new file mode 100644 index 0000000000..6b232f6ad7 --- /dev/null +++ b/src/services/mcp/oauth/HttpTransportWithOAuth.ts @@ -0,0 +1,140 @@ +/** + * HTTP Transport wrapper with OAuth 2.1 support + * Intercepts 401 responses and initiates OAuth flow when needed + */ + +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import * as vscode from "vscode" +import { OAuthManager } from "./OAuthManager" + +export class HttpTransportWithOAuth { + private oauthManager: OAuthManager + private accessToken: string | null = null + private serverName: string + private resourceUrl: string + + constructor( + private transport: SSEClientTransport | StreamableHTTPClientTransport, + serverName: string, + resourceUrl: string, + context: vscode.ExtensionContext, + ) { + this.serverName = serverName + this.resourceUrl = resourceUrl + this.oauthManager = new OAuthManager(context) + this.wrapTransportMethods() + } + + /** + * Wrap transport methods to intercept 401 responses + */ + private wrapTransportMethods(): void { + // Store original fetch method if using custom fetch + const originalFetch = (global as any).fetch || fetch + + // Override global fetch to intercept responses + const interceptedFetch = async (url: string | URL | Request, init?: RequestInit): Promise => { + // Add OAuth token if available + const headers = new Headers(init?.headers || {}) + + // Check if we have an access token + if (!this.accessToken) { + this.accessToken = await this.oauthManager.getAccessToken(this.serverName) + } + + if (this.accessToken) { + headers.set("Authorization", `Bearer ${this.accessToken}`) + } + + // Make the request + const response = await originalFetch(url, { + ...init, + headers, + }) + + // Check for 401 Unauthorized + if (response.status === 401) { + const wwwAuthenticate = response.headers.get("WWW-Authenticate") + + if (wwwAuthenticate) { + // Attempt OAuth flow + const newToken = await this.oauthManager.handle401Response( + this.serverName, + this.resourceUrl, + wwwAuthenticate, + ) + + if (newToken) { + // Update our token + this.accessToken = newToken + + // Retry the request with new token + const retryHeaders = new Headers(init?.headers || {}) + retryHeaders.set("Authorization", `Bearer ${newToken}`) + + return await originalFetch(url, { + ...init, + headers: retryHeaders, + }) + } + } + } + + return response + } + + // Replace global fetch for this transport + ;(global as any).fetch = interceptedFetch + } + + /** + * Get the wrapped transport + */ + public getTransport(): SSEClientTransport | StreamableHTTPClientTransport { + return this.transport + } + + /** + * Clear OAuth credentials for this server + */ + public async clearCredentials(): Promise { + await this.oauthManager.clearServerCredentials(this.serverName) + this.accessToken = null + } + + /** + * Dispose of OAuth manager + */ + public dispose(): void { + this.oauthManager.dispose() + } +} + +/** + * Create an SSE transport with OAuth support + */ +export function createSSETransportWithOAuth( + url: URL, + options: any, + serverName: string, + context: vscode.ExtensionContext, +): SSEClientTransport { + const transport = new SSEClientTransport(url, options) + const wrapper = new HttpTransportWithOAuth(transport, serverName, url.toString(), context) + return transport // Return the original transport, which has been enhanced with OAuth +} + +/** + * Create a StreamableHTTP transport with OAuth support + */ +export function createStreamableHTTPTransportWithOAuth( + url: URL, + options: any, + serverName: string, + context: vscode.ExtensionContext, +): StreamableHTTPClientTransport { + const transport = new StreamableHTTPClientTransport(url, options) + const wrapper = new HttpTransportWithOAuth(transport, serverName, url.toString(), context) + return transport // Return the original transport, which has been enhanced with OAuth +} diff --git a/src/services/mcp/oauth/OAuthClient.ts b/src/services/mcp/oauth/OAuthClient.ts new file mode 100644 index 0000000000..01aaa5804f --- /dev/null +++ b/src/services/mcp/oauth/OAuthClient.ts @@ -0,0 +1,548 @@ +/** + * OAuth 2.1 Client for MCP Server Authentication + * Implements Authorization Code + PKCE flow per OAuth 2.1 specification + */ + +import * as crypto from "crypto" +import * as vscode from "vscode" +import { + AuthorizationServerMetadata, + ClientRegistrationRequest, + ClientRegistrationResponse, + OAuthConfig, + OAuthError, + PKCEChallenge, + ProtectedResourceMetadata, + StoredOAuthCredentials, + TokenResponse, + WWWAuthenticateChallenge, +} from "./types" + +export class OAuthClient { + private static readonly PKCE_VERIFIER_LENGTH = 128 + private static readonly TOKEN_REFRESH_BUFFER_MS = 30 * 1000 // 30 seconds buffer + + constructor( + private readonly serverName: string, + private readonly resourceUrl: string, + private readonly context: vscode.ExtensionContext, + ) {} + + /** + * Parse WWW-Authenticate header to extract OAuth challenge information + * @param header The WWW-Authenticate header value + * @returns Parsed challenge information + */ + public parseWWWAuthenticate(header: string): WWWAuthenticateChallenge { + const result: WWWAuthenticateChallenge = { scheme: "" } + + // Match the scheme (e.g., "Bearer", "Basic", etc.) + const schemeMatch = header.match(/^(\w+)\s+/) + if (!schemeMatch) { + throw new Error("Invalid WWW-Authenticate header format") + } + result.scheme = schemeMatch[1] + + // Parse parameters + const paramsString = header.substring(schemeMatch[0].length) + const paramRegex = /(\w+)="([^"]+)"/g + let match: RegExpExecArray | null + + while ((match = paramRegex.exec(paramsString)) !== null) { + const [, key, value] = match + switch (key) { + case "realm": + result.realm = value + break + case "scope": + result.scope = value + break + case "error": + result.error = value + break + case "error_description": + result.error_description = value + break + case "error_uri": + result.error_uri = value + break + case "resource": + result.resource = value + break + case "as_uri": + result.as_uri = value + break + } + } + + return result + } + + /** + * Discover OAuth Protected Resource Metadata (RFC 9728) + * @param resourceUrl The resource server URL + * @returns Protected resource metadata + */ + public async discoverResourceMetadata(resourceUrl: string): Promise { + const metadataUrl = new URL("/.well-known/oauth-protected-resource", resourceUrl).toString() + + const response = await fetch(metadataUrl, { + method: "GET", + headers: { + Accept: "application/json", + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch resource metadata: ${response.status} ${response.statusText}`) + } + + const metadata = (await response.json()) as ProtectedResourceMetadata + + // Validate required fields + if (!metadata.resource || !metadata.authorization_servers || metadata.authorization_servers.length === 0) { + throw new Error("Invalid resource metadata: missing required fields") + } + + return metadata + } + + /** + * Discover Authorization Server Metadata (RFC 8414 + OIDC Discovery) + * Attempts discovery in the specified order per requirements + * @param issuer The authorization server issuer URL + * @returns Authorization server metadata + */ + public async discoverAuthorizationServerMetadata(issuer: string): Promise { + const issuerUrl = new URL(issuer) + const hasPath = issuerUrl.pathname !== "/" && issuerUrl.pathname !== "" + + const discoveryUrls: string[] = [] + + if (hasPath) { + // Issuer with path component + const pathComponent = issuerUrl.pathname.replace(/^\//, "").replace(/\/$/, "") + discoveryUrls.push( + new URL(`/.well-known/oauth-authorization-server/${pathComponent}`, issuerUrl.origin).toString(), + new URL(`/.well-known/openid-configuration/${pathComponent}`, issuerUrl.origin).toString(), + new URL(`${issuerUrl.pathname}/.well-known/openid-configuration`, issuerUrl.origin).toString(), + ) + } else { + // Issuer without path component + discoveryUrls.push( + new URL("/.well-known/oauth-authorization-server", issuerUrl.origin).toString(), + new URL("/.well-known/openid-configuration", issuerUrl.origin).toString(), + ) + } + + // Try each discovery URL in order + let lastError: Error | null = null + for (const url of discoveryUrls) { + try { + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }) + + if (response.ok) { + const metadata = (await response.json()) as AuthorizationServerMetadata + + // Validate required fields + if (!metadata.issuer || !metadata.authorization_endpoint || !metadata.token_endpoint) { + continue // Try next URL + } + + // Validate PKCE support + if (!this.validatePKCESupport(metadata)) { + throw new Error( + "Authorization server does not support PKCE with S256 method, which is required by OAuth 2.1", + ) + } + + return metadata + } + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + // Continue to next URL + } + } + + throw lastError || new Error("Failed to discover authorization server metadata") + } + + /** + * Validate PKCE support in authorization server metadata + * @param metadata Authorization server metadata + * @returns true if PKCE S256 is supported + */ + private validatePKCESupport(metadata: AuthorizationServerMetadata): boolean { + // For RFC 8414: if code_challenge_methods_supported is absent, refuse + // For OIDC Discovery: verify code_challenge_methods_supported present, if absent refuse + if (!metadata.code_challenge_methods_supported) { + return false + } + + // Check if S256 is supported + return metadata.code_challenge_methods_supported.includes("S256") + } + + /** + * Perform Dynamic Client Registration (RFC 7591) + * @param metadata Authorization server metadata + * @returns Client registration response + */ + public async registerClient(metadata: AuthorizationServerMetadata): Promise { + if (!metadata.registration_endpoint) { + throw new Error("Authorization server does not support dynamic client registration") + } + + // Determine redirect URI based on environment + const redirectUri = this.getRedirectUri() + + const registrationRequest: ClientRegistrationRequest = { + client_name: `Roo Code MCP Client - ${this.serverName}`, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", // Public client + scope: "openid profile", // Request basic OIDC scopes if available + software_id: "roo-code-mcp-client", + software_version: this.context.extension.packageJSON.version, + } + + const response = await fetch(metadata.registration_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(registrationRequest), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error( + `Client registration failed: ${error.error || response.status} - ${ + error.error_description || response.statusText + }`, + ) + } + + const registration = (await response.json()) as ClientRegistrationResponse + + // Store client credentials + await this.storeClientCredentials(registration) + + return registration + } + + /** + * Generate PKCE challenge for authorization request + * @returns PKCE challenge parameters + */ + public generatePKCEChallenge(): PKCEChallenge { + // Generate code verifier (128 characters from unreserved characters) + const verifier = crypto + .randomBytes(OAuthClient.PKCE_VERIFIER_LENGTH) + .toString("base64url") + .substring(0, OAuthClient.PKCE_VERIFIER_LENGTH) + + // Generate code challenge using S256 method + const challenge = crypto.createHash("sha256").update(verifier).digest("base64url") + + return { + code_verifier: verifier, + code_challenge: challenge, + code_challenge_method: "S256", + } + } + + /** + * Build authorization URL for user consent + * @param metadata Authorization server metadata + * @param clientId Client ID + * @param pkce PKCE challenge + * @param resource Resource indicator (RFC 8707) + * @param scope Optional scope + * @returns Authorization URL + */ + public buildAuthorizationUrl( + metadata: AuthorizationServerMetadata, + clientId: string, + pkce: PKCEChallenge, + resource: string, + scope?: string, + ): string { + const redirectUri = this.getRedirectUri() + const state = crypto.randomBytes(32).toString("base64url") + + // Store state for validation + this.context.globalState.update(`oauth_state_${this.serverName}`, state) + + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: redirectUri, + state, + code_challenge: pkce.code_challenge, + code_challenge_method: pkce.code_challenge_method, + resource, // RFC 8707 - Resource Indicators + }) + + if (scope) { + params.append("scope", scope) + } + + return `${metadata.authorization_endpoint}?${params.toString()}` + } + + /** + * Exchange authorization code for tokens + * @param metadata Authorization server metadata + * @param code Authorization code + * @param clientId Client ID + * @param clientSecret Optional client secret + * @param pkce PKCE verifier + * @param resource Resource indicator + * @returns Token response + */ + public async exchangeCodeForTokens( + metadata: AuthorizationServerMetadata, + code: string, + clientId: string, + clientSecret: string | undefined, + pkce: PKCEChallenge, + resource: string, + ): Promise { + const redirectUri = this.getRedirectUri() + + const params = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: pkce.code_verifier, + resource, // RFC 8707 - Include resource in token request + }) + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + } + + // Add client authentication if confidential client + if (clientSecret) { + const auth = Buffer.from(`${clientId}:${clientSecret}`).toString("base64") + headers["Authorization"] = `Basic ${auth}` + } + + const response = await fetch(metadata.token_endpoint, { + method: "POST", + headers, + body: params.toString(), + }) + + if (!response.ok) { + const error = (await response.json()) as OAuthError + throw new Error(`Token exchange failed: ${error.error} - ${error.error_description || ""}`) + } + + const tokens = (await response.json()) as TokenResponse + + // Store tokens + await this.storeTokens(tokens, clientId, clientSecret) + + return tokens + } + + /** + * Refresh access token using refresh token + * @param metadata Authorization server metadata + * @param refreshToken Refresh token + * @param clientId Client ID + * @param clientSecret Optional client secret + * @param resource Resource indicator + * @returns New token response + */ + public async refreshAccessToken( + metadata: AuthorizationServerMetadata, + refreshToken: string, + clientId: string, + clientSecret: string | undefined, + resource: string, + ): Promise { + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + resource, // RFC 8707 - Include resource in refresh request + }) + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + } + + // Add client authentication if confidential client + if (clientSecret) { + const auth = Buffer.from(`${clientId}:${clientSecret}`).toString("base64") + headers["Authorization"] = `Basic ${auth}` + } + + const response = await fetch(metadata.token_endpoint, { + method: "POST", + headers, + body: params.toString(), + }) + + if (!response.ok) { + const error = (await response.json()) as OAuthError + throw new Error(`Token refresh failed: ${error.error} - ${error.error_description || ""}`) + } + + const tokens = (await response.json()) as TokenResponse + + // Update stored tokens + await this.storeTokens(tokens, clientId, clientSecret) + + return tokens + } + + /** + * Get stored OAuth credentials + * @returns Stored credentials or null + */ + public async getStoredCredentials(): Promise { + const key = `oauth_credentials_${this.serverName}` + return this.context.globalState.get(key) || null + } + + /** + * Check if stored token is expired or about to expire + * @param credentials Stored credentials + * @returns true if token needs refresh + */ + public isTokenExpired(credentials: StoredOAuthCredentials): boolean { + if (!credentials.expiresAt) { + return false // No expiry information, assume valid + } + + const now = Date.now() + return now >= credentials.expiresAt - OAuthClient.TOKEN_REFRESH_BUFFER_MS + } + + /** + * Clear stored OAuth credentials + */ + public async clearCredentials(): Promise { + const keys = [ + `oauth_credentials_${this.serverName}`, + `oauth_client_${this.serverName}`, + `oauth_state_${this.serverName}`, + `oauth_pkce_${this.serverName}`, + ] + + for (const key of keys) { + await this.context.globalState.update(key, undefined) + } + } + + /** + * Get redirect URI for OAuth flow + * @returns Redirect URI + */ + private getRedirectUri(): string { + // Use VS Code's built-in URI handler for OAuth callbacks + return `vscode://RooCodeInc.roo-code/oauth-callback` + } + + /** + * Store client registration details + * @param registration Client registration response + */ + private async storeClientCredentials(registration: ClientRegistrationResponse): Promise { + const key = `oauth_client_${this.serverName}` + await this.context.globalState.update(key, registration) + } + + /** + * Store OAuth tokens + * @param tokens Token response + * @param clientId Client ID + * @param clientSecret Optional client secret + */ + private async storeTokens( + tokens: TokenResponse, + clientId: string, + clientSecret: string | undefined, + ): Promise { + const credentials: StoredOAuthCredentials = { + serverName: this.serverName, + serverUrl: this.resourceUrl, + clientId, + clientSecret, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + tokenType: tokens.token_type, + scope: tokens.scope, + expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined, + } + + const key = `oauth_credentials_${this.serverName}` + await this.context.globalState.update(key, credentials) + } + + /** + * Get stored client registration + * @returns Client registration or null + */ + public async getStoredClientRegistration(): Promise { + const key = `oauth_client_${this.serverName}` + return this.context.globalState.get(key) || null + } + + /** + * Validate state parameter from OAuth callback + * @param state State parameter from callback + * @returns true if state is valid + */ + public async validateState(state: string): Promise { + const key = `oauth_state_${this.serverName}` + const storedState = await this.context.globalState.get(key) + + if (!storedState || storedState !== state) { + return false + } + + // Clear state after validation + await this.context.globalState.update(key, undefined) + return true + } + + /** + * Store PKCE verifier for later use + * @param pkce PKCE challenge + */ + public async storePKCEVerifier(pkce: PKCEChallenge): Promise { + const key = `oauth_pkce_${this.serverName}` + await this.context.globalState.update(key, pkce) + } + + /** + * Get stored PKCE verifier + * @returns PKCE challenge or null + */ + public async getStoredPKCEVerifier(): Promise { + const key = `oauth_pkce_${this.serverName}` + const pkce = await this.context.globalState.get(key) + + if (pkce) { + // Clear PKCE after retrieval for security + await this.context.globalState.update(key, undefined) + } + + return pkce || null + } +} diff --git a/src/services/mcp/oauth/OAuthManager.ts b/src/services/mcp/oauth/OAuthManager.ts new file mode 100644 index 0000000000..9618de043e --- /dev/null +++ b/src/services/mcp/oauth/OAuthManager.ts @@ -0,0 +1,449 @@ +/** + * OAuth Manager for MCP Servers + * Orchestrates the complete OAuth 2.1 flow for HTTP-based MCP servers + */ + +import * as vscode from "vscode" +import { OAuthClient } from "./OAuthClient" +import { + AuthorizationServerMetadata, + ClientRegistrationResponse, + OAuthConfig, + PKCEChallenge, + StoredOAuthCredentials, + TokenResponse, + WWWAuthenticateChallenge, +} from "./types" + +export class OAuthManager { + private oauthClients: Map = new Map() + private authorizationInProgress: Map = new Map() + + constructor(private readonly context: vscode.ExtensionContext) { + // Register URI handler for OAuth callbacks + this.registerOAuthCallbackHandler() + } + + /** + * Handle 401 Unauthorized response from MCP server + * Initiates OAuth flow if WWW-Authenticate header is present + * @param serverName MCP server name + * @param resourceUrl Resource server URL + * @param wwwAuthenticateHeader WWW-Authenticate header value + * @returns Access token if successful, null otherwise + */ + public async handle401Response( + serverName: string, + resourceUrl: string, + wwwAuthenticateHeader: string, + ): Promise { + try { + // Check if authorization is already in progress + if (this.authorizationInProgress.get(serverName)) { + vscode.window.showWarningMessage(`OAuth authorization already in progress for ${serverName}`) + return null + } + + // Get or create OAuth client for this server + let client = this.oauthClients.get(serverName) + if (!client) { + client = new OAuthClient(serverName, resourceUrl, this.context) + this.oauthClients.set(serverName, client) + } + + // Parse WWW-Authenticate header + const challenge = client.parseWWWAuthenticate(wwwAuthenticateHeader) + + if (challenge.scheme !== "Bearer") { + vscode.window.showErrorMessage( + `Unsupported authentication scheme: ${challenge.scheme}. Only Bearer is supported.`, + ) + return null + } + + // Check for existing valid credentials + const existingCredentials = await client.getStoredCredentials() + if (existingCredentials && !client.isTokenExpired(existingCredentials)) { + return existingCredentials.accessToken + } + + // If we have a refresh token, try to refresh + if (existingCredentials?.refreshToken) { + try { + const tokens = await this.refreshToken(client, existingCredentials) + return tokens.access_token + } catch (error) { + console.error("Failed to refresh token, initiating new authorization:", error) + // Continue with new authorization flow + } + } + + // Start OAuth flow + this.authorizationInProgress.set(serverName, true) + + try { + // Step 1: Discover resource metadata (RFC 9728) + vscode.window.showInformationMessage(`Discovering OAuth configuration for ${serverName}...`) + const resourceMetadata = await client.discoverResourceMetadata(resourceUrl) + + // Step 2: Select authorization server (for now, use the first one) + const authServerUrl = resourceMetadata.authorization_servers[0] + + // Step 3: Discover authorization server metadata (RFC 8414 + OIDC) + const authMetadata = await client.discoverAuthorizationServerMetadata(authServerUrl) + + // Step 4: Check for existing client registration or perform dynamic registration + let clientRegistration = await client.getStoredClientRegistration() + + if (!clientRegistration && authMetadata.registration_endpoint) { + // Perform dynamic client registration (RFC 7591) + vscode.window.showInformationMessage(`Registering client with authorization server...`) + clientRegistration = await client.registerClient(authMetadata) + } else if (!clientRegistration) { + // No dynamic registration available, need manual configuration + clientRegistration = await this.promptForClientCredentials(serverName) + if (!clientRegistration) { + return null + } + } + + // Step 5: Generate PKCE challenge + const pkce = client.generatePKCEChallenge() + await client.storePKCEVerifier(pkce) + + // Step 6: Build authorization URL + const authUrl = client.buildAuthorizationUrl( + authMetadata, + clientRegistration.client_id, + pkce, + resourceUrl, // Use resource URL as resource indicator + clientRegistration.scope, + ) + + // Step 7: Open browser for user authorization + const authorized = await this.openAuthorizationUrl(authUrl, serverName) + if (!authorized) { + return null + } + + // Step 8: Wait for callback (handled by URI handler) + const tokens = await this.waitForTokens(serverName) + if (!tokens) { + return null + } + + return tokens.access_token + } finally { + this.authorizationInProgress.delete(serverName) + } + } catch (error) { + console.error("OAuth flow failed:", error) + vscode.window.showErrorMessage( + `OAuth authentication failed for ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + ) + this.authorizationInProgress.delete(serverName) + return null + } + } + + /** + * Get valid access token for MCP server + * Refreshes token if needed + * @param serverName MCP server name + * @returns Access token or null + */ + public async getAccessToken(serverName: string): Promise { + const client = this.oauthClients.get(serverName) + if (!client) { + return null + } + + const credentials = await client.getStoredCredentials() + if (!credentials) { + return null + } + + // Check if token needs refresh + if (client.isTokenExpired(credentials)) { + if (!credentials.refreshToken) { + // No refresh token, need new authorization + return null + } + + try { + const tokens = await this.refreshToken(client, credentials) + return tokens.access_token + } catch (error) { + console.error("Failed to refresh token:", error) + // Clear invalid credentials + await client.clearCredentials() + return null + } + } + + return credentials.accessToken + } + + /** + * Refresh access token + * @param client OAuth client + * @param credentials Stored credentials + * @returns New tokens + */ + private async refreshToken(client: OAuthClient, credentials: StoredOAuthCredentials): Promise { + // Discover authorization server metadata + const authServerUrl = await this.getAuthServerUrl(credentials.serverUrl) + if (!authServerUrl) { + throw new Error("Cannot determine authorization server URL") + } + + const authMetadata = await client.discoverAuthorizationServerMetadata(authServerUrl) + + // Refresh token + return await client.refreshAccessToken( + authMetadata, + credentials.refreshToken!, + credentials.clientId, + credentials.clientSecret, + credentials.serverUrl, + ) + } + + /** + * Get authorization server URL for a resource + * @param resourceUrl Resource server URL + * @returns Authorization server URL or null + */ + private async getAuthServerUrl(resourceUrl: string): Promise { + try { + // Create temporary client to discover metadata + const tempClient = new OAuthClient("temp", resourceUrl, this.context) + const resourceMetadata = await tempClient.discoverResourceMetadata(resourceUrl) + return resourceMetadata.authorization_servers[0] || null + } catch (error) { + console.error("Failed to get authorization server URL:", error) + return null + } + } + + /** + * Prompt user for manual client credentials + * @param serverName Server name + * @returns Client registration or null + */ + private async promptForClientCredentials(serverName: string): Promise { + const clientId = await vscode.window.showInputBox({ + prompt: `Enter Client ID for ${serverName}`, + placeHolder: "client-id", + ignoreFocusOut: true, + }) + + if (!clientId) { + return null + } + + const clientSecret = await vscode.window.showInputBox({ + prompt: `Enter Client Secret for ${serverName} (leave empty for public client)`, + placeHolder: "client-secret (optional)", + password: true, + ignoreFocusOut: true, + }) + + const redirectUri = `vscode://RooCodeInc.roo-code/oauth-callback` + + return { + client_id: clientId, + client_secret: clientSecret || undefined, + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + } + } + + /** + * Open authorization URL in browser + * @param authUrl Authorization URL + * @param serverName Server name + * @returns true if user proceeded with authorization + */ + private async openAuthorizationUrl(authUrl: string, serverName: string): Promise { + const result = await vscode.window.showInformationMessage( + `Authorization required for MCP server "${serverName}". Click "Authorize" to open your browser and grant access.`, + "Authorize", + "Cancel", + ) + + if (result !== "Authorize") { + return false + } + + // Open browser + await vscode.env.openExternal(vscode.Uri.parse(authUrl)) + return true + } + + /** + * Wait for OAuth tokens after authorization + * @param serverName Server name + * @returns Tokens or null + */ + private async waitForTokens(serverName: string): Promise { + return new Promise((resolve) => { + // Set up a timeout + const timeout = setTimeout( + () => { + vscode.window.showErrorMessage(`OAuth authorization timed out for ${serverName}`) + resolve(null) + }, + 5 * 60 * 1000, + ) // 5 minutes timeout + + // Check for tokens periodically + const checkInterval = setInterval(async () => { + const client = this.oauthClients.get(serverName) + if (!client) { + clearInterval(checkInterval) + clearTimeout(timeout) + resolve(null) + return + } + + const credentials = await client.getStoredCredentials() + if (credentials) { + clearInterval(checkInterval) + clearTimeout(timeout) + resolve({ + access_token: credentials.accessToken, + token_type: credentials.tokenType, + refresh_token: credentials.refreshToken, + scope: credentials.scope, + expires_in: credentials.expiresAt + ? Math.floor((credentials.expiresAt - Date.now()) / 1000) + : undefined, + }) + } + }, 1000) // Check every second + }) + } + + /** + * Register URI handler for OAuth callbacks + */ + private registerOAuthCallbackHandler(): void { + vscode.window.registerUriHandler({ + handleUri: async (uri: vscode.Uri) => { + if (uri.path === "/oauth-callback") { + await this.handleOAuthCallback(uri) + } + }, + }) + } + + /** + * Handle OAuth callback from browser + * @param uri Callback URI with authorization code + */ + private async handleOAuthCallback(uri: vscode.Uri): Promise { + try { + // Parse query parameters + const params = new URLSearchParams(uri.query) + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + const errorDescription = params.get("error_description") + + if (error) { + vscode.window.showErrorMessage(`OAuth authorization failed: ${error} - ${errorDescription || ""}`) + return + } + + if (!code || !state) { + vscode.window.showErrorMessage("Invalid OAuth callback: missing code or state") + return + } + + // Find the client that initiated this flow + // We need to validate state to determine which server this is for + let targetClient: OAuthClient | null = null + let targetServerName: string | null = null + + for (const [serverName, client] of this.oauthClients) { + if (await client.validateState(state)) { + targetClient = client + targetServerName = serverName + break + } + } + + if (!targetClient || !targetServerName) { + vscode.window.showErrorMessage("OAuth callback received for unknown session") + return + } + + // Get stored PKCE verifier + const pkce = await targetClient.getStoredPKCEVerifier() + if (!pkce) { + vscode.window.showErrorMessage("OAuth callback received but PKCE verifier not found") + return + } + + // Get client registration + const clientRegistration = await targetClient.getStoredClientRegistration() + if (!clientRegistration) { + vscode.window.showErrorMessage("OAuth callback received but client registration not found") + return + } + + // Get authorization server metadata + const credentials = await targetClient.getStoredCredentials() + const resourceUrl = credentials?.serverUrl || "" + const authServerUrl = await this.getAuthServerUrl(resourceUrl) + + if (!authServerUrl) { + vscode.window.showErrorMessage("Cannot determine authorization server URL") + return + } + + const authMetadata = await targetClient.discoverAuthorizationServerMetadata(authServerUrl) + + // Exchange code for tokens + vscode.window.showInformationMessage(`Completing OAuth authorization for ${targetServerName}...`) + + const tokens = await targetClient.exchangeCodeForTokens( + authMetadata, + code, + clientRegistration.client_id, + clientRegistration.client_secret, + pkce, + resourceUrl, + ) + + vscode.window.showInformationMessage(`Successfully authorized MCP server "${targetServerName}"`) + } catch (error) { + console.error("OAuth callback handling failed:", error) + vscode.window.showErrorMessage( + `Failed to complete OAuth authorization: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Clear OAuth credentials for a server + * @param serverName Server name + */ + public async clearServerCredentials(serverName: string): Promise { + const client = this.oauthClients.get(serverName) + if (client) { + await client.clearCredentials() + this.oauthClients.delete(serverName) + } + } + + /** + * Dispose of OAuth manager + */ + public dispose(): void { + this.oauthClients.clear() + this.authorizationInProgress.clear() + } +} diff --git a/src/services/mcp/oauth/types.ts b/src/services/mcp/oauth/types.ts new file mode 100644 index 0000000000..52d1349638 --- /dev/null +++ b/src/services/mcp/oauth/types.ts @@ -0,0 +1,181 @@ +/** + * OAuth 2.1 types and interfaces for MCP server authentication + * Implements RFC 6749, RFC 7636 (PKCE), RFC 8414, RFC 8707, RFC 9728 + */ + +/** + * OAuth 2.0 Protected Resource Metadata (RFC 9728) + */ +export interface ProtectedResourceMetadata { + resource: string + authorization_servers: string[] + bearer_methods_supported?: string[] + resource_documentation?: string + resource_policy_uri?: string + resource_tos_uri?: string +} + +/** + * OAuth 2.0 Authorization Server Metadata (RFC 8414) + */ +export interface AuthorizationServerMetadata { + issuer: string + authorization_endpoint: string + token_endpoint: string + jwks_uri?: string + registration_endpoint?: string + scopes_supported?: string[] + response_types_supported: string[] + response_modes_supported?: string[] + grant_types_supported?: string[] + token_endpoint_auth_methods_supported?: string[] + token_endpoint_auth_signing_alg_values_supported?: string[] + service_documentation?: string + ui_locales_supported?: string[] + op_policy_uri?: string + op_tos_uri?: string + revocation_endpoint?: string + revocation_endpoint_auth_methods_supported?: string[] + introspection_endpoint?: string + introspection_endpoint_auth_methods_supported?: string[] + code_challenge_methods_supported?: string[] + // Additional OIDC Discovery fields + userinfo_endpoint?: string + end_session_endpoint?: string + check_session_iframe?: string + acr_values_supported?: string[] + subject_types_supported?: string[] + id_token_signing_alg_values_supported?: string[] + id_token_encryption_alg_values_supported?: string[] + id_token_encryption_enc_values_supported?: string[] + userinfo_signing_alg_values_supported?: string[] + userinfo_encryption_alg_values_supported?: string[] + userinfo_encryption_enc_values_supported?: string[] + request_object_signing_alg_values_supported?: string[] + request_object_encryption_alg_values_supported?: string[] + request_object_encryption_enc_values_supported?: string[] + display_values_supported?: string[] + claim_types_supported?: string[] + claims_supported?: string[] + claims_locales_supported?: string[] + claims_parameter_supported?: boolean + request_parameter_supported?: boolean + request_uri_parameter_supported?: boolean + require_request_uri_registration?: boolean +} + +/** + * Dynamic Client Registration Request (RFC 7591) + */ +export interface ClientRegistrationRequest { + redirect_uris: string[] + client_name?: string + client_uri?: string + logo_uri?: string + scope?: string + contacts?: string[] + tos_uri?: string + policy_uri?: string + jwks_uri?: string + jwks?: any + software_id?: string + software_version?: string + grant_types?: string[] + response_types?: string[] + token_endpoint_auth_method?: string +} + +/** + * Dynamic Client Registration Response (RFC 7591) + */ +export interface ClientRegistrationResponse { + client_id: string + client_secret?: string + client_id_issued_at?: number + client_secret_expires_at?: number + redirect_uris: string[] + grant_types?: string[] + response_types?: string[] + token_endpoint_auth_method?: string + client_name?: string + client_uri?: string + logo_uri?: string + scope?: string + contacts?: string[] + tos_uri?: string + policy_uri?: string + jwks_uri?: string + jwks?: any + software_id?: string + software_version?: string +} + +/** + * OAuth 2.0 Token Response + */ +export interface TokenResponse { + access_token: string + token_type: string + expires_in?: number + refresh_token?: string + scope?: string + id_token?: string // For OIDC +} + +/** + * OAuth 2.0 Error Response + */ +export interface OAuthError { + error: string + error_description?: string + error_uri?: string +} + +/** + * PKCE (RFC 7636) parameters + */ +export interface PKCEChallenge { + code_verifier: string + code_challenge: string + code_challenge_method: "S256" +} + +/** + * Stored OAuth credentials for an MCP server + */ +export interface StoredOAuthCredentials { + serverName: string + serverUrl: string + clientId: string + clientSecret?: string + accessToken: string + refreshToken?: string + expiresAt?: number + scope?: string + tokenType: string +} + +/** + * OAuth configuration for an MCP server + */ +export interface OAuthConfig { + clientId?: string + clientSecret?: string + scope?: string + authorizationServerUrl?: string + resourceUrl: string +} + +/** + * WWW-Authenticate header parsed values + */ +export interface WWWAuthenticateChallenge { + scheme: string + realm?: string + scope?: string + error?: string + error_description?: string + error_uri?: string + resource?: string + as_uri?: string +}