Skip to content

Supporting Azure Identity for Authentication #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<Tenant id>",
"AZURE_CLIENT_ID": "<Azure SP Application ID>",
"AZURE_SCOPES": "<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.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
Expand Down
116 changes: 114 additions & 2 deletions src/lib/node-oauth-client-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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 {
Expand Down Expand Up @@ -99,6 +118,10 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
* @returns The OAuth tokens or undefined
*/
async tokens(): Promise<OAuthTokens | undefined> {
if (this.authType === 'azure') {
return await this.getAzureTokens()
}

if (DEBUG) {
debugLog('Reading OAuth tokens')
debugLog('Token request stack trace:', new Error().stack)
Expand Down Expand Up @@ -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<OAuthTokens | undefined> {
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<void> {
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')
}
}
23 changes: 22 additions & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
52 changes: 51 additions & 1 deletion src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

/**
Expand Down
Loading
Loading