import open from 'open'
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import {
OAuthClientInformationFull,
OAuthClientInformationFullSchema,
OAuthTokens,
OAuthTokensSchema,
} from '@modelcontextprotocol/sdk/shared/auth.js'
import type { OAuthProviderOptions, StaticOAuthClientMetadata } from './types'
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, deleteConfigFile } from './mcp-auth-config'
import { StaticOAuthClientInformationFull } from './types'
import { getServerUrlHash, log, debugLog, MCP_REMOTE_VERSION } from './utils'
import { sanitizeUrl } from 'strict-url-sanitise'
import { randomUUID } from 'node:crypto'
/**
* Extended OAuth tokens with expiration timestamp
*/
interface OAuthTokensWithExpiration extends OAuthTokens {
expires_at?: number // Unix timestamp in milliseconds when the token expires
}
/**
* Parse JWT token to extract exp field
* @param token JWT token string
* @returns exp timestamp in seconds, or null if not a valid JWT or exp not found
*/
function parseJwtExpiration(token: string): number | null {
try {
const parts = token.split('.')
if (parts.length !== 3) {
return null // Not a JWT token
}
// Decode base64url encoded payload
const payload = parts[1]
// Replace base64url characters with base64 characters
const base64Payload = payload.replace(/-/g, '+').replace(/_/g, '/')
// Add padding if needed
const paddedPayload = base64Payload + '='.repeat((4 - base64Payload.length % 4) % 4)
const decodedPayload = Buffer.from(paddedPayload, 'base64').toString('utf-8')
const payloadObj = JSON.parse(decodedPayload)
if (typeof payloadObj.exp === 'number') {
return payloadObj.exp * 1000 // Convert to milliseconds
}
return null
} catch (error) {
debugLog('Failed to parse JWT token', error)
return null
}
}
/**
* Calculate expiration timestamp from token
* @param tokens OAuth tokens
* @returns Expiration timestamp in milliseconds, or null if cannot be determined
*/
function calculateExpirationTimestamp(tokens: OAuthTokens): number | null {
// First, try to extract exp from JWT token
if (tokens.access_token) {
const jwtExp = parseJwtExpiration(tokens.access_token)
if (jwtExp !== null) {
return jwtExp
}
}
// Fallback to expires_in if available
if (typeof tokens.expires_in === 'number' && tokens.expires_in > 0) {
return Date.now() + tokens.expires_in * 1000
}
return null
}
/**
* Check if token is expired
* @param tokens OAuth tokens with expiration
* @returns true if token is expired or expiration cannot be determined
*/
function isTokenExpired(tokens: OAuthTokensWithExpiration): boolean {
if (!tokens.expires_at) {
// If no expiration timestamp, consider it expired if expires_in is invalid
return tokens.expires_in !== undefined && tokens.expires_in <= 0
}
return Date.now() >= tokens.expires_at
}
/**
* Implements the OAuthClientProvider interface for Node.js environments.
* Handles OAuth flow and token storage for MCP clients.
*/
export class NodeOAuthClientProvider implements OAuthClientProvider {
private serverUrlHash: string
private callbackPath: string
private clientName: string
private clientUri: string
private softwareId: string
private softwareVersion: string
private staticOAuthClientMetadata: StaticOAuthClientMetadata
private staticOAuthClientInfo: StaticOAuthClientInformationFull
private authorizeResource: string | undefined
private _state: string
/**
* Creates a new NodeOAuthClientProvider
* @param options Configuration options for the provider
*/
constructor(readonly options: OAuthProviderOptions) {
this.serverUrlHash = getServerUrlHash(options.serverUrl)
this.callbackPath = options.callbackPath || '/oauth/callback'
this.clientName = options.clientName || 'MCP CLI Client'
this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli'
this.softwareId = options.softwareId || '2e6dc280-f3c3-4e01-99a7-8181dbd1d23d'
this.softwareVersion = options.softwareVersion || MCP_REMOTE_VERSION
this.staticOAuthClientMetadata = options.staticOAuthClientMetadata
this.staticOAuthClientInfo = options.staticOAuthClientInfo
this.authorizeResource = options.authorizeResource
this._state = randomUUID()
}
get redirectUrl(): string {
return `http://${this.options.host}:${this.options.callbackPort}${this.callbackPath}`
}
get clientMetadata() {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
client_name: this.clientName,
client_uri: this.clientUri,
software_id: this.softwareId,
software_version: this.softwareVersion,
...this.staticOAuthClientMetadata,
}
}
state(): string {
return this._state
}
/**
* Gets the client information if it exists
* @returns The client information or undefined
*/
async clientInformation(): Promise<OAuthClientInformationFull | undefined> {
debugLog('Reading client info')
if (this.staticOAuthClientInfo) {
debugLog('Returning static client info')
return this.staticOAuthClientInfo
}
const clientInfo = await readJsonFile<OAuthClientInformationFull>(
this.serverUrlHash,
'client_info.json',
OAuthClientInformationFullSchema,
)
debugLog('Client info result:', clientInfo ? 'Found' : 'Not found')
return clientInfo
}
/**
* Saves client information
* @param clientInformation The client information to save
*/
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
debugLog('Saving client info', { client_id: clientInformation.client_id })
await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation)
}
/**
* Gets the OAuth tokens if they exist
* @returns The OAuth tokens or undefined
*/
async tokens(): Promise<OAuthTokens | undefined> {
debugLog('Reading OAuth tokens')
debugLog('Token request stack trace:', new Error().stack)
// Read with extended schema that includes expires_at
const tokens = await readJsonFile<OAuthTokensWithExpiration>(this.serverUrlHash, 'tokens.json', {
async parseAsync(data: any) {
// First validate with standard schema
const validated = await OAuthTokensSchema.parseAsync(data)
// Then add expires_at if present
return {
...validated,
expires_at: typeof data.expires_at === 'number' ? data.expires_at : undefined,
} as OAuthTokensWithExpiration
},
})
if (tokens) {
const timeLeft = tokens.expires_in || 0
const expiresAt = tokens.expires_at
const isExpired = expiresAt ? Date.now() >= expiresAt : (tokens.expires_in !== undefined && tokens.expires_in <= 0)
// Alert if expires_in is invalid
if (typeof tokens.expires_in !== 'number' || tokens.expires_in < 0) {
debugLog('⚠️ WARNING: Invalid expires_in detected while reading tokens ⚠️', {
expiresIn: tokens.expires_in,
tokenObject: JSON.stringify(tokens),
stack: new Error('Invalid expires_in value').stack,
})
}
debugLog('Token result:', {
found: true,
hasAccessToken: !!tokens.access_token,
hasRefreshToken: !!tokens.refresh_token,
expiresIn: `${timeLeft} seconds`,
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined,
isExpired,
expiresInValue: tokens.expires_in,
})
} else {
debugLog('Token result: Not found')
}
return tokens
}
/**
* Saves OAuth tokens
* @param tokens The tokens to save
*/
async saveTokens(tokens: OAuthTokens): Promise<void> {
const timeLeft = tokens.expires_in || 0
// Alert if expires_in is invalid
if (typeof tokens.expires_in !== 'number' || tokens.expires_in < 0) {
debugLog('⚠️ WARNING: Invalid expires_in detected in tokens ⚠️', {
expiresIn: tokens.expires_in,
tokenObject: JSON.stringify(tokens),
stack: new Error('Invalid expires_in value').stack,
})
}
// Calculate expiration timestamp
const expiresAt = calculateExpirationTimestamp(tokens)
const tokensToSave: OAuthTokensWithExpiration = {
...tokens,
expires_at: expiresAt || undefined,
}
debugLog('Saving tokens', {
hasAccessToken: !!tokens.access_token,
hasRefreshToken: !!tokens.refresh_token,
expiresIn: `${timeLeft} seconds`,
expiresInValue: tokens.expires_in,
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined,
})
await writeJsonFile(this.serverUrlHash, 'tokens.json', tokensToSave)
}
/**
* Redirects the user to the authorization URL
* @param authorizationUrl The URL to redirect to
*/
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
if (this.authorizeResource) {
authorizationUrl.searchParams.set('resource', this.authorizeResource)
}
log(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`)
debugLog('Redirecting to authorization URL', authorizationUrl.toString())
try {
await open(sanitizeUrl(authorizationUrl.toString()))
log('Browser opened automatically.')
} catch (error) {
log('Could not open browser automatically. Please copy and paste the URL above into your browser.')
debugLog('Failed to open browser', error)
}
}
/**
* Saves the PKCE code verifier
* @param codeVerifier The code verifier to save
*/
async saveCodeVerifier(codeVerifier: string): Promise<void> {
debugLog('Saving code verifier')
await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier)
}
/**
* Gets the PKCE code verifier
* @returns The code verifier
*/
async codeVerifier(): Promise<string> {
debugLog('Reading code verifier')
const verifier = await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session')
debugLog('Code verifier found:', !!verifier)
return verifier
}
/**
* Invalidates the specified credentials
* @param scope The scope of credentials to invalidate
*/
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
debugLog(`Invalidating credentials: ${scope}`)
switch (scope) {
case 'all':
await Promise.all([
deleteConfigFile(this.serverUrlHash, 'client_info.json'),
deleteConfigFile(this.serverUrlHash, 'tokens.json'),
deleteConfigFile(this.serverUrlHash, 'code_verifier.txt'),
])
debugLog('All credentials invalidated')
break
case 'client':
await deleteConfigFile(this.serverUrlHash, 'client_info.json')
debugLog('Client information invalidated')
break
case 'tokens':
await deleteConfigFile(this.serverUrlHash, 'tokens.json')
debugLog('OAuth tokens invalidated')
break
case 'verifier':
await deleteConfigFile(this.serverUrlHash, 'code_verifier.txt')
debugLog('Code verifier invalidated')
break
default:
throw new Error(`Unknown credential scope: ${scope}`)
}
}
}