Files
mcphub/src/services/mcpOAuthProvider.ts

590 lines
18 KiB
TypeScript

/**
* MCP OAuth Provider Implementation
*
* Implements OAuthClientProvider interface from @modelcontextprotocol/sdk/client/auth.js
* to handle OAuth 2.0 authentication for upstream MCP servers using the SDK's built-in
* OAuth support.
*
* This provider integrates with our existing OAuth infrastructure:
* - Dynamic client registration (RFC7591)
* - Token storage and refresh
* - Authorization flow handling
*/
import { randomBytes } from 'node:crypto';
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
import type {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import { ServerConfig } from '../types/index.js';
import { getSystemConfigDao } from '../dao/index.js';
import {
initializeOAuthForServer,
getRegisteredClient,
removeRegisteredClient,
fetchScopesFromServer,
} from './oauthClientRegistration.js';
import {
clearOAuthData,
loadServerConfig,
mutateOAuthSettings,
persistClientCredentials,
persistTokens,
updatePendingAuthorization,
ServerConfigWithOAuth,
} from './oauthSettingsStore.js';
// Import getServerByName to access ServerInfo
import { getServerByName } from './mcpService.js';
/**
* MCPHub OAuth Provider for server-side OAuth flows
*
* This provider handles OAuth authentication for upstream MCP servers.
* Unlike browser-based providers, this runs in a Node.js server environment,
* so the authorization flow requires external handling (e.g., via web UI).
*/
export class MCPHubOAuthProvider implements OAuthClientProvider {
private serverName: string;
private serverConfig: ServerConfig;
private _codeVerifier?: string;
private _currentState?: string;
private _systemInstallBaseUrl?: string;
constructor(serverName: string, serverConfig: ServerConfig, systemInstallBaseUrl?: string) {
this.serverName = serverName;
this.serverConfig = serverConfig;
this._systemInstallBaseUrl = systemInstallBaseUrl;
}
/**
* Factory method to create an MCPHubOAuthProvider with async config loading
*/
static async create(
serverName: string,
serverConfig: ServerConfig,
): Promise<MCPHubOAuthProvider> {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const systemInstallBaseUrl = systemConfig?.install?.baseUrl;
return new MCPHubOAuthProvider(serverName, serverConfig, systemInstallBaseUrl);
}
private getSystemInstallBaseUrl(): string | undefined {
return this._systemInstallBaseUrl;
}
private sanitizeRedirectUri(input?: string): string | null {
if (!input) {
return null;
}
try {
const url = new URL(input);
url.searchParams.delete('server');
const params = url.searchParams.toString();
url.search = params ? `?${params}` : '';
return url.toString();
} catch {
return null;
}
}
private buildRedirectUriFromBase(baseUrl?: string): string | null {
if (!baseUrl) {
return null;
}
const trimmed = baseUrl.trim();
if (!trimmed) {
return null;
}
try {
const normalizedBase = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
const redirect = new URL('oauth/callback', normalizedBase);
return this.sanitizeRedirectUri(redirect.toString());
} catch {
return null;
}
}
/**
* Get redirect URL for OAuth callback
*/
get redirectUrl(): string {
const dynamicConfig = this.serverConfig.oauth?.dynamicRegistration;
const metadata = dynamicConfig?.metadata || {};
const fallback = 'http://localhost:3000/oauth/callback';
const systemConfigured = this.buildRedirectUriFromBase(this.getSystemInstallBaseUrl());
const metadataConfigured = this.sanitizeRedirectUri(metadata.redirect_uris?.[0]);
return systemConfigured ?? metadataConfigured ?? fallback;
}
/**
* Get client metadata for dynamic registration or static configuration
*/
get clientMetadata(): OAuthClientMetadata {
const dynamicConfig = this.serverConfig.oauth?.dynamicRegistration;
const metadata = dynamicConfig?.metadata || {};
// Use redirectUrl getter to ensure consistent callback URL
const redirectUri = this.redirectUrl;
const systemConfigured = this.buildRedirectUriFromBase(this.getSystemInstallBaseUrl());
const metadataRedirects =
metadata.redirect_uris && metadata.redirect_uris.length > 0
? metadata.redirect_uris
.map((uri) => this.sanitizeRedirectUri(uri))
.filter((uri): uri is string => Boolean(uri))
: [];
const redirectUris: string[] = [];
if (systemConfigured) {
redirectUris.push(systemConfigured);
}
for (const uri of metadataRedirects) {
if (!redirectUris.includes(uri)) {
redirectUris.push(uri);
}
}
if (!redirectUris.includes(redirectUri)) {
redirectUris.push(redirectUri);
}
const tokenEndpointAuthMethod =
metadata.token_endpoint_auth_method && metadata.token_endpoint_auth_method !== ''
? metadata.token_endpoint_auth_method
: this.serverConfig.oauth?.clientSecret
? 'client_secret_post'
: 'none';
return {
...metadata, // Include any additional custom metadata
client_name: metadata.client_name || `MCPHub - ${this.serverName}`,
redirect_uris: redirectUris,
grant_types: metadata.grant_types || ['authorization_code', 'refresh_token'],
response_types: metadata.response_types || ['code'],
token_endpoint_auth_method: tokenEndpointAuthMethod,
scope: metadata.scope || this.serverConfig.oauth?.scopes?.join(' ') || 'openid',
};
}
private async ensureScopesFromServer(): Promise<string[] | undefined> {
const serverUrl = this.serverConfig.url;
const existingScopes = this.serverConfig.oauth?.scopes;
if (!serverUrl) {
return existingScopes;
}
if (existingScopes && existingScopes.length > 0) {
return existingScopes;
}
try {
const scopes = await fetchScopesFromServer(serverUrl);
if (scopes && scopes.length > 0) {
const updatedConfig = await mutateOAuthSettings(this.serverName, ({ oauth }) => {
oauth.scopes = scopes;
});
if (updatedConfig) {
this.serverConfig = updatedConfig;
}
console.log(`Stored auto-detected scopes for ${this.serverName}: ${scopes.join(', ')}`);
return scopes;
}
} catch (error) {
console.warn(
`Failed to auto-detect scopes for ${this.serverName}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return existingScopes;
}
private generateState(): string {
const payload = {
server: this.serverName,
nonce: randomBytes(16).toString('hex'),
};
const base64 = Buffer.from(JSON.stringify(payload)).toString('base64');
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async state(): Promise<string> {
if (!this._currentState) {
this._currentState = this.generateState();
}
return this._currentState;
}
/**
* Get previously registered client information
*/
clientInformation(): OAuthClientInformation | undefined {
const clientInfo = getRegisteredClient(this.serverName);
if (!clientInfo) {
// Try to use static client configuration from cached serverConfig
// Note: we only use cache here since this is a sync method
const serverConfig = this.serverConfig;
// Try to use static client configuration from serverConfig
if (serverConfig?.oauth?.clientId) {
return {
client_id: serverConfig.oauth.clientId,
client_secret: serverConfig.oauth.clientSecret,
};
}
return undefined;
}
return {
client_id: clientInfo.clientId,
client_secret: clientInfo.clientSecret,
};
}
/**
* Save registered client information
* Called by SDK after successful dynamic registration
*/
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
console.log(`Saving OAuth client information for server: ${this.serverName}`);
const scopeString = info.scope?.trim();
const scopes =
scopeString && scopeString.length > 0
? scopeString.split(/\s+/).filter((value) => value.length > 0)
: undefined;
try {
const updatedConfig = await persistClientCredentials(this.serverName, {
clientId: info.client_id,
clientSecret: info.client_secret,
scopes,
});
if (updatedConfig) {
this.serverConfig = updatedConfig;
}
if (!scopes || scopes.length === 0) {
await this.ensureScopesFromServer();
}
} catch (error) {
console.error(
`Failed to persist OAuth client credentials for server ${this.serverName}:`,
error,
);
throw error;
}
}
/**
* Get stored OAuth tokens
*/
tokens(): OAuthTokens | undefined {
// Use cached config only (tokens are updated via saveTokens which updates cache)
const serverConfig = this.serverConfig;
if (!serverConfig?.oauth?.accessToken) {
return undefined;
}
return {
access_token: serverConfig.oauth.accessToken,
token_type: 'Bearer',
refresh_token: serverConfig.oauth.refreshToken,
// Note: expires_in is not typically stored, only the token itself
// The SDK will handle token refresh when needed
};
}
/**
* Save OAuth tokens
* Called by SDK after successful token exchange or refresh
*/
async saveTokens(tokens: OAuthTokens): Promise<void> {
const currentOAuth = this.serverConfig.oauth;
const accessTokenChanged = currentOAuth?.accessToken !== tokens.access_token;
const refreshTokenProvided = tokens.refresh_token !== undefined;
const refreshTokenChanged =
refreshTokenProvided && currentOAuth?.refreshToken !== tokens.refresh_token;
const hadPending = Boolean(currentOAuth?.pendingAuthorization);
if (!accessTokenChanged && !refreshTokenChanged && !hadPending) {
return;
}
console.log(`Saving OAuth tokens: ${JSON.stringify(tokens)} for server: ${this.serverName}`);
const updatedConfig = await persistTokens(this.serverName, {
accessToken: tokens.access_token,
refreshToken: refreshTokenProvided ? (tokens.refresh_token ?? null) : undefined,
clearPendingAuthorization: hadPending,
});
if (updatedConfig) {
this.serverConfig = updatedConfig;
}
this._codeVerifier = undefined;
this._currentState = undefined;
const serverInfo = getServerByName(this.serverName);
if (serverInfo) {
serverInfo.oauth = undefined;
}
console.log(`Saved OAuth tokens for server: ${this.serverName}`);
}
/**
* Redirect to authorization URL
* In a server environment, we can't directly redirect the user
* Instead, we store the URL in ServerInfo for the frontend to access
*/
async redirectToAuthorization(url: URL): Promise<void> {
console.log('='.repeat(80));
console.log(`OAuth Authorization Required for server: ${this.serverName}`);
console.log(`Authorization URL: ${url.toString()}`);
console.log('='.repeat(80));
let state = url.searchParams.get('state') || undefined;
if (!state) {
state = await this.state();
url.searchParams.set('state', state);
} else {
this._currentState = state;
}
const authorizationUrl = url.toString();
try {
const pendingUpdate: Partial<NonNullable<ServerConfig['oauth']>['pendingAuthorization']> = {
authorizationUrl,
state,
};
if (this._codeVerifier) {
pendingUpdate.codeVerifier = this._codeVerifier;
}
const updatedConfig = await updatePendingAuthorization(this.serverName, pendingUpdate);
if (updatedConfig) {
this.serverConfig = updatedConfig;
}
} catch (error) {
console.error(
`Failed to persist pending OAuth authorization state for ${this.serverName}:`,
error,
);
}
// Store the authorization URL in ServerInfo for the frontend to access
const serverInfo = getServerByName(this.serverName);
if (serverInfo) {
serverInfo.status = 'oauth_required';
serverInfo.oauth = {
authorizationUrl,
state,
codeVerifier: this._codeVerifier,
};
console.log(`Stored OAuth authorization URL in ServerInfo for server: ${this.serverName}`);
} else {
console.warn(`ServerInfo not found for ${this.serverName}, cannot store authorization URL`);
}
// Throw error to indicate authorization is needed
// The error will be caught in the connection flow and handled appropriately
throw new Error(
`OAuth authorization required for server ${this.serverName}. Please complete OAuth flow via web UI.`,
);
}
/**
* Save PKCE code verifier for later use in token exchange
*/
async saveCodeVerifier(verifier: string): Promise<void> {
this._codeVerifier = verifier;
try {
const updatedConfig = await updatePendingAuthorization(this.serverName, {
codeVerifier: verifier,
});
if (updatedConfig) {
this.serverConfig = updatedConfig;
}
} catch (error) {
console.error(`Failed to persist OAuth code verifier for ${this.serverName}:`, error);
}
console.log(`Saved code verifier for server: ${this.serverName}`);
}
/**
* Retrieve PKCE code verifier for token exchange
*/
async codeVerifier(): Promise<string> {
if (this._codeVerifier) {
return this._codeVerifier;
}
const storedConfig = await loadServerConfig(this.serverName);
const storedVerifier = storedConfig?.oauth?.pendingAuthorization?.codeVerifier;
if (storedVerifier) {
this.serverConfig = storedConfig || this.serverConfig;
this._codeVerifier = storedVerifier;
return storedVerifier;
}
throw new Error(`No code verifier stored for server: ${this.serverName}`);
}
/**
* Invalidate cached OAuth credentials when the SDK detects they are no longer valid.
* This keeps stored configuration in sync and forces a fresh authorization flow.
*/
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
const storedConfig = await loadServerConfig(this.serverName);
if (!storedConfig?.oauth) {
if (scope === 'verifier' || scope === 'all') {
this._codeVerifier = undefined;
}
return;
}
let currentConfig = storedConfig as ServerConfigWithOAuth;
const assignUpdatedConfig = (updated?: ServerConfigWithOAuth) => {
if (updated) {
currentConfig = updated;
this.serverConfig = updated;
} else {
this.serverConfig = currentConfig;
}
};
assignUpdatedConfig(currentConfig);
let changed = false;
if (scope === 'tokens' || scope === 'all') {
if (currentConfig.oauth.accessToken || currentConfig.oauth.refreshToken) {
const updated = await clearOAuthData(this.serverName, 'tokens');
assignUpdatedConfig(updated);
changed = true;
console.warn(`Cleared OAuth tokens for server: ${this.serverName}`);
}
}
if (scope === 'client' || scope === 'all') {
const supportsDynamicClient = currentConfig.oauth.dynamicRegistration?.enabled === true;
if (
supportsDynamicClient &&
(currentConfig.oauth.clientId || currentConfig.oauth.clientSecret)
) {
removeRegisteredClient(this.serverName);
const updated = await clearOAuthData(this.serverName, 'client');
assignUpdatedConfig(updated);
changed = true;
console.warn(`Cleared OAuth client registration for server: ${this.serverName}`);
}
}
if (scope === 'verifier' || scope === 'all') {
this._codeVerifier = undefined;
this._currentState = undefined;
if (currentConfig.oauth.pendingAuthorization) {
const updated = await clearOAuthData(this.serverName, 'verifier');
assignUpdatedConfig(updated);
changed = true;
}
}
if (changed) {
this._currentState = undefined;
const serverInfo = getServerByName(this.serverName);
if (serverInfo) {
serverInfo.status = 'oauth_required';
serverInfo.oauth = undefined;
}
}
}
}
const prepopulateScopesIfMissing = async (
serverName: string,
serverConfig: ServerConfig,
): Promise<void> => {
if (!serverConfig.oauth || serverConfig.oauth.scopes?.length) {
return;
}
if (!serverConfig.url) {
return;
}
try {
const scopes = await fetchScopesFromServer(serverConfig.url);
if (scopes && scopes.length > 0) {
const updatedConfig = await mutateOAuthSettings(serverName, ({ oauth }) => {
oauth.scopes = scopes;
});
if (!serverConfig.oauth) {
serverConfig.oauth = {};
}
serverConfig.oauth.scopes = scopes;
if (updatedConfig) {
console.log(`Stored auto-detected scopes for ${serverName}: ${scopes.join(', ')}`);
}
}
} catch (error) {
console.warn(
`Failed to auto-detect scopes for ${serverName} during provider initialization: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
};
/**
* Create an OAuth provider for a server if OAuth is configured
*
* @param serverName - Name of the server
* @param serverConfig - Server configuration
* @returns OAuthClientProvider instance or undefined if OAuth not configured
*/
export const createOAuthProvider = async (
serverName: string,
serverConfig: ServerConfig,
): Promise<OAuthClientProvider | undefined> => {
// Ensure scopes are pre-populated if dynamic registration already ran previously
await prepopulateScopesIfMissing(serverName, serverConfig);
// Initialize OAuth for the server (performs registration if needed)
// This ensures the client is registered before the SDK tries to use it
try {
await initializeOAuthForServer(serverName, serverConfig);
} catch (error) {
console.warn(`Failed to initialize OAuth for server ${serverName}:`, error);
// Continue anyway - the SDK might be able to handle it
}
// Create and return the provider using the factory method
const provider = await MCPHubOAuthProvider.create(serverName, serverConfig);
console.log(`Created OAuth provider for server: ${serverName}`);
return provider;
};