Skip to main content
Glama
mcp-oauth-provider.ts16.8 kB
/** * MCP-Compliant OAuth 2.1 Provider * Implementation of MCP Authorization specification 2025-06-18 */ import open from 'open'; import { EventEmitter } from 'node:events'; import { WPTokens, WPClientInfo, OAuthError, MCPOAuthConfig, AuthorizationServerMetadata, ProtectedResourceMetadata, ClientRegistrationRequest, ClientRegistrationResponse, PKCEData, WWWAuthenticateHeader, } from './oauth-types.js'; import { generateServerUrlHash, readTokens, writeTokens, readClientInfo, writeClientInfo, writeTextFile, readTextFile, deleteConfigFile, isTokenValid, } from './persistent-auth-config.js'; import { generatePKCE, generateCanonicalResourceURI, discoverAuthorizationServerMetadata, discoverProtectedResourceMetadata, parseWWWAuthenticateHeader, registerDynamicClient, exchangeAuthorizationCode, buildAuthorizationUrl, generateSecureState, } from './mcp-oauth-utils.js'; import { setupWPOAuthCallbackServer } from './oauth-callback-server.js'; import { logger } from './utils.js'; import { CONFIG, getDefaultOAuthScopes, getOAuthCallbackPort, getCustomHeaders } from './config.js'; /** * MCP-compliant OAuth 2.1 Provider for WordPress * Implements OAuth 2.1 with PKCE, Resource Indicators, and Dynamic Client Registration */ export class MCPOAuthProvider { private config: MCPOAuthConfig; private serverUrlHash: string; private events: EventEmitter; private authPromise: Promise<WPTokens> | null = null; // OAuth 2.1 flow state private currentState: string | null = null; private currentPKCE: PKCEData | null = null; // Discovered metadata private authServerMetadata: AuthorizationServerMetadata | null = null; private resourceMetadata: ProtectedResourceMetadata | null = null; constructor(options: Partial<MCPOAuthConfig>) { const serverUrl = options.serverUrl || CONFIG.WP_API_URL; const defaultScopes = getDefaultOAuthScopes(); // Build MCP-compliant configuration this.config = { serverUrl, resource: generateCanonicalResourceURI(serverUrl), responseType: 'code', // OAuth 2.1 uses authorization code flow usePKCE: true, // PKCE is required for OAuth 2.1 redirectUri: `http://${CONFIG.OAUTH_HOST}:${CONFIG.OAUTH_CALLBACK_PORT || 0}/oauth/callback`, scopes: options.scopes || defaultScopes, clientId: options.clientId || CONFIG.WP_OAUTH_CLIENT_ID, callbackPort: CONFIG.OAUTH_CALLBACK_PORT || 0, // 0 = auto-select host: CONFIG.OAUTH_HOST, timeout: CONFIG.OAUTH_TIMEOUT, ...options, }; this.serverUrlHash = generateServerUrlHash(this.config.serverUrl); this.events = new EventEmitter(); this.events.setMaxListeners(10); logger.oauth('Initialized MCP-compliant OAuth 2.1 provider'); logger.debug('OAuth configuration', 'OAUTH', { serverUrl: this.config.serverUrl, resource: this.config.resource, serverHash: this.serverUrlHash, flowType: this.config.responseType, usePKCE: this.config.usePKCE, callbackPort: this.config.callbackPort, }); } /** * 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; } } /** * Discover OAuth 2.1 server endpoints using MCP-compliant discovery */ private async discoverOAuthEndpoints(): Promise<void> { try { logger.oauth('Starting MCP-compliant OAuth endpoint discovery'); // Step 1: Try to discover protected resource metadata (RFC 9728) try { this.resourceMetadata = await discoverProtectedResourceMetadata(this.config.resource); logger.oauth('Protected resource metadata discovered'); } catch (error) { logger.warn( 'Protected resource metadata discovery failed, trying 401 response method', 'OAUTH' ); // Step 2: Try making a request to get WWW-Authenticate header await this.discoverVia401Response(); } // Step 3: Discover authorization server metadata (RFC 8414) const authServerUrl = this.getAuthorizationServerUrl(); if (authServerUrl) { this.authServerMetadata = await discoverAuthorizationServerMetadata(authServerUrl); logger.oauth('Authorization server metadata discovered'); // Update config with discovered endpoints this.config.authorizationEndpoint = this.authServerMetadata.authorization_endpoint; this.config.tokenEndpoint = this.authServerMetadata.token_endpoint; this.config.registrationEndpoint = this.authServerMetadata.registration_endpoint; } else { throw new OAuthError('Could not determine authorization server URL', 'DISCOVERY_FAILED'); } } catch (error) { logger.error('OAuth endpoint discovery failed', 'OAUTH', error); throw error; } } /** * Discover endpoints via 401 Unauthorized response (RFC 9728 Section 5.1) */ private async discoverVia401Response(): Promise<void> { try { logger.oauth('Attempting discovery via 401 response method'); // Make an unauthenticated request to trigger 401 with WWW-Authenticate header const customHeaders = getCustomHeaders(); const response = await fetch(this.config.serverUrl, { headers: { Accept: 'application/json', ...customHeaders, }, }); if (response.status === 401) { const wwwAuthHeader = response.headers.get('WWW-Authenticate'); if (wwwAuthHeader) { const authInfo = parseWWWAuthenticateHeader(wwwAuthHeader); logger.oauth('WWW-Authenticate header parsed successfully'); if (authInfo.resource_metadata_url) { // Fetch protected resource metadata from the indicated URL const metadataResponse = await fetch(authInfo.resource_metadata_url, { headers: { 'Accept': 'application/json', ...customHeaders, }, }); if (metadataResponse.ok) { this.resourceMetadata = (await metadataResponse.json()) as ProtectedResourceMetadata; logger.oauth('Protected resource metadata obtained via 401 response'); } } } } } catch (error) { logger.warn('401 response discovery method failed', 'OAUTH', error); // This is expected to fail sometimes, continue with fallback methods } } /** * Get authorization server URL from discovered metadata */ private getAuthorizationServerUrl(): string | null { if (this.resourceMetadata?.authorization_servers?.length) { // Use first authorization server from metadata return this.resourceMetadata.authorization_servers[0]; } // Fallback: construct authorization server URL from resource URL try { const url = new URL(this.config.serverUrl); return url.origin; } catch { return null; } } /** * Perform dynamic client registration if needed and supported */ private async ensureClientRegistration(): Promise<void> { if (this.config.clientId) { logger.oauth('Using provided client ID, skipping dynamic registration'); return; } if (!CONFIG.OAUTH_DYNAMIC_REGISTRATION) { throw new OAuthError( 'No client ID provided and dynamic registration is disabled', 'NO_CLIENT_ID' ); } if (!this.authServerMetadata?.registration_endpoint) { throw new OAuthError( 'Dynamic client registration not supported by authorization server', 'NO_DYNAMIC_REGISTRATION' ); } try { logger.oauth('Performing dynamic client registration'); const registrationRequest: ClientRegistrationRequest = { redirect_uris: [this.config.redirectUri], token_endpoint_auth_method: 'none', // Public client grant_types: ['authorization_code'], response_types: ['code'], client_name: 'WordPress MCP Remote', scope: this.config.scopes.join(' '), }; const registrationResponse = await registerDynamicClient( this.authServerMetadata.registration_endpoint, registrationRequest ); // Store client information const clientInfo: WPClientInfo = { client_id: registrationResponse.client_id, client_secret: registrationResponse.client_secret, }; await writeClientInfo(this.serverUrlHash, clientInfo); this.config.clientId = registrationResponse.client_id; logger.oauth('Dynamic client registration completed successfully'); } catch (error) { logger.error('Dynamic client registration failed', 'OAUTH', error); throw error; } } /** * Initiate MCP-compliant OAuth 2.1 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 const existingTokens = await this.tokens(); if (existingTokens) { logger.oauth('Already have valid tokens, skipping authorization'); return; } logger.oauth('Starting MCP-compliant OAuth 2.1 authorization flow'); this.authPromise = this.performAuthorization(); try { await this.authPromise; logger.oauth('OAuth 2.1 authorization completed successfully'); } catch (error) { logger.error('OAuth 2.1 authorization failed', 'OAUTH', error); throw error; } finally { this.authPromise = null; } } /** * Perform the complete OAuth 2.1 authorization flow */ private async performAuthorization(): Promise<WPTokens> { try { // Step 1: Discover OAuth endpoints await this.discoverOAuthEndpoints(); // Step 2: Ensure we have a client ID (via registration if needed) await this.ensureClientRegistration(); // Step 3: Generate PKCE parameters (required for OAuth 2.1) this.currentPKCE = generatePKCE(); this.currentState = generateSecureState(); // Store PKCE verifier for later use await writeTextFile(this.serverUrlHash, 'pkce_verifier.txt', this.currentPKCE.codeVerifier); await writeTextFile(this.serverUrlHash, 'oauth_state.txt', this.currentState); // Step 4: Set up callback server with smart port selection const callbackPort = this.config.callbackPort === 0 ? await getOAuthCallbackPort() : this.config.callbackPort; const callbackServer = setupWPOAuthCallbackServer( { port: callbackPort, host: this.config.host, serverUrlHash: this.serverUrlHash, timeout: this.config.timeout, }, this.events ); await callbackServer.start(); logger.oauth('OAuth callback server started'); // Update redirect URI with the actual port used const actualRedirectUri = `http://${this.config.host}:${callbackPort}/oauth/callback`; // Step 5: Build authorization URL with all required parameters const authUrl = buildAuthorizationUrl( this.config.authorizationEndpoint!, this.config.clientId!, actualRedirectUri, this.config.scopes, this.currentState, this.currentPKCE.codeChallenge, CONFIG.OAUTH_RESOURCE_INDICATOR ? this.config.resource : undefined ); logger.oauth('Built OAuth 2.1 authorization URL'); logger.debug('Authorization URL', 'OAUTH', { url: authUrl }); // Step 6: Open browser for user authorization 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'); } // Step 7: Wait for authorization code const authCode = await this.waitForAuthorizationCode(); // Step 8: Exchange authorization code for access token const tokens = await this.exchangeCodeForTokens(authCode); // Step 9: Store tokens await writeTokens(this.serverUrlHash, tokens); logger.oauth('OAuth 2.1 tokens stored successfully'); await callbackServer.stop(); return tokens; } catch (error) { logger.error('Error during OAuth 2.1 authorization flow', 'OAUTH', error); throw error; } } /** * Wait for authorization code from callback */ private async waitForAuthorizationCode(): Promise<string> { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { cleanup(); reject(new OAuthError('Authorization timeout', 'TIMEOUT')); }, this.config.timeout); const cleanup = () => { this.events.removeAllListeners('oauth-code-received'); this.events.removeAllListeners('oauth-error'); clearTimeout(timeout); }; this.events.once('oauth-code-received', async ({ code, state }) => { cleanup(); // Validate state parameter if (state !== this.currentState) { reject(new OAuthError('State parameter mismatch', 'INVALID_STATE')); return; } logger.oauth('Valid authorization code received'); resolve(code); }); this.events.once('oauth-error', (error: Error) => { cleanup(); reject(error); }); }); } /** * Exchange authorization code for access tokens */ private async exchangeCodeForTokens(code: string): Promise<WPTokens> { try { const codeVerifier = await readTextFile( this.serverUrlHash, 'pkce_verifier.txt', 'PKCE code verifier not found' ); const tokenResponse = await exchangeAuthorizationCode( this.config.tokenEndpoint!, code, this.config.redirectUri, this.config.clientId!, codeVerifier, CONFIG.OAUTH_RESOURCE_INDICATOR ? this.config.resource : undefined ); // Clean up temporary files await deleteConfigFile(this.serverUrlHash, 'pkce_verifier.txt'); await deleteConfigFile(this.serverUrlHash, 'oauth_state.txt'); return tokenResponse; } catch (error) { logger.error('Token exchange failed', 'OAUTH', error); throw error; } } /** * Clear stored credentials and state */ async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'state'): 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, 'pkce_verifier.txt'), deleteConfigFile(this.serverUrlHash, 'oauth_state.txt'), ]); break; case 'client': await deleteConfigFile(this.serverUrlHash, 'client_info.json'); break; case 'tokens': await deleteConfigFile(this.serverUrlHash, 'tokens.json'); break; case 'state': await Promise.all([ deleteConfigFile(this.serverUrlHash, 'pkce_verifier.txt'), deleteConfigFile(this.serverUrlHash, 'oauth_state.txt'), ]); break; } logger.oauth(`Credentials invalidated: ${scope}`); } /** * Check if currently authorized */ async isAuthorized(): Promise<boolean> { const tokens = await this.tokens(); return tokens !== null; } /** * Get server URL hash */ getServerUrlHash(): string { return this.serverUrlHash; } /** * Get OAuth configuration */ getConfig(): MCPOAuthConfig { return { ...this.config }; } }

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