diff options
Diffstat (limited to 'packages/core/src/mcp/oauth-provider.ts')
| -rw-r--r-- | packages/core/src/mcp/oauth-provider.ts | 281 |
1 files changed, 214 insertions, 67 deletions
diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index eaec5c2e..c2579d1a 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -144,8 +144,8 @@ export class MCPOAuthProvider { private static async discoverOAuthFromMCPServer( mcpServerUrl: string, ): Promise<MCPOAuthConfig | null> { - const baseUrl = OAuthUtils.extractBaseUrl(mcpServerUrl); - return OAuthUtils.discoverOAuthConfig(baseUrl); + // Use the full URL with path preserved for OAuth discovery + return OAuthUtils.discoverOAuthConfig(mcpServerUrl); } /** @@ -302,14 +302,18 @@ export class MCPOAuthProvider { } // 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)}`, - ); + // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) + if (mcpServerUrl) { + try { + params.append( + 'resource', + OAuthUtils.buildResourceParameter(mcpServerUrl), + ); + } catch (error) { + console.warn( + `Could not add resource parameter: ${getErrorMessage(error)}`, + ); + } } const url = new URL(config.authorizationUrl!); @@ -355,32 +359,93 @@ export class MCPOAuthProvider { } // 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)}`, - ); + // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) + if (mcpServerUrl) { + const resourceUrl = mcpServerUrl; + try { + params.append( + 'resource', + OAuthUtils.buildResourceParameter(resourceUrl), + ); + } catch (error) { + console.warn( + `Could not add resource parameter: ${getErrorMessage(error)}`, + ); + } } const response = await fetch(config.tokenUrl!, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json, application/x-www-form-urlencoded', }, body: params.toString(), }); + const responseText = await response.text(); + const contentType = response.headers.get('content-type') || ''; + if (!response.ok) { - const errorText = await response.text(); + // Try to parse error from form-urlencoded response + let errorMessage: string | null = null; + try { + const errorParams = new URLSearchParams(responseText); + const error = errorParams.get('error'); + const errorDescription = errorParams.get('error_description'); + if (error) { + errorMessage = `Token exchange failed: ${error} - ${errorDescription || 'No description'}`; + } + } catch { + // Fall back to raw error + } throw new Error( - `Token exchange failed: ${response.status} - ${errorText}`, + errorMessage || + `Token exchange failed: ${response.status} - ${responseText}`, + ); + } + + // Log unexpected content types for debugging + if ( + !contentType.includes('application/json') && + !contentType.includes('application/x-www-form-urlencoded') + ) { + console.warn( + `Token endpoint returned unexpected content-type: ${contentType}. ` + + `Expected application/json or application/x-www-form-urlencoded. ` + + `Will attempt to parse response.`, ); } - return (await response.json()) as OAuthTokenResponse; + // Try to parse as JSON first, fall back to form-urlencoded + try { + return JSON.parse(responseText) as OAuthTokenResponse; + } catch { + // Parse form-urlencoded response + const tokenParams = new URLSearchParams(responseText); + const accessToken = tokenParams.get('access_token'); + const tokenType = tokenParams.get('token_type') || 'Bearer'; + const expiresIn = tokenParams.get('expires_in'); + const refreshToken = tokenParams.get('refresh_token'); + const scope = tokenParams.get('scope'); + + if (!accessToken) { + // Check for error in response + const error = tokenParams.get('error'); + const errorDescription = tokenParams.get('error_description'); + throw new Error( + `Token exchange failed: ${error || 'no_access_token'} - ${errorDescription || responseText}`, + ); + } + + return { + access_token: accessToken, + token_type: tokenType, + expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined, + refresh_token: refreshToken || undefined, + scope: scope || undefined, + } as OAuthTokenResponse; + } } /** @@ -417,32 +482,92 @@ export class MCPOAuthProvider { } // 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)}`, - ); + // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth) + if (mcpServerUrl) { + try { + params.append( + 'resource', + OAuthUtils.buildResourceParameter(mcpServerUrl), + ); + } catch (error) { + console.warn( + `Could not add resource parameter: ${getErrorMessage(error)}`, + ); + } } const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json, application/x-www-form-urlencoded', }, body: params.toString(), }); + const responseText = await response.text(); + const contentType = response.headers.get('content-type') || ''; + if (!response.ok) { - const errorText = await response.text(); + // Try to parse error from form-urlencoded response + let errorMessage: string | null = null; + try { + const errorParams = new URLSearchParams(responseText); + const error = errorParams.get('error'); + const errorDescription = errorParams.get('error_description'); + if (error) { + errorMessage = `Token refresh failed: ${error} - ${errorDescription || 'No description'}`; + } + } catch { + // Fall back to raw error + } throw new Error( - `Token refresh failed: ${response.status} - ${errorText}`, + errorMessage || + `Token refresh failed: ${response.status} - ${responseText}`, ); } - return (await response.json()) as OAuthTokenResponse; + // Log unexpected content types for debugging + if ( + !contentType.includes('application/json') && + !contentType.includes('application/x-www-form-urlencoded') + ) { + console.warn( + `Token refresh endpoint returned unexpected content-type: ${contentType}. ` + + `Expected application/json or application/x-www-form-urlencoded. ` + + `Will attempt to parse response.`, + ); + } + + // Try to parse as JSON first, fall back to form-urlencoded + try { + return JSON.parse(responseText) as OAuthTokenResponse; + } catch { + // Parse form-urlencoded response + const tokenParams = new URLSearchParams(responseText); + const accessToken = tokenParams.get('access_token'); + const tokenType = tokenParams.get('token_type') || 'Bearer'; + const expiresIn = tokenParams.get('expires_in'); + const refreshToken = tokenParams.get('refresh_token'); + const scope = tokenParams.get('scope'); + + if (!accessToken) { + // Check for error in response + const error = tokenParams.get('error'); + const errorDescription = tokenParams.get('error_description'); + throw new Error( + `Token refresh failed: ${error || 'unknown_error'} - ${errorDescription || responseText}`, + ); + } + + return { + access_token: accessToken, + token_type: tokenType, + expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined, + refresh_token: refreshToken || undefined, + scope: scope || undefined, + } as OAuthTokenResponse; + } } /** @@ -464,37 +589,43 @@ export class MCPOAuthProvider { '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', - }, - }); + // First check if the server requires authentication via WWW-Authenticate header + try { + const headers: HeadersInit = OAuthUtils.isSSEEndpoint(mcpServerUrl) + ? { Accept: 'text/event-stream' } + : { Accept: 'application/json' }; + + const response = await fetch(mcpServerUrl, { + method: 'HEAD', + headers, + }); + + if (response.status === 401 || response.status === 307) { + const wwwAuthenticate = response.headers.get('www-authenticate'); - 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 || [], - }; - } + if (wwwAuthenticate) { + const discoveredConfig = + await OAuthUtils.discoverOAuthFromWWWAuthenticate( + wwwAuthenticate, + ); + if (discoveredConfig) { + // Merge discovered config with existing config, preserving clientId and clientSecret + config = { + ...config, + authorizationUrl: discoveredConfig.authorizationUrl, + tokenUrl: discoveredConfig.tokenUrl, + scopes: discoveredConfig.scopes || config.scopes || [], + // Preserve existing client credentials + clientId: config.clientId, + clientSecret: config.clientSecret, + }; } } - } catch (error) { - console.debug( - `Failed to check SSE endpoint for authentication requirements: ${getErrorMessage(error)}`, - ); } + } catch (error) { + console.debug( + `Failed to check endpoint for authentication requirements: ${getErrorMessage(error)}`, + ); } // If we still don't have OAuth config, try the standard discovery @@ -502,8 +633,16 @@ export class MCPOAuthProvider { const discoveredConfig = await this.discoverOAuthFromMCPServer(mcpServerUrl); if (discoveredConfig) { - config = { ...config, ...discoveredConfig }; - console.log('OAuth configuration discovered successfully'); + // Merge discovered config with existing config, preserving clientId and clientSecret + config = { + ...config, + authorizationUrl: discoveredConfig.authorizationUrl, + tokenUrl: discoveredConfig.tokenUrl, + scopes: discoveredConfig.scopes || config.scopes || [], + // Preserve existing client credentials + clientId: config.clientId, + clientSecret: config.clientSecret, + }; } else { throw new Error( 'Failed to discover OAuth configuration from MCP server', @@ -633,9 +772,13 @@ export class MCPOAuthProvider { ); // Convert to our token format + if (!tokenResponse.access_token) { + throw new Error('No access token received from token endpoint'); + } + const token: MCPOAuthToken = { accessToken: tokenResponse.access_token, - tokenType: tokenResponse.token_type, + tokenType: tokenResponse.token_type || 'Bearer', refreshToken: tokenResponse.refresh_token, scope: tokenResponse.scope, }; @@ -657,12 +800,16 @@ export class MCPOAuthProvider { // Verify token was saved const savedToken = await MCPOAuthTokenStorage.getToken(serverName); - if (savedToken) { - console.log( - `Token verification successful: ${savedToken.token.accessToken.substring(0, 20)}...`, - ); + if (savedToken && savedToken.token && savedToken.token.accessToken) { + const tokenPreview = + savedToken.token.accessToken.length > 20 + ? `${savedToken.token.accessToken.substring(0, 20)}...` + : '[token]'; + console.log(`Token verification successful: ${tokenPreview}`); } else { - console.error('Token verification failed: token not found after save'); + console.error( + 'Token verification failed: token not found or invalid after save', + ); } } catch (saveError) { console.error(`Failed to save token: ${getErrorMessage(saveError)}`); |
