feat: add OAuth 2.1 support for HTTP MCP servers

- Implement OAuth 2.1 client with PKCE support (RFC 7636)
- Add resource server discovery (RFC 9728)
- Add authorization server discovery (RFC 8414 + OIDC Discovery)
- Support dynamic client registration (RFC 7591)
- Integrate OAuth flow into SSE and StreamableHTTP transports
- Add secure token storage and refresh mechanism
- Include resource indicators (RFC 8707) in auth requests

This implementation allows Roo to automatically handle OAuth 2.1
authentication when connecting to protected HTTP-based MCP servers,
following all required RFCs for secure authentication flow.
This commit is contained in:
Roo Code 2025-09-18 01:41:42 +00:00
parent 87b45def18
commit 31ce0535d7
5 changed files with 1350 additions and 10 deletions

View file

@ -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) => {

View file

@ -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<Response> => {
// 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<void> {
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
}

View file

@ -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<ProtectedResourceMetadata> {
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<AuthorizationServerMetadata> {
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<ClientRegistrationResponse> {
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<TokenResponse> {
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<string, string> = {
"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<TokenResponse> {
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<string, string> = {
"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<StoredOAuthCredentials | null> {
const key = `oauth_credentials_${this.serverName}`
return this.context.globalState.get<StoredOAuthCredentials>(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<void> {
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<void> {
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<void> {
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<ClientRegistrationResponse | null> {
const key = `oauth_client_${this.serverName}`
return this.context.globalState.get<ClientRegistrationResponse>(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<boolean> {
const key = `oauth_state_${this.serverName}`
const storedState = await this.context.globalState.get<string>(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<void> {
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<PKCEChallenge | null> {
const key = `oauth_pkce_${this.serverName}`
const pkce = await this.context.globalState.get<PKCEChallenge>(key)
if (pkce) {
// Clear PKCE after retrieval for security
await this.context.globalState.update(key, undefined)
}
return pkce || null
}
}

View file

@ -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<string, OAuthClient> = new Map()
private authorizationInProgress: Map<string, boolean> = 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<string | null> {
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<string | null> {
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<TokenResponse> {
// 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<string | null> {
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<ClientRegistrationResponse | null> {
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<boolean> {
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<TokenResponse | null> {
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<void> {
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<void> {
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()
}
}

View file

@ -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
}