feat: add OAuth authentication support for streamable-http MCP servers

- Implement OAuthHandler to manage OAuth flows and token storage
- Create OAuthStreamableHTTPClientTransport wrapper for OAuth-enabled connections
- Add OAuth configuration support to MCP server schema
- Store OAuth tokens securely in VS Code global storage
- Support token refresh and re-authentication on 401 responses
- Add comprehensive tests for OAuth functionality

Fixes #7296
This commit is contained in:
Roo Code 2025-08-21 18:50:18 +00:00
parent 4216618c72
commit ce1000b1e8
4 changed files with 1187 additions and 20 deletions

View file

@ -2,6 +2,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { OAuthStreamableHTTPClientTransport } from "./OAuthStreamableHTTPClientTransport"
import { OAuthConfig } from "./OAuthHandler"
import ReconnectingEventSource from "reconnecting-eventsource"
import {
CallToolResultSchema,
@ -117,6 +119,18 @@ const createServerTypeSchema = () => {
type: z.enum(["streamable-http"]).optional(),
url: z.string().url("URL must be a valid URL format"),
headers: z.record(z.string()).optional(),
// OAuth configuration (optional)
oauth: z
.object({
clientId: z.string(),
clientSecret: z.string().optional(),
authorizationUrl: z.string().url(),
tokenUrl: z.string().url(),
redirectUri: z.string().optional(),
scopes: z.array(z.string()).optional(),
additionalParams: z.record(z.string()).optional(),
})
.optional(),
// Ensure no stdio fields are present
command: z.undefined().optional(),
args: z.undefined().optional(),
@ -736,30 +750,69 @@ 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: {
// Check if OAuth is configured
if (configInjected.oauth) {
// Use OAuth-enabled transport
const provider = this.providerRef.deref()
if (!provider) {
throw new Error("Provider not available for OAuth initialization")
}
const oauthTransport = new OAuthStreamableHTTPClientTransport({
url: new URL(configInjected.url),
headers: configInjected.headers,
},
})
oauth: configInjected.oauth as OAuthConfig,
serverName: name,
context: provider.context,
})
// Set up Streamable HTTP specific error handling
transport.onerror = async (error) => {
console.error(`Transport error for "${name}" (streamable-http):`, error)
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
}
// Get the underlying transport
transport = oauthTransport.getTransport()
transport.onclose = async () => {
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
// Set up error handling
oauthTransport.onerror = async (error) => {
console.error(`Transport error for "${name}" (streamable-http with OAuth):`, error)
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
}
oauthTransport.onclose = async () => {
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
}
await this.notifyWebviewOfServerChanges()
}
} else {
// Standard Streamable HTTP connection without OAuth
transport = new StreamableHTTPClientTransport(new URL(configInjected.url), {
requestInit: {
headers: configInjected.headers,
},
})
// Set up Streamable HTTP specific error handling
transport.onerror = async (error) => {
console.error(`Transport error for "${name}" (streamable-http):`, error)
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
}
await this.notifyWebviewOfServerChanges()
}
transport.onclose = async () => {
const connection = this.findConnection(name, source)
if (connection) {
connection.server.status = "disconnected"
}
await this.notifyWebviewOfServerChanges()
}
await this.notifyWebviewOfServerChanges()
}
} else if (configInjected.type === "sse") {
// SSE connection

View file

@ -0,0 +1,488 @@
import * as vscode from "vscode"
import * as http from "http"
import * as crypto from "crypto"
import * as fs from "fs/promises"
import * as path from "path"
import { URL } from "url"
import pkceChallenge from "pkce-challenge"
export interface OAuthConfig {
clientId: string
clientSecret?: string
authorizationUrl: string
tokenUrl: string
redirectUri?: string
scopes?: string[]
additionalParams?: Record<string, string>
}
export interface OAuthTokens {
accessToken: string
refreshToken?: string
expiresAt?: number
tokenType?: string
scope?: string
}
interface StoredOAuthData {
tokens: OAuthTokens
serverName: string
timestamp: number
}
export class OAuthHandler {
private static instance: OAuthHandler | null = null
private server: http.Server | null = null
private pendingAuthorizations: Map<string, (tokens: OAuthTokens | null) => void> = new Map()
private tokenStorage: Map<string, StoredOAuthData> = new Map()
private storageFilePath: string
private constructor(private context: vscode.ExtensionContext) {
// Initialize storage file path
this.storageFilePath = path.join(context.globalStorageUri.fsPath, "mcp-oauth-tokens.json")
this.loadStoredTokens()
}
public static getInstance(context: vscode.ExtensionContext): OAuthHandler {
if (!OAuthHandler.instance) {
OAuthHandler.instance = new OAuthHandler(context)
}
return OAuthHandler.instance
}
/**
* Load stored OAuth tokens from disk
*/
private async loadStoredTokens(): Promise<void> {
try {
const data = await fs.readFile(this.storageFilePath, "utf-8")
const parsed = JSON.parse(data) as Record<string, StoredOAuthData>
this.tokenStorage = new Map(Object.entries(parsed))
} catch (error) {
// File doesn't exist or is invalid, start with empty storage
this.tokenStorage = new Map()
}
}
/**
* Save OAuth tokens to disk
*/
private async saveStoredTokens(): Promise<void> {
try {
const data = Object.fromEntries(this.tokenStorage)
await fs.mkdir(path.dirname(this.storageFilePath), { recursive: true })
await fs.writeFile(this.storageFilePath, JSON.stringify(data, null, 2))
} catch (error) {
console.error("Failed to save OAuth tokens:", error)
}
}
/**
* Get stored tokens for a server
*/
public async getStoredTokens(serverName: string): Promise<OAuthTokens | null> {
const stored = this.tokenStorage.get(serverName)
if (!stored) {
return null
}
// Check if token is expired
if (stored.tokens.expiresAt && stored.tokens.expiresAt < Date.now()) {
// Token is expired, remove it
this.tokenStorage.delete(serverName)
await this.saveStoredTokens()
return null
}
return stored.tokens
}
/**
* Store tokens for a server
*/
private async storeTokens(serverName: string, tokens: OAuthTokens): Promise<void> {
this.tokenStorage.set(serverName, {
tokens,
serverName,
timestamp: Date.now(),
})
await this.saveStoredTokens()
}
/**
* Clear stored tokens for a server
*/
public async clearTokens(serverName: string): Promise<void> {
this.tokenStorage.delete(serverName)
await this.saveStoredTokens()
}
/**
* Start the OAuth flow for a server
*/
public async authenticate(serverName: string, config: OAuthConfig): Promise<OAuthTokens | null> {
// Check if we have valid stored tokens
const storedTokens = await this.getStoredTokens(serverName)
if (storedTokens) {
return storedTokens
}
// Start OAuth flow
return new Promise<OAuthTokens | null>((resolve) => {
this.startOAuthFlow(serverName, config, resolve)
})
}
private async startOAuthFlow(
serverName: string,
config: OAuthConfig,
resolve: (tokens: OAuthTokens | null) => void,
): Promise<void> {
try {
// Generate PKCE challenge
const pkce = await pkceChallenge()
const state = crypto.randomBytes(16).toString("hex")
// Start local server if not already running
if (!this.server) {
await this.startCallbackServer()
}
// Store the pending authorization
const authKey = `${serverName}-${state}`
this.pendingAuthorizations.set(authKey, async (tokens) => {
if (tokens) {
await this.storeTokens(serverName, tokens)
}
resolve(tokens)
})
// Build authorization URL
const authUrl = new URL(config.authorizationUrl)
authUrl.searchParams.set("client_id", config.clientId)
authUrl.searchParams.set("response_type", "code")
authUrl.searchParams.set("redirect_uri", config.redirectUri || "http://localhost:3000/callback")
authUrl.searchParams.set("state", state)
authUrl.searchParams.set("code_challenge", pkce.code_challenge)
authUrl.searchParams.set("code_challenge_method", "S256")
if (config.scopes && config.scopes.length > 0) {
authUrl.searchParams.set("scope", config.scopes.join(" "))
}
// Add any additional parameters
if (config.additionalParams) {
for (const [key, value] of Object.entries(config.additionalParams)) {
authUrl.searchParams.set(key, value)
}
}
// Store config for token exchange
this.storePendingConfig(authKey, config, pkce.code_verifier)
// Open the authorization URL in the browser
const opened = await vscode.env.openExternal(vscode.Uri.parse(authUrl.toString()))
if (!opened) {
throw new Error("Failed to open authorization URL in browser")
}
// Show information message
vscode.window.showInformationMessage(
`Opening browser for OAuth authentication for ${serverName}. Please complete the authorization flow.`,
)
// Set a timeout for the authorization
setTimeout(
() => {
if (this.pendingAuthorizations.has(authKey)) {
this.pendingAuthorizations.delete(authKey)
this.clearPendingConfig(authKey)
resolve(null)
vscode.window.showErrorMessage(`OAuth authentication timeout for ${serverName}`)
}
},
5 * 60 * 1000,
) // 5 minutes timeout
} catch (error) {
console.error("OAuth authentication error:", error)
vscode.window.showErrorMessage(`OAuth authentication failed: ${error}`)
resolve(null)
}
}
/**
* Start the local callback server
*/
private async startCallbackServer(): Promise<void> {
return new Promise((resolve, reject) => {
this.server = http.createServer(async (req, res) => {
const url = new URL(req.url || "", `http://${req.headers.host}`)
if (url.pathname === "/callback") {
await this.handleCallback(url, res)
} else {
res.writeHead(404)
res.end("Not found")
}
})
this.server.listen(3000, "localhost", () => {
console.log("OAuth callback server listening on http://localhost:3000")
resolve()
})
this.server.on("error", (error) => {
console.error("OAuth callback server error:", error)
reject(error)
})
})
}
/**
* Handle OAuth callback
*/
private async handleCallback(url: URL, res: http.ServerResponse): Promise<void> {
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
if (error) {
res.writeHead(200, { "Content-Type": "text/html" })
res.end(`
<html>
<body>
<h1>Authentication Failed</h1>
<p>Error: ${error}</p>
<p>You can close this window.</p>
</body>
</html>
`)
return
}
if (!code || !state) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(`
<html>
<body>
<h1>Invalid Request</h1>
<p>Missing authorization code or state.</p>
</body>
</html>
`)
return
}
// Find the pending authorization
let authKey: string | null = null
for (const key of this.pendingAuthorizations.keys()) {
if (key.endsWith(`-${state}`)) {
authKey = key
break
}
}
if (!authKey) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(`
<html>
<body>
<h1>Invalid State</h1>
<p>The authorization state is invalid or expired.</p>
</body>
</html>
`)
return
}
const callback = this.pendingAuthorizations.get(authKey)
const config = this.getPendingConfig(authKey)
if (!callback || !config) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(`
<html>
<body>
<h1>Configuration Error</h1>
<p>Missing configuration for this authorization.</p>
</body>
</html>
`)
return
}
try {
// Exchange code for tokens
const tokens = await this.exchangeCodeForTokens(code, config)
// Send success response
res.writeHead(200, { "Content-Type": "text/html" })
res.end(`
<html>
<body>
<h1>Authentication Successful!</h1>
<p>You can close this window and return to VS Code.</p>
<script>window.close();</script>
</body>
</html>
`)
// Clean up and call callback
this.pendingAuthorizations.delete(authKey)
this.clearPendingConfig(authKey)
callback(tokens)
} catch (error) {
console.error("Token exchange error:", error)
res.writeHead(500, { "Content-Type": "text/html" })
res.end(`
<html>
<body>
<h1>Token Exchange Failed</h1>
<p>Error: ${error}</p>
</body>
</html>
`)
// Clean up and call callback with null
this.pendingAuthorizations.delete(authKey)
this.clearPendingConfig(authKey)
callback(null)
}
}
/**
* Exchange authorization code for tokens
*/
private async exchangeCodeForTokens(
code: string,
config: { oauth: OAuthConfig; codeVerifier: string },
): Promise<OAuthTokens> {
const params = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: config.oauth.redirectUri || "http://localhost:3000/callback",
client_id: config.oauth.clientId,
code_verifier: config.codeVerifier,
})
if (config.oauth.clientSecret) {
params.set("client_secret", config.oauth.clientSecret)
}
const response = await fetch(config.oauth.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Token exchange failed: ${response.status} - ${error}`)
}
const data = await response.json()
// Calculate expiration time if expires_in is provided
let expiresAt: number | undefined
if (data.expires_in) {
expiresAt = Date.now() + data.expires_in * 1000
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt,
tokenType: data.token_type,
scope: data.scope,
}
}
/**
* Refresh an access token
*/
public async refreshToken(
serverName: string,
config: OAuthConfig,
refreshToken: string,
): Promise<OAuthTokens | null> {
try {
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId,
})
if (config.clientSecret) {
params.set("client_secret", config.clientSecret)
}
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
})
if (!response.ok) {
// Refresh failed, clear stored tokens
await this.clearTokens(serverName)
return null
}
const data = await response.json()
// Calculate expiration time if expires_in is provided
let expiresAt: number | undefined
if (data.expires_in) {
expiresAt = Date.now() + data.expires_in * 1000
}
const tokens: OAuthTokens = {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken, // Use new refresh token if provided, otherwise keep the old one
expiresAt,
tokenType: data.token_type,
scope: data.scope,
}
// Store the new tokens
await this.storeTokens(serverName, tokens)
return tokens
} catch (error) {
console.error("Token refresh error:", error)
await this.clearTokens(serverName)
return null
}
}
// Temporary storage for pending OAuth configs
private pendingConfigs: Map<string, { oauth: OAuthConfig; codeVerifier: string }> = new Map()
private storePendingConfig(authKey: string, config: OAuthConfig, codeVerifier: string): void {
this.pendingConfigs.set(authKey, { oauth: config, codeVerifier })
}
private getPendingConfig(authKey: string): { oauth: OAuthConfig; codeVerifier: string } | undefined {
return this.pendingConfigs.get(authKey)
}
private clearPendingConfig(authKey: string): void {
this.pendingConfigs.delete(authKey)
}
/**
* Dispose of the OAuth handler
*/
public dispose(): void {
if (this.server) {
this.server.close()
this.server = null
}
this.pendingAuthorizations.clear()
this.pendingConfigs.clear()
}
}

View file

@ -0,0 +1,314 @@
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { OAuthHandler, OAuthConfig, OAuthTokens } from "./OAuthHandler"
import * as vscode from "vscode"
export interface OAuthStreamableHTTPConfig {
url: URL
headers?: Record<string, string>
oauth?: OAuthConfig
serverName: string
context: vscode.ExtensionContext
}
/**
* Wrapper for StreamableHTTPClientTransport that adds OAuth authentication support
*/
export class OAuthStreamableHTTPClientTransport {
private transport: StreamableHTTPClientTransport
private oauthHandler: OAuthHandler | null = null
private oauthConfig: OAuthConfig | null = null
private serverName: string
private tokens: OAuthTokens | null = null
private isAuthenticating: boolean = false
private authenticationPromise: Promise<void> | null = null
private originalHeaders: Record<string, string>
constructor(config: OAuthStreamableHTTPConfig) {
this.serverName = config.serverName
this.originalHeaders = config.headers || {}
// If OAuth config is provided, set up OAuth handler
if (config.oauth) {
this.oauthConfig = config.oauth
this.oauthHandler = OAuthHandler.getInstance(config.context)
}
// Create the base transport with initial headers
this.transport = new StreamableHTTPClientTransport(config.url, {
requestInit: {
headers: this.originalHeaders,
},
})
// Intercept the transport to add OAuth headers
if (this.oauthConfig) {
this.setupOAuthInterception()
}
}
/**
* Set up OAuth interception for the transport
*/
private setupOAuthInterception(): void {
// Store the original fetch function
const originalFetch = globalThis.fetch
// Create a custom fetch that adds OAuth headers
const oauthFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
// Only intercept requests to our MCP server URL
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
// Check if this is a request to our MCP server
if (url && this.shouldInterceptRequest(url)) {
// Ensure we have valid tokens
await this.ensureAuthenticated()
// Add OAuth token to headers
if (this.tokens) {
const headers = new Headers(init?.headers || {})
headers.set("Authorization", `${this.tokens.tokenType || "Bearer"} ${this.tokens.accessToken}`)
init = {
...init,
headers,
}
}
// Make the request
const response = await originalFetch(input, init)
// Check if we got a 401 Unauthorized response
if (response.status === 401) {
// Token might be expired, try to refresh or re-authenticate
await this.handleUnauthorized()
// Retry the request with new token
if (this.tokens) {
const headers = new Headers(init?.headers || {})
headers.set("Authorization", `${this.tokens.tokenType || "Bearer"} ${this.tokens.accessToken}`)
const retryInit = {
...init,
headers,
}
return originalFetch(input, retryInit)
}
}
return response
}
// Not our request, pass through
return originalFetch(input, init)
}
// Replace global fetch temporarily when our transport is active
// This is a workaround since StreamableHTTPClientTransport doesn't expose its fetch method
const originalStart = this.transport.start.bind(this.transport)
const originalClose = this.transport.close.bind(this.transport)
this.transport.start = async () => {
// Replace fetch
globalThis.fetch = oauthFetch as typeof fetch
// Ensure we're authenticated before starting
if (this.oauthConfig && this.oauthHandler) {
try {
await this.ensureAuthenticated()
} catch (error) {
console.error(`OAuth authentication failed for ${this.serverName}:`, error)
// Continue anyway - the server might not require auth for all endpoints
}
}
return originalStart()
}
this.transport.close = async () => {
// Restore original fetch
globalThis.fetch = originalFetch
return originalClose()
}
}
/**
* Check if we should intercept this request
*/
private shouldInterceptRequest(url: string): boolean {
// This is a simplified check - you might want to make this more sophisticated
// For now, we'll intercept all requests while this transport is active
return true
}
/**
* Ensure we have valid OAuth tokens
*/
private async ensureAuthenticated(): Promise<void> {
// If already authenticating, wait for it to complete
if (this.isAuthenticating && this.authenticationPromise) {
await this.authenticationPromise
return
}
// If we already have tokens, check if they're still valid
if (this.tokens) {
if (!this.tokens.expiresAt || this.tokens.expiresAt > Date.now()) {
// Tokens are still valid
return
}
// Try to refresh the token
if (this.tokens.refreshToken && this.oauthHandler && this.oauthConfig) {
const refreshedTokens = await this.oauthHandler.refreshToken(
this.serverName,
this.oauthConfig,
this.tokens.refreshToken,
)
if (refreshedTokens) {
this.tokens = refreshedTokens
return
}
}
}
// Check for stored tokens
if (this.oauthHandler) {
const storedTokens = await this.oauthHandler.getStoredTokens(this.serverName)
if (storedTokens) {
this.tokens = storedTokens
return
}
}
// Need to authenticate
await this.authenticate()
}
/**
* Handle 401 Unauthorized response
*/
private async handleUnauthorized(): Promise<void> {
// If we have a refresh token, try to refresh
if (this.tokens?.refreshToken && this.oauthHandler && this.oauthConfig) {
const refreshedTokens = await this.oauthHandler.refreshToken(
this.serverName,
this.oauthConfig,
this.tokens.refreshToken,
)
if (refreshedTokens) {
this.tokens = refreshedTokens
return
}
}
// Clear stored tokens and re-authenticate
if (this.oauthHandler) {
await this.oauthHandler.clearTokens(this.serverName)
}
// Clear current tokens
this.tokens = null
await this.authenticate()
}
/**
* Perform OAuth authentication
*/
private async authenticate(): Promise<void> {
if (!this.oauthHandler || !this.oauthConfig) {
throw new Error("OAuth not configured for this transport")
}
// Prevent multiple simultaneous authentication attempts
if (this.isAuthenticating) {
if (this.authenticationPromise) {
await this.authenticationPromise
}
return
}
this.isAuthenticating = true
this.authenticationPromise = (async () => {
try {
const tokens = await this.oauthHandler!.authenticate(this.serverName, this.oauthConfig!)
if (!tokens) {
throw new Error("OAuth authentication failed or was cancelled")
}
this.tokens = tokens
} finally {
this.isAuthenticating = false
this.authenticationPromise = null
}
})()
await this.authenticationPromise
}
/**
* Get the underlying transport
*/
public getTransport(): StreamableHTTPClientTransport {
return this.transport
}
/**
* Start the transport
*/
public async start(): Promise<void> {
await this.transport.start()
}
/**
* Close the transport
*/
public async close(): Promise<void> {
await this.transport.close()
}
/**
* Check if OAuth is configured for this transport
*/
public hasOAuth(): boolean {
return this.oauthConfig !== null
}
/**
* Get the current OAuth tokens (if any)
*/
public getTokens(): OAuthTokens | null {
return this.tokens
}
/**
* Clear OAuth tokens and force re-authentication on next request
*/
public async clearTokens(): Promise<void> {
this.tokens = null
if (this.oauthHandler) {
await this.oauthHandler.clearTokens(this.serverName)
}
}
// Proxy all other properties and methods to the underlying transport
get onerror() {
return this.transport.onerror
}
set onerror(handler: ((error: Error) => void) | undefined) {
this.transport.onerror = handler
}
get onclose() {
return this.transport.onclose
}
set onclose(handler: (() => void) | undefined) {
this.transport.onclose = handler
}
}

View file

@ -0,0 +1,312 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import * as vscode from "vscode"
import { OAuthHandler, OAuthConfig } from "../OAuthHandler"
import * as fs from "fs/promises"
import * as http from "http"
// Mock vscode
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
},
env: {
openExternal: vi.fn().mockResolvedValue(true),
},
Uri: {
parse: vi.fn((str: string) => ({ toString: () => str })),
},
ExtensionContext: vi.fn(),
}))
// Mock fs
vi.mock("fs/promises", () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
access: vi.fn(),
}))
// Mock http
vi.mock("http", () => {
const mockServer = {
listen: vi.fn((port: number, host: string, callback: () => void) => {
callback()
}),
close: vi.fn(),
on: vi.fn(),
}
return {
createServer: vi.fn(() => mockServer),
Server: vi.fn(),
}
})
// Mock pkce-challenge
vi.mock("pkce-challenge", () => ({
default: vi.fn().mockResolvedValue({
code_challenge: "test_challenge",
code_verifier: "test_verifier",
}),
}))
describe("OAuthHandler", () => {
let mockContext: vscode.ExtensionContext
let oauthHandler: OAuthHandler
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
// Create mock context
mockContext = {
globalStorageUri: {
fsPath: "/test/storage",
},
} as any
// Reset singleton
;(OAuthHandler as any).instance = null
})
afterEach(() => {
// Clean up
if (oauthHandler) {
oauthHandler.dispose()
}
})
describe("getInstance", () => {
it("should create a singleton instance", () => {
const instance1 = OAuthHandler.getInstance(mockContext)
const instance2 = OAuthHandler.getInstance(mockContext)
expect(instance1).toBe(instance2)
})
})
describe("getStoredTokens", () => {
it("should return null when no tokens are stored", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"))
oauthHandler = OAuthHandler.getInstance(mockContext)
const tokens = await oauthHandler.getStoredTokens("test-server")
expect(tokens).toBeNull()
})
it("should return stored tokens when they exist and are valid", async () => {
const storedData = {
"test-server": {
tokens: {
accessToken: "test_token",
refreshToken: "refresh_token",
expiresAt: Date.now() + 3600000, // 1 hour from now
},
serverName: "test-server",
timestamp: Date.now(),
},
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(storedData))
oauthHandler = OAuthHandler.getInstance(mockContext)
const tokens = await oauthHandler.getStoredTokens("test-server")
expect(tokens).toEqual({
accessToken: "test_token",
refreshToken: "refresh_token",
expiresAt: expect.any(Number),
})
})
it("should return null when tokens are expired", async () => {
const storedData = {
"test-server": {
tokens: {
accessToken: "test_token",
refreshToken: "refresh_token",
expiresAt: Date.now() - 3600000, // 1 hour ago
},
serverName: "test-server",
timestamp: Date.now(),
},
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(storedData))
oauthHandler = OAuthHandler.getInstance(mockContext)
const tokens = await oauthHandler.getStoredTokens("test-server")
expect(tokens).toBeNull()
})
})
describe("clearTokens", () => {
it("should remove tokens for a server", async () => {
const storedData = {
"test-server": {
tokens: {
accessToken: "test_token",
},
serverName: "test-server",
timestamp: Date.now(),
},
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(storedData))
vi.mocked(fs.writeFile).mockResolvedValue()
oauthHandler = OAuthHandler.getInstance(mockContext)
await oauthHandler.clearTokens("test-server")
// Check that writeFile was called with empty object for that server
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining("mcp-oauth-tokens.json"),
expect.stringContaining("{}"),
)
})
})
describe("authenticate", () => {
it("should return stored tokens if they exist", async () => {
const storedData = {
"test-server": {
tokens: {
accessToken: "stored_token",
refreshToken: "stored_refresh",
expiresAt: Date.now() + 3600000,
},
serverName: "test-server",
timestamp: Date.now(),
},
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(storedData))
oauthHandler = OAuthHandler.getInstance(mockContext)
const config: OAuthConfig = {
clientId: "test_client",
authorizationUrl: "https://auth.example.com/authorize",
tokenUrl: "https://auth.example.com/token",
}
const tokens = await oauthHandler.authenticate("test-server", config)
expect(tokens).toEqual({
accessToken: "stored_token",
refreshToken: "stored_refresh",
expiresAt: expect.any(Number),
})
// Should not open browser if tokens exist
expect(vscode.env.openExternal).not.toHaveBeenCalled()
})
it("should start OAuth flow when no tokens exist", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"))
oauthHandler = OAuthHandler.getInstance(mockContext)
const config: OAuthConfig = {
clientId: "test_client",
authorizationUrl: "https://auth.example.com/authorize",
tokenUrl: "https://auth.example.com/token",
scopes: ["read", "write"],
}
// Start authentication (won't complete without callback)
const authPromise = oauthHandler.authenticate("test-server", config)
// Should open browser
expect(vscode.env.openExternal).toHaveBeenCalledWith(
expect.objectContaining({
toString: expect.any(Function),
}),
)
// Check the URL that was opened
const urlArg = vi.mocked(vscode.env.openExternal).mock.calls[0][0]
const url = urlArg.toString()
expect(url).toContain("https://auth.example.com/authorize")
expect(url).toContain("client_id=test_client")
expect(url).toContain("response_type=code")
expect(url).toContain("scope=read%20write")
expect(url).toContain("code_challenge=test_challenge")
})
})
describe("refreshToken", () => {
it("should refresh tokens successfully", async () => {
// Mock successful token refresh response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: "new_access_token",
refresh_token: "new_refresh_token",
expires_in: 3600,
token_type: "Bearer",
}),
})
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"))
vi.mocked(fs.writeFile).mockResolvedValue()
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
oauthHandler = OAuthHandler.getInstance(mockContext)
const config: OAuthConfig = {
clientId: "test_client",
clientSecret: "test_secret",
authorizationUrl: "https://auth.example.com/authorize",
tokenUrl: "https://auth.example.com/token",
}
const tokens = await oauthHandler.refreshToken("test-server", config, "old_refresh_token")
expect(tokens).toEqual({
accessToken: "new_access_token",
refreshToken: "new_refresh_token",
expiresAt: expect.any(Number),
tokenType: "Bearer",
scope: undefined,
})
// Check that fetch was called with correct parameters
expect(global.fetch).toHaveBeenCalledWith(
"https://auth.example.com/token",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: expect.stringContaining("grant_type=refresh_token"),
}),
)
})
it("should return null when refresh fails", async () => {
// Mock failed token refresh response
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: async () => "Invalid refresh token",
})
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"))
oauthHandler = OAuthHandler.getInstance(mockContext)
const config: OAuthConfig = {
clientId: "test_client",
authorizationUrl: "https://auth.example.com/authorize",
tokenUrl: "https://auth.example.com/token",
}
const tokens = await oauthHandler.refreshToken("test-server", config, "invalid_refresh_token")
expect(tokens).toBeNull()
})
})
})