/**
* OAuth 2.0 Provider for Static Clients
*
* This provider implements standard OAuth 2.0 authorization code flow
* with static client configuration, for marketplace scenarios and servers
* that require pre-registered clients.
*
* It handles the complete OAuth flow:
* 1. Generate authorization URL
* 2. Start callback server to receive authorization code
* 3. Exchange authorization code for access token
* 4. Store and manage tokens
*/
import open from 'open'
import express from 'express'
import { EventEmitter } from 'events'
import { randomUUID } from 'node:crypto'
import { sanitizeUrl } from 'strict-url-sanitise'
import type { OAuthProviderOptions } from './types'
import { StaticOAuthClientInformationFull } from './types'
import {
readJsonFile,
writeJsonFile,
deleteConfigFile,
getConfigFilePath,
} from './mcp-auth-config'
import {
getServerUrlHash,
getTokenStorageKey,
log,
debugLog,
} from './utils'
import { OAuthTokens, OAuthTokensSchema } from '@modelcontextprotocol/sdk/shared/auth.js'
import { setupOAuthCallbackServerWithLongPoll } from './utils'
/**
* 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
}
/**
* OAuth 2.0 Provider for Static Clients
*/
export class NonPkceOAuthProvider {
private serverUrlHash: string
private tokenStorageKey: string
private callbackPath: string
private clientId: string
private clientSecret: string | undefined
private redirectUri: string
private serverUrl: string
private events: EventEmitter
private callbackServer: any = null
private _state: string
constructor(
options: OAuthProviderOptions,
staticOAuthClientInfo: StaticOAuthClientInformationFull,
events: EventEmitter,
) {
this.serverUrlHash = getServerUrlHash(options.serverUrl)
this.serverUrl = options.serverUrl
this.callbackPath = options.callbackPath || '/oauth/callback'
this.redirectUri = `http://${options.host}:${options.callbackPort}${this.callbackPath}`
this.events = events
this._state = randomUUID()
// Extract client_id and client_secret from static client info
if (!staticOAuthClientInfo || !staticOAuthClientInfo.client_id) {
throw new Error('Static OAuth client information with client_id is required')
}
this.clientId = staticOAuthClientInfo.client_id
this.clientSecret = staticOAuthClientInfo.client_secret
// Calculate token storage key based on client_id
// Same client_id will share the same token storage to avoid conflicts
this.tokenStorageKey = getTokenStorageKey(this.clientId)
}
/**
* Get stored OAuth tokens
* Uses tokenStorageKey (based on client_id) to enable token sharing across servers
*/
async getTokens(): Promise<OAuthTokens | undefined> {
const newTokenFilePath = getConfigFilePath(this.tokenStorageKey, 'tokens.json')
debugLog('Reading OAuth tokens', {
tokenStorageKey: this.tokenStorageKey,
serverUrlHash: this.serverUrlHash,
clientId: this.clientId,
tokenFilePath: newTokenFilePath,
})
// Try to read from new location (based on client_id)
let tokens = await readJsonFile<OAuthTokensWithExpiration>(this.tokenStorageKey, '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) {
log(`Token loaded from file: ${newTokenFilePath}`)
}
// Migration: If not found in new location, try to migrate from old location
if (!tokens) {
const oldTokenFilePath = getConfigFilePath(this.serverUrlHash, 'tokens.json')
debugLog('Token not found in new location, attempting migration from old location', {
newTokenFilePath,
oldTokenFilePath,
})
const oldTokens = await readJsonFile<OAuthTokensWithExpiration>(this.serverUrlHash, 'tokens.json', {
async parseAsync(data: any) {
const validated = await OAuthTokensSchema.parseAsync(data)
return {
...validated,
expires_at: typeof data.expires_at === 'number' ? data.expires_at : undefined,
} as OAuthTokensWithExpiration
},
})
if (oldTokens) {
log(`Token found in old location: ${oldTokenFilePath}`)
log(`Migrating token to new location: ${newTokenFilePath}`)
debugLog('Found tokens in old location, migrating to new location', {
oldLocation: this.serverUrlHash,
newLocation: this.tokenStorageKey,
oldTokenFilePath,
newTokenFilePath,
})
// Migrate tokens to new location
await this.saveTokens(oldTokens)
tokens = oldTokens
log('Token migration completed')
debugLog('Token migration completed')
} else {
log(`Token not found in old location: ${oldTokenFilePath}`)
}
}
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 (same as SDK)
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,
storageKey: this.tokenStorageKey,
tokenFilePath: newTokenFilePath,
})
} else {
log(`Token not found in any location`)
debugLog('Token result: Not found', {
newTokenFilePath,
oldTokenFilePath: getConfigFilePath(this.serverUrlHash, 'tokens.json'),
})
}
return tokens
}
/**
* Save OAuth tokens
*/
async saveTokens(tokens: OAuthTokens): Promise<void> {
const timeLeft = tokens.expires_in || 0
// Alert if expires_in is invalid (same as SDK)
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,
}
const tokenFilePath = getConfigFilePath(this.tokenStorageKey, 'tokens.json')
log(`Saving tokens to file: ${tokenFilePath}`)
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,
storageKey: this.tokenStorageKey,
clientId: this.clientId,
tokenFilePath,
})
// Store tokens using tokenStorageKey (based on client_id)
// Same client_id will share the same token storage to avoid conflicts
await writeJsonFile(this.tokenStorageKey, 'tokens.json', tokensToSave)
}
/**
* Get authorization URL
*/
getAuthorizationUrl(authorizationEndpoint: string, authorizeResource?: string): URL {
const url = new URL(authorizationEndpoint)
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', this.clientId)
url.searchParams.set('redirect_uri', this.redirectUri)
url.searchParams.set('state', this._state)
if (authorizeResource) {
url.searchParams.set('resource', authorizeResource)
}
return url
}
/**
* Start OAuth authorization flow
*/
async startAuthorization(
authorizationEndpoint: string,
tokenEndpoint: string,
callbackPort: number,
authorizeResource?: string,
authTimeoutMs: number = 30000,
): Promise<OAuthTokens> {
// Check if we already have valid tokens
const existingTokens = await this.getTokens()
if (existingTokens && existingTokens.access_token) {
// Check if token is expired using expires_at
const tokensWithExp = existingTokens as OAuthTokensWithExpiration
if (!isTokenExpired(tokensWithExp)) {
debugLog('Using existing valid tokens')
return existingTokens
}
}
// Set up callback server
const { server, waitForAuthCode } = setupOAuthCallbackServerWithLongPoll({
port: callbackPort,
path: this.callbackPath,
events: this.events,
authTimeoutMs,
})
this.callbackServer = server
try {
// Get authorization URL
const authUrl = this.getAuthorizationUrl(authorizationEndpoint, authorizeResource)
log(`\nPlease authorize this client by visiting:\n${authUrl.toString()}\n`)
debugLog('Redirecting to authorization URL', authUrl.toString())
// Open browser
try {
await open(sanitizeUrl(authUrl.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)
}
// Wait for authorization code
log('Waiting for authorization...')
const authCode = await waitForAuthCode()
debugLog('Received authorization code')
// Exchange authorization code for access token
log('Exchanging authorization code for access token...')
const tokens = await this.exchangeCodeForToken(authCode, tokenEndpoint)
// Save tokens
await this.saveTokens(tokens)
log('Authorization successful!')
return tokens
} finally {
// Close callback server
if (this.callbackServer) {
this.callbackServer.close()
this.callbackServer = null
}
}
}
/**
* Exchange authorization code for access token
*/
private async exchangeCodeForToken(authorizationCode: string, tokenEndpoint: string): Promise<OAuthTokens> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: this.redirectUri,
client_id: this.clientId,
})
// Add client_secret if available
if (this.clientSecret) {
body.append('client_secret', this.clientSecret)
}
debugLog('Exchanging code for token', {
tokenEndpoint,
hasClientSecret: !!this.clientSecret,
})
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.toString(),
})
if (!response.ok) {
const errorText = await response.text()
debugLog('Token exchange failed', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
throw new Error(`Token exchange failed: HTTP ${response.status} - ${errorText}`)
}
const tokenData = await response.json()
debugLog('Token exchange successful', {
hasAccessToken: !!tokenData.access_token,
hasRefreshToken: !!tokenData.refresh_token,
expiresIn: tokenData.expires_in,
})
// Convert to OAuthTokens format
const tokens: OAuthTokens = {
access_token: tokenData.access_token,
token_type: tokenData.token_type || 'Bearer',
expires_in: tokenData.expires_in,
refresh_token: tokenData.refresh_token,
scope: tokenData.scope,
}
return tokens
}
/**
* Refresh access token using refresh token
*/
async refreshToken(tokenEndpoint: string): Promise<OAuthTokens> {
const existingTokens = await this.getTokens()
if (!existingTokens || !existingTokens.refresh_token) {
throw new Error('No refresh token available')
}
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: existingTokens.refresh_token,
client_id: this.clientId,
})
if (this.clientSecret) {
body.append('client_secret', this.clientSecret)
}
debugLog('Refreshing token')
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.toString(),
})
if (!response.ok) {
const errorText = await response.text()
debugLog('Token refresh failed', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
throw new Error(`Token refresh failed: HTTP ${response.status} - ${errorText}`)
}
const tokenData = await response.json()
const tokens: OAuthTokens = {
access_token: tokenData.access_token,
token_type: tokenData.token_type || 'Bearer',
expires_in: tokenData.expires_in,
refresh_token: tokenData.refresh_token || existingTokens.refresh_token,
scope: tokenData.scope,
}
await this.saveTokens(tokens)
return tokens
}
/**
* Get valid access token (refresh if needed)
*
* This method:
* 1. Checks if we have valid tokens (not expired)
* 2. If expired, tries to refresh using refresh_token
* 3. If refresh fails or no refresh_token, starts new authorization flow
* 4. Returns a valid access token
*/
async getValidAccessToken(
authorizationEndpoint: string,
tokenEndpoint: string,
callbackPort: number,
authorizeResource?: string,
): Promise<string> {
let tokens = await this.getTokens()
// Check if token is expired or missing using expires_at
const tokensWithExp = tokens as OAuthTokensWithExpiration | undefined
if (!tokens || !tokens.access_token || (tokensWithExp && isTokenExpired(tokensWithExp))) {
debugLog('Token expired or missing, attempting refresh or re-authorization')
// Try to refresh if we have a refresh token
if (tokens && tokens.refresh_token) {
try {
debugLog('Attempting to refresh token using refresh_token')
tokens = await this.refreshToken(tokenEndpoint)
debugLog('Token refreshed successfully')
} catch (error) {
debugLog('Token refresh failed, starting new authorization', error)
// Refresh failed, start new authorization flow
tokens = await this.startAuthorization(
authorizationEndpoint,
tokenEndpoint,
callbackPort,
authorizeResource,
)
}
} else {
// No refresh token available, start new authorization flow
debugLog('No refresh token available, starting new authorization')
tokens = await this.startAuthorization(
authorizationEndpoint,
tokenEndpoint,
callbackPort,
authorizeResource,
)
}
} else if (tokens && tokens.access_token) {
const tokensWithExp = tokens as OAuthTokensWithExpiration
debugLog('Using existing valid token', {
expiresIn: tokens.expires_in,
expiresAt: tokensWithExp.expires_at ? new Date(tokensWithExp.expires_at).toISOString() : undefined,
hasRefreshToken: !!tokens.refresh_token,
})
}
if (!tokens || !tokens.access_token) {
throw new Error('Failed to obtain access token')
}
return tokens.access_token
}
/**
* Invalidate tokens
*/
async invalidateTokens(): Promise<void> {
debugLog('Invalidating tokens', {
storageKey: this.tokenStorageKey,
clientId: this.clientId,
})
await deleteConfigFile(this.tokenStorageKey, 'tokens.json')
}
/**
* Close callback server
*/
close(): void {
if (this.callbackServer) {
this.callbackServer.close()
this.callbackServer = null
}
}
}