Skip to main content
Glama
persistent-oauth-client-provider.ts13.6 kB
import open from 'open'; import { EventEmitter } from 'node:events'; import { generateServerUrlHash, readTokens, writeTokens, readClientInfo, writeClientInfo, writeTextFile, readTextFile, deleteConfigFile, isTokenValid, } from './persistent-auth-config.js'; import { WPTokens, WPClientInfo, OAuthError, WPOAuthOptions } from './oauth-types.js'; import { setupWPOAuthCallbackServer } from './oauth-callback-server.js'; import { logger } from './utils.js'; import { CONFIG, getDefaultOAuthScopes, getOAuthCallbackPort } from './config.js'; /** * Persistent WordPress OAuth Client Provider * Stores tokens permanently in ~/.mcp-auth/wordpress-remote-{version}/ */ export class PersistentWPOAuthClientProvider { private options: WPOAuthOptions; private serverUrlHash: string; private events: EventEmitter; private authPromise: Promise<WPTokens> | null = null; constructor(options: Partial<WPOAuthOptions>) { this.options = { // Base configuration from CONFIG and explicit options serverUrl: options.serverUrl || CONFIG.WP_API_URL, host: options.host || CONFIG.OAUTH_HOST, scopes: options.scopes || getDefaultOAuthScopes(), callbackPort: options.callbackPort || CONFIG.OAUTH_CALLBACK_PORT || 0, // 0 = auto-select timeout: options.timeout || CONFIG.OAUTH_TIMEOUT, // OAuth endpoints must be explicitly configured authorizeEndpoint: options.authorizeEndpoint || CONFIG.OAUTH_AUTHORIZE_ENDPOINT, clientId: options.clientId || CONFIG.WP_OAUTH_CLIENT_ID, ...options, // Allow any additional options to override defaults } as WPOAuthOptions; this.serverUrlHash = generateServerUrlHash(this.options.serverUrl); this.events = new EventEmitter(); // Set reasonable timeout for auth operations this.events.setMaxListeners(10); logger.oauth('Initialized Persistent WordPress OAuth provider'); logger.debug('OAuth provider options', 'OAUTH', { serverUrl: this.options.serverUrl, serverHash: this.serverUrlHash, clientId: this.options.clientId || 'auto-generated', callbackPort: this.options.callbackPort, authorizeEndpoint: this.options.authorizeEndpoint, }); } /** * Get current tokens if available and valid from persistent storage */ async tokens(): Promise<WPTokens | null> { try { const tokens = await readTokens(this.serverUrlHash); if (tokens) { const validation = isTokenValid(tokens); if (validation.isValid) { logger.oauth('Found valid tokens in persistent storage'); if (validation.expiresIn) { logger.debug(`Tokens expire in ${validation.expiresIn} seconds`, 'OAUTH'); } return tokens; } else { logger.warn(`Tokens in persistent storage are invalid: ${validation.error}`, 'OAUTH'); return null; } } logger.debug('No tokens found in persistent storage', 'OAUTH'); return null; } catch (error) { logger.error('Error retrieving tokens from persistent storage', 'OAUTH', error); return null; } } /** * Save tokens to persistent storage */ async saveTokens(tokens: WPTokens): Promise<void> { try { await writeTokens(this.serverUrlHash, tokens); logger.oauth('Tokens saved to persistent storage'); } catch (error) { logger.error('Error saving tokens to persistent storage', 'OAUTH', error); throw error; } } /** * Get client information from persistent storage */ async clientInformation(): Promise<WPClientInfo | null> { try { const clientInfo = await readClientInfo(this.serverUrlHash); if (clientInfo) { logger.debug('Found client information in persistent storage', 'OAUTH'); return clientInfo; } logger.debug('No client information found in persistent storage', 'OAUTH'); return null; } catch (error) { logger.error('Error retrieving client information', 'OAUTH', error); return null; } } /** * Save client information to persistent storage */ async saveClientInformation(clientInfo: WPClientInfo): Promise<void> { try { await writeClientInfo(this.serverUrlHash, clientInfo); logger.oauth('Client information saved to persistent storage'); } catch (error) { logger.error('Error saving client information', 'OAUTH', error); throw error; } } /** * Save PKCE code verifier */ async saveCodeVerifier(codeVerifier: string): Promise<void> { try { await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier); logger.debug('Code verifier saved', 'OAUTH'); } catch (error) { logger.error('Error saving code verifier', 'OAUTH', error); throw error; } } /** * Get PKCE code verifier */ async codeVerifier(): Promise<string> { try { const verifier = await readTextFile( this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session' ); logger.debug('Code verifier retrieved', 'OAUTH'); return verifier; } catch (error) { logger.error('Error retrieving code verifier', 'OAUTH', error); throw error; } } /** * Initiate OAuth authorization flow */ async authorize(): Promise<void> { // If authorization is already in progress, wait for it if (this.authPromise) { logger.oauth('Authorization already in progress, waiting...'); await this.authPromise; return; } // Check if we already have valid tokens in persistent storage const existingTokens = await this.tokens(); if (existingTokens) { logger.oauth('Already have valid tokens in persistent storage, skipping authorization'); return; } logger.oauth('Starting OAuth authorization flow for WordPress (persistent storage)'); this.authPromise = this.performAuthorization(); try { await this.authPromise; logger.oauth('OAuth authorization completed successfully'); } catch (error) { logger.error('OAuth authorization failed', 'OAUTH', error); throw error; } finally { this.authPromise = null; } } /** * Perform the actual OAuth authorization */ private async performAuthorization(): Promise<WPTokens> { // Use smart port selection or fixed port as configured const callbackPort = this.options.callbackPort === 0 ? await getOAuthCallbackPort() : this.options.callbackPort; const callbackServerOptions = { port: callbackPort, host: this.options.host, serverUrlHash: this.serverUrlHash, timeout: CONFIG.OAUTH_TIMEOUT, }; logger.oauth(`Setting up callback server on ${this.options.host}:${callbackPort}`); const callbackServer = setupWPOAuthCallbackServer(callbackServerOptions, this.events); try { // Start the callback server logger.oauth('Starting callback server...'); await callbackServer.start(); logger.oauth('Callback server started successfully'); // Generate state parameter for security const state = this.generateState(); logger.debug(`Generated state parameter: ${state}`, 'OAUTH'); // Build authorization URL const authUrl = this.buildAuthorizationUrl(callbackServer.getCallbackUrl(), state); logger.oauth(`Built authorization URL: ${authUrl}`); logger.debug(`Callback URL: ${callbackServer.getCallbackUrl()}`, 'OAUTH'); // Open browser to authorization URL logger.oauth('Attempting to open browser...'); try { await open(authUrl); logger.oauth('Browser opened successfully'); } catch (browserError) { logger.error('Failed to open browser automatically', 'OAUTH', browserError); logger.info('\n=== MANUAL ACTION REQUIRED ==='); logger.info('Please manually open the following URL in your browser:'); logger.info(`${authUrl}`); logger.info('===============================\n'); // Don't throw here, continue waiting for manual authorization } // Wait for authorization result logger.oauth('Waiting for authorization result...'); const tokens = await this.waitForAuthorizationResult(); logger.oauth('Authorization result received'); // Save tokens to persistent storage logger.oauth('Saving tokens to persistent storage...'); await this.saveTokens(tokens); logger.oauth('Tokens saved successfully'); return tokens; } catch (error) { logger.error('Error during authorization flow', 'OAUTH', error); if (error instanceof Error && error.stack) { logger.debug('Stack trace', 'OAUTH', error.stack); } throw error; } finally { // Always stop the callback server logger.oauth('Stopping callback server...'); try { await callbackServer.stop(); logger.oauth('Callback server stopped'); } catch (stopError) { logger.error('Error stopping callback server', 'OAUTH', stopError); } } } /** * Build the WordPress authorization URL */ private buildAuthorizationUrl(callbackUrl: string, state: string): string { if (!this.options.authorizeEndpoint) { throw new OAuthError( 'OAuth authorize endpoint not configured. Please set OAUTH_AUTHORIZE_ENDPOINT environment variable.', 'MISSING_AUTHORIZE_ENDPOINT' ); } const params = new URLSearchParams({ response_type: 'token', // Implicit flow redirect_uri: callbackUrl, scope: this.options.scopes?.join(' ') || getDefaultOAuthScopes().join(' '), state: state, }); // Add client_id if provided if (this.options.clientId) { params.set('client_id', this.options.clientId); } // Check if authorizeEndpoint is a full URL or relative path if (this.options.authorizeEndpoint.startsWith('http://') || this.options.authorizeEndpoint.startsWith('https://')) { // Full URL - use as is return `${this.options.authorizeEndpoint}?${params.toString()}`; } else { // Relative path - construct with base URL const baseUrl = this.options.serverUrl.replace(/\/+$/, ''); // Remove trailing slashes return `${baseUrl}${this.options.authorizeEndpoint}?${params.toString()}`; } } /** * Generate a secure state parameter */ private generateState(): string { return ( Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) ); } /** * Wait for authorization result from callback server */ private async waitForAuthorizationResult(): Promise<WPTokens> { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { cleanup(); reject(new OAuthError('Authorization timeout', 'TIMEOUT')); }, CONFIG.LOCK_TIMEOUT); const cleanup = () => { this.events.removeAllListeners('oauth-success'); this.events.removeAllListeners('oauth-error'); clearTimeout(timeout); }; this.events.once('oauth-success', (tokens: WPTokens) => { cleanup(); logger.oauth('OAuth authorization successful for WordPress (persistent storage)'); resolve(tokens); }); this.events.once('oauth-error', (error: Error) => { cleanup(); logger.error('OAuth authorization error', 'OAUTH', error.message); reject(error); }); }); } /** * Clear stored tokens and credentials */ async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> { logger.oauth(`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'), ]); logger.oauth('All credentials invalidated'); break; case 'client': await deleteConfigFile(this.serverUrlHash, 'client_info.json'); logger.oauth('Client information invalidated'); break; case 'tokens': await deleteConfigFile(this.serverUrlHash, 'tokens.json'); logger.oauth('OAuth tokens invalidated'); break; case 'verifier': await deleteConfigFile(this.serverUrlHash, 'code_verifier.txt'); logger.oauth('Code verifier invalidated'); break; default: throw new Error(`Unknown credential scope: ${scope}`); } } /** * Get authorization status */ async isAuthorized(): Promise<boolean> { const tokens = await this.tokens(); return tokens !== null; } /** * Get the server URL hash */ getServerUrlHash(): string { return this.serverUrlHash; } /** * Get OAuth options */ getOptions(): WPOAuthOptions { return { ...this.options }; } /** * Redirect to authorization URL (for browser opening) */ async redirectToAuthorization(authorizationUrl: URL): Promise<void> { logger.oauth(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`); try { await open(authorizationUrl.toString()); logger.oauth('Browser opened automatically.'); } catch (error) { logger.warn( 'Could not open browser automatically. Please copy and paste the URL above into your browser.', 'OAUTH' ); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Automattic/mcp-wordpress-remote'

If you have feedback or need assistance with the MCP directory API, please join our Discord server