diff --git a/README.md b/README.md index 8cce8d6..2c59175 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,45 @@ To bypass authentication, or to emit custom headers on all requests to your remo }, ``` +### Azure Authentication + +For servers that require Azure AD/Entra ID authentication, you can use Azure Identity instead of OAuth: + +```json +{ + "mcpServers": { + "azure-mcp": { + "command": "npx", + "args": [ + "mcp-remote", + "https://remote.mcp.server/sse", + "--auth-type", "azure", + "--azure-tenant-id", "${TENANT_ID}", + "--azure-client-id", "${CLIENT_ID}", + "--azure-scopes", "${SCOPES}" + ] + }, + "env": { + "AZURE_TENANT_ID": "", + "AZURE_CLIENT_ID": "", + "AZURE_SCOPES": "" + } + } +} +``` + +**Azure Authentication Features:** +- Uses interactive browser authentication (no secrets required) +- Automatic token refresh handled by Azure Identity SDK +- Supports all Azure AD tenants and scopes +- One-time authentication per session + +**Required Azure Parameters:** +- `--auth-type azure`: Enable Azure authentication +- `--azure-tenant-id`: Your Azure AD tenant ID +- `--azure-client-id`: Your Azure application (client) ID +- `--azure-scopes`: Space-separated scopes (e.g., "https://graph.microsoft.com/.default") + ### Flags * If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package. diff --git a/package.json b/package.json index 4494d92..48f306b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-remote", - "version": "0.1.17", + "version": "0.2.0", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp", @@ -31,6 +31,7 @@ "test:unit:watch": "vitest" }, "dependencies": { + "@azure/identity": "^4.4.1", "express": "^4.21.2", "open": "^10.1.0", "strict-url-sanitise": "^0.0.1" diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts index 613806a..cf0d210 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -6,16 +6,20 @@ import { OAuthTokens, OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' -import type { OAuthProviderOptions, StaticOAuthClientMetadata } from './types' +import type { OAuthProviderOptions, StaticOAuthClientMetadata, AzureAuthOptions, AuthType } from './types' import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, deleteConfigFile } from './mcp-auth-config' import { StaticOAuthClientInformationFull } from './types' import { getServerUrlHash, log, debugLog, DEBUG, MCP_REMOTE_VERSION } from './utils' import { sanitizeUrl } from 'strict-url-sanitise' import { randomUUID } from 'node:crypto' +// Azure Identity imports +import { InteractiveBrowserCredential, AccessToken } from '@azure/identity' + /** * Implements the OAuthClientProvider interface for Node.js environments. * Handles OAuth flow and token storage for MCP clients. + * Also supports Azure Identity authentication. */ export class NodeOAuthClientProvider implements OAuthClientProvider { private serverUrlHash: string @@ -27,12 +31,20 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { private staticOAuthClientMetadata: StaticOAuthClientMetadata private staticOAuthClientInfo: StaticOAuthClientInformationFull private _state: string + + // Azure Identity properties + private azureCredential?: InteractiveBrowserCredential + private azureScopes?: string[] + private azureOptions?: AzureAuthOptions + private authType: AuthType /** * Creates a new NodeOAuthClientProvider * @param options Configuration options for the provider + * @param authType Authentication type (oauth or azure) + * @param azureOptions Azure configuration options (if using Azure auth) */ - constructor(readonly options: OAuthProviderOptions) { + constructor(readonly options: OAuthProviderOptions, authType: AuthType = 'oauth', azureOptions?: AzureAuthOptions) { this.serverUrlHash = getServerUrlHash(options.serverUrl) this.callbackPath = options.callbackPath || '/oauth/callback' this.clientName = options.clientName || 'MCP CLI Client' @@ -42,6 +54,13 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { this.staticOAuthClientMetadata = options.staticOAuthClientMetadata this.staticOAuthClientInfo = options.staticOAuthClientInfo this._state = randomUUID() + this.authType = authType + this.azureOptions = azureOptions + + // Initialize Azure Identity if using Azure auth + if (this.authType === 'azure' && this.azureOptions) { + this.initializeAzureCredential() + } } get redirectUrl(): string { @@ -99,6 +118,10 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @returns The OAuth tokens or undefined */ async tokens(): Promise { + if (this.authType === 'azure') { + return await this.getAzureTokens() + } + if (DEBUG) { debugLog('Reading OAuth tokens') debugLog('Token request stack trace:', new Error().stack) @@ -237,4 +260,93 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { throw new Error(`Unknown credential scope: ${scope}`) } } + + /** + * Initializes the Azure credential for authentication + * @private + */ + private initializeAzureCredential(): void { + if (!this.azureOptions) { + throw new Error('Azure options are required for Azure authentication') + } + + if (DEBUG) debugLog('Initializing Azure credential', { + tenantId: this.azureOptions.tenantId, + clientId: this.azureOptions.clientId, + scopes: this.azureOptions.scopes + }) + + // Create the Interactive Browser Credential + this.azureCredential = new InteractiveBrowserCredential({ + clientId: this.azureOptions.clientId, + tenantId: this.azureOptions.tenantId, + redirectUri: this.azureOptions.redirectUri || `http://localhost:${this.options.callbackPort}/azure/callback` + }) + + // Store scopes for token requests + this.azureScopes = this.azureOptions.scopes + + if (DEBUG) debugLog('Azure credential initialized successfully') + } + + /** + * Gets Azure tokens using the Azure Identity SDK + * @returns OAuth-compatible tokens from Azure + * @private + */ + private async getAzureTokens(): Promise { + if (!this.azureCredential || !this.azureScopes) { + throw new Error('Azure credential not initialized. Call initializeAzureCredential first.') + } + + if (DEBUG) debugLog('Getting Azure tokens') + + try { + // Get token from Azure Identity SDK + const azureToken: AccessToken = await this.azureCredential.getToken(this.azureScopes) + + if (DEBUG) debugLog('Azure token obtained successfully', { + expiresOn: azureToken.expiresOnTimestamp, + timeUntilExpiry: Math.floor((azureToken.expiresOnTimestamp - Date.now()) / 1000) + }) + + // Convert Azure token to OAuth-compatible format + const oauthTokens: OAuthTokens = { + access_token: azureToken.token, + token_type: 'Bearer', + expires_in: Math.floor((azureToken.expiresOnTimestamp - Date.now()) / 1000), + // Azure tokens don't have refresh tokens in this flow + // The Azure Identity SDK handles refresh automatically + } + + return oauthTokens + } catch (error) { + log('Error getting Azure token:', error) + if (DEBUG) debugLog('Azure token error details', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }) + throw error + } + } + + /** + * Initializes Azure authentication if not already done + * This method can be called to ensure Azure auth is ready + */ + public async initializeAzureAuth(): Promise { + if (this.authType !== 'azure') { + return + } + + if (!this.azureCredential) { + this.initializeAzureCredential() + } + + // Trigger initial authentication by requesting a token + // This will open the browser for interactive authentication + await this.getAzureTokens() + + log('Azure authentication completed successfully') + } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 74b3a96..cb203f9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -41,6 +41,27 @@ export interface OAuthCallbackServerOptions { events: EventEmitter } -// optional tatic OAuth client information +// optional static OAuth client information export type StaticOAuthClientMetadata = OAuthClientMetadata | null | undefined export type StaticOAuthClientInformationFull = OAuthClientInformationFull | null | undefined + +/** + * Azure Identity configuration options + */ +export interface AzureAuthOptions { + /** Azure tenant ID */ + tenantId: string + /** Azure client/application ID */ + clientId: string + /** Scopes to request (space or array separated) */ + scopes: string[] + /** Optional redirect URI for interactive flows */ + redirectUri?: string + /** Optional authority URL (defaults to public cloud) */ + authority?: string +} + +/** + * Authentication type configuration + */ +export type AuthType = 'oauth' | 'azure' diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c5598d5..03c60fd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -673,7 +673,57 @@ export async function parseCommandLineArgs(args: string[], usage: string) { }) } - return { serverUrl, callbackPort, headers, transportStrategy, host, debug, staticOAuthClientMetadata, staticOAuthClientInfo } + // Parse Azure authentication options + let authType: 'oauth' | 'azure' = 'oauth' // Default to OAuth + let azureOptions: { tenantId: string; clientId: string; scopes: string[] } | undefined + + const authTypeIndex = args.indexOf('--auth-type') + if (authTypeIndex !== -1 && authTypeIndex < args.length - 1) { + const type = args[authTypeIndex + 1] + if (type === 'azure' || type === 'oauth') { + authType = type + log(`Using authentication type: ${authType}`) + } else { + log(`Warning: Ignoring invalid auth type: ${type}. Valid values are: oauth, azure`) + } + } + + if (authType === 'azure') { + // Parse Azure-specific options + const azureTenantIdIndex = args.indexOf('--azure-tenant-id') + const azureClientIdIndex = args.indexOf('--azure-client-id') + const azureScopesIndex = args.indexOf('--azure-scopes') + + if (azureTenantIdIndex === -1 || azureTenantIdIndex >= args.length - 1) { + log('Error: --azure-tenant-id is required when using Azure authentication') + process.exit(1) + } + + if (azureClientIdIndex === -1 || azureClientIdIndex >= args.length - 1) { + log('Error: --azure-client-id is required when using Azure authentication') + process.exit(1) + } + + if (azureScopesIndex === -1 || azureScopesIndex >= args.length - 1) { + log('Error: --azure-scopes is required when using Azure authentication') + process.exit(1) + } + + // Convert scopes string to array + const scopesString = args[azureScopesIndex + 1] + const scopesArray = scopesString.split(' ').filter(s => s.length > 0) + + azureOptions = { + tenantId: args[azureTenantIdIndex + 1], + clientId: args[azureClientIdIndex + 1], + scopes: scopesArray + } + + log(`Using Azure authentication with tenant: ${azureOptions.tenantId}, client: ${azureOptions.clientId}`) + log(`Azure scopes: ${azureOptions.scopes.join(' ')}`) + } + + return { serverUrl, callbackPort, headers, transportStrategy, host, debug, staticOAuthClientMetadata, staticOAuthClientInfo, authType, azureOptions } } /** diff --git a/src/proxy.ts b/src/proxy.ts index 846580d..c3be81c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -20,7 +20,7 @@ import { getServerUrlHash, TransportStrategy, } from './lib/utils' -import { StaticOAuthClientInformationFull, StaticOAuthClientMetadata } from './lib/types' +import { StaticOAuthClientInformationFull, StaticOAuthClientMetadata, AzureAuthOptions, AuthType } from './lib/types' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' import { createLazyAuthCoordinator } from './lib/coordination' @@ -35,6 +35,8 @@ async function runProxy( host: string, staticOAuthClientMetadata: StaticOAuthClientMetadata, staticOAuthClientInfo: StaticOAuthClientInformationFull, + authType: AuthType = 'oauth', + azureOptions?: AzureAuthOptions, ) { // Set up event emitter for auth flow const events = new EventEmitter() @@ -45,7 +47,7 @@ async function runProxy( // Create a lazy auth coordinator const authCoordinator = createLazyAuthCoordinator(serverUrlHash, callbackPort, events) - // Create the OAuth client provider + // Create the OAuth client provider (supports both OAuth and Azure) const authProvider = new NodeOAuthClientProvider({ serverUrl, callbackPort, @@ -53,7 +55,7 @@ async function runProxy( clientName: 'MCP CLI Proxy', staticOAuthClientMetadata, staticOAuthClientInfo, - }) + }, authType, azureOptions) // Create the STDIO transport for local connections const localTransport = new StdioServerTransport() @@ -63,22 +65,38 @@ async function runProxy( // Define an auth initializer function const authInitializer = async () => { - const authState = await authCoordinator.initializeAuth() - - // Store server in outer scope for cleanup - server = authState.server - - // If auth was completed by another instance, just log that we'll use the auth from disk - if (authState.skipBrowserAuth) { - log('Authentication was completed by another instance - will use tokens from disk') - // TODO: remove, the callback is happening before the tokens are exchanged - // so we're slightly too early - await new Promise((res) => setTimeout(res, 1_000)) - } + if (authType === 'azure') { + // For Azure authentication, we handle it directly through the auth provider + log('Initializing Azure authentication...') + await authProvider.initializeAzureAuth() + + // Create a dummy server for consistency with the existing interface + const dummyServer = { close: () => {} } + server = dummyServer + + return { + waitForAuthCode: () => Promise.resolve(''), // Not needed for Azure + skipBrowserAuth: true, // Azure handles auth directly + } + } else { + // Standard OAuth flow + const authState = await authCoordinator.initializeAuth() + + // Store server in outer scope for cleanup + server = authState.server + + // If auth was completed by another instance, just log that we'll use the auth from disk + if (authState.skipBrowserAuth) { + log('Authentication was completed by another instance - will use tokens from disk') + // TODO: remove, the callback is happening before the tokens are exchanged + // so we're slightly too early + await new Promise((res) => setTimeout(res, 1_000)) + } - return { - waitForAuthCode: authState.waitForAuthCode, - skipBrowserAuth: authState.skipBrowserAuth, + return { + waitForAuthCode: authState.waitForAuthCode, + skipBrowserAuth: authState.skipBrowserAuth, + } } } @@ -142,8 +160,8 @@ to the CA certificate file. If using claude_desktop_config.json, this might look // Parse command-line arguments and run the proxy parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts [callback-port] [--debug]') - .then(({ serverUrl, callbackPort, headers, transportStrategy, host, debug, staticOAuthClientMetadata, staticOAuthClientInfo }) => { - return runProxy(serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo) + .then(({ serverUrl, callbackPort, headers, transportStrategy, host, debug, staticOAuthClientMetadata, staticOAuthClientInfo, authType, azureOptions }) => { + return runProxy(serverUrl, callbackPort, headers, transportStrategy, host, staticOAuthClientMetadata, staticOAuthClientInfo, authType, azureOptions) }) .catch((error) => { log('Fatal error:', error)