Skip to main content
Glama
GitHubAuthManager.ts•29.7 kB
/** * GitHub authentication manager using OAuth device flow * Handles authentication for MCP servers without requiring client secrets */ import { TokenManager } from '../security/tokenManager.js'; import { logger } from '../utils/logger.js'; import { APICache } from '../cache/APICache.js'; import { UnicodeValidator } from '../security/validators/unicodeValidator.js'; import { SecurityMonitor } from '../security/securityMonitor.js'; import { ErrorHandler, ErrorCategory } from '../utils/ErrorHandler.js'; import { ConfigManager } from '../config/ConfigManager.js'; export interface DeviceCodeResponse { device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number; } export interface TokenResponse { access_token: string; token_type: string; scope: string; } export interface AuthStatus { isAuthenticated: boolean; hasToken: boolean; username?: string; scopes?: string[]; expiresAt?: Date; } /** * Manages GitHub authentication using the OAuth device flow * This is the recommended approach for CLI/desktop applications */ export class GitHubAuthManager { /** * DollhouseMCP's official OAuth App Client ID * This is PUBLIC information - OAuth Client IDs are meant to be visible. * Only Client Secrets are private (device flow doesn't use secrets). * * This Client ID enables the GitHub device flow authentication * allowing users to authenticate with an 8-character code. */ private static readonly DEFAULT_CLIENT_ID = 'Ov23li9gyNZP6m9aJ2EP'; /** * Get the CLIENT_ID from environment variable, ConfigManager, or default * Priority: Environment variable > ConfigManager > Default Client ID * * @returns The OAuth Client ID to use for authentication */ public static async getClientId(): Promise<string | null> { // Check environment variable first (for backward compatibility) const envClientId = process.env.DOLLHOUSE_GITHUB_CLIENT_ID; if (envClientId) { logger.debug('Using OAuth Client ID from environment variable'); return envClientId; } // Check ConfigManager for stored configuration try { const configManager = ConfigManager.getInstance(); await configManager.initialize(); const configClientId = configManager.getGitHubClientId(); if (configClientId) { logger.debug('Using OAuth Client ID from config'); return configClientId; } } catch (error) { logger.debug('No OAuth Client ID in config', { error }); } // Use default DollhouseMCP OAuth App Client ID // This enables "just works" experience for NPM installs logger.debug('Using default DollhouseMCP OAuth Client ID'); return GitHubAuthManager.DEFAULT_CLIENT_ID; } // GitHub OAuth endpoints private static readonly DEVICE_CODE_URL = 'https://github.com/login/device/code'; private static readonly TOKEN_URL = 'https://github.com/login/oauth/access_token'; private static readonly USER_URL = 'https://api.github.com/user'; // Polling configuration private static readonly DEFAULT_POLL_INTERVAL = 5000; // 5 seconds private static readonly MAX_POLL_ATTEMPTS = 180; // 15 minutes total /** * OAuth error codes that require immediate propagation per RFC 6749/8628. * These are terminal errors that cannot be recovered by retrying. */ private static readonly TERMINAL_OAUTH_ERROR_CODES = [ 'expired_token', // Authorization code has expired 'access_denied', // User explicitly denied authorization 'unsupported_grant_type', // Invalid grant type (configuration error) 'invalid_grant' // Invalid or expired device code ] as const; /** * Error message patterns that indicate terminal OAuth errors. * Used for message-based error detection when error codes aren't available. */ private static readonly TERMINAL_ERROR_PATTERNS = [ 'authorization code has expired', 'Authorization was denied', 'Authentication failed', 'expired_token', 'access_denied' ] as const; private apiCache: APICache; private activePolling: AbortController | null = null; constructor(apiCache: APICache) { this.apiCache = apiCache; } /** * Determines if an OAuth error is terminal and should propagate immediately. * * Per RFC 6749 (OAuth 2.0) and RFC 8628 (Device Authorization Grant): * - Terminal errors (expired_token, access_denied) MUST stop polling immediately * - Transient errors (network failures, slow_down) should be retried * * Error Detection Priority (most to least reliable): * 1. Explicit error code parameter (from GitHub API response) * 2. Error code embedded in Error object properties * 3. Message pattern matching (fallback for compatibility) * * @param error - The error to check * @param errorCode - Optional OAuth error code from API response * @returns true if error is terminal and should propagate, false if retriable * * @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 * @see https://docs.github.com/en/developers/apps/authorizing-oauth-apps */ private static isTerminalOAuthError(error: Error, errorCode?: string): boolean { // PRIORITY 1: Check explicit error code parameter (most reliable) // This comes directly from GitHub's API response: { error: "expired_token" } if (errorCode && GitHubAuthManager.TERMINAL_OAUTH_ERROR_CODES.includes(errorCode as any)) { return true; } // PRIORITY 2: Check if error has embedded error code in properties // GitHub often includes error code in Error object properties const errorObj = error as any; if (errorObj.code && GitHubAuthManager.TERMINAL_OAUTH_ERROR_CODES.includes(errorObj.code)) { return true; } // PRIORITY 3: Fall back to message pattern matching (least reliable) // Only used for backward compatibility and unknown error formats // Message text can change, so this is brittle but necessary for robustness const errorMessage = error.message.toLowerCase(); return GitHubAuthManager.TERMINAL_ERROR_PATTERNS.some(pattern => errorMessage.includes(pattern.toLowerCase()) ); } /** * Execute a network request with retry logic */ private async fetchWithRetry( url: string, options: RequestInit, maxRetries: number = 3, retryDelay: number = 1000 ): Promise<Response> { let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetch(url, options); return response; } catch (error) { lastError = error as Error; ErrorHandler.logError('GitHubAuthManager.fetchWithRetry', error, { url, attempt }); // Check if it's a network error that should be retried const isNetworkError = error instanceof Error && ( error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT') || error.message.includes('ENOTFOUND') || error.message.includes('network') ); if (isNetworkError && attempt < maxRetries) { logger.debug(`Network request failed, retrying (${attempt}/${maxRetries})`, { url, error: error.message, nextRetryIn: retryDelay * attempt }); // Exponential backoff await new Promise(resolve => setTimeout(resolve, retryDelay * attempt)); } else { // Not a network error or last attempt, throw immediately throw error; } } } throw lastError || new Error('Network request failed after all retries'); } /** * Check current authentication status */ async getAuthStatus(): Promise<AuthStatus> { const token = await TokenManager.getGitHubTokenAsync(); if (!token) { return { isAuthenticated: false, hasToken: false }; } try { // Try to get user info to validate token const userInfo = await this.fetchUserInfo(token); return { isAuthenticated: true, hasToken: true, username: userInfo.login, scopes: userInfo.scopes }; } catch (error) { // Token might be invalid or expired ErrorHandler.logError('GitHubAuthManager.checkAuthStatus', error); return { isAuthenticated: false, hasToken: true // Has token but it's invalid }; } } /** * Initiate the device flow authentication process */ async initiateDeviceFlow(): Promise<DeviceCodeResponse> { const clientId = await GitHubAuthManager.getClientId(); // getClientId() always returns a value (env, config, or default) // Log the OAuth flow step for debugging logger.debug('OAUTH_STEP_1: Getting client ID', { clientId: clientId?.substring(0, 8) + '...' }); if (!clientId) { throw new Error('OAUTH_NO_CLIENT_ID: No OAuth client ID configured. Set DOLLHOUSE_GITHUB_CLIENT_ID environment variable.'); } logger.debug('OAUTH_STEP_2: Initiating device flow', { url: GitHubAuthManager.DEVICE_CODE_URL }); try { const response = await this.fetchWithRetry(GitHubAuthManager.DEVICE_CODE_URL, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: clientId, scope: 'public_repo read:user' }) }); logger.debug('OAUTH_STEP_3: GitHub response', { status: response.status, statusText: response.statusText, headers: { 'x-github-request-id': response.headers.get('x-github-request-id'), 'x-ratelimit-remaining': response.headers.get('x-ratelimit-remaining') } }); if (!response.ok) { const responseText = await response.text(); logger.error('GitHub OAuth endpoint error', { status: response.status, statusText: response.statusText, responseBody: responseText, clientId: clientId?.substring(0, 8) + '...' }); // Parse GitHub's error response for specific error codes try { const errorData = JSON.parse(responseText); if (errorData.error === 'unauthorized_client') { throw new Error(`OAUTH_CLIENT_UNAUTHORIZED: OAuth app '${clientId?.substring(0, 8)}...' is not authorized for device flow. The app may need reconfiguration.`); } if (errorData.error === 'invalid_client') { throw new Error(`OAUTH_CLIENT_INVALID: GitHub rejected OAuth client ID '${clientId?.substring(0, 8)}...'. The app may not exist or be disabled.`); } if (errorData.error_description) { throw new Error(`OAUTH_API_ERROR: ${errorData.error_description}`); } } catch (parseError) { // If we can't parse the error, provide HTTP status specific error if (response.status === 401) { throw new Error(`OAUTH_CLIENT_INVALID: GitHub rejected OAuth client ID '${clientId?.substring(0, 8)}...'. The app may not exist or be disabled.`); } if (response.status === 403) { throw new Error(`OAUTH_DEVICE_FLOW_DISABLED: This OAuth app doesn't have device flow enabled. Contact administrator.`); } if (response.status === 429) { throw new Error(`OAUTH_RATE_LIMITED: Too many authentication attempts. Please wait before trying again.`); } throw new Error(`OAUTH_HTTP_${response.status}: GitHub OAuth failed - ${response.statusText}`); } } const data = await response.json(); // Validate response if (!data.device_code || !data.user_code || !data.verification_uri) { logger.error('Invalid device flow response structure', { hasDeviceCode: !!data.device_code, hasUserCode: !!data.user_code, hasVerificationUri: !!data.verification_uri }); throw new Error('OAUTH_INVALID_RESPONSE: Invalid device flow response from GitHub - missing required fields'); } // Log security event for audit trail SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'GitHubAuthManager.initiateDeviceFlow', details: 'GitHub OAuth device flow initiated successfully', metadata: { userCode: data.user_code, expiresIn: data.expires_in, interval: data.interval } }); return data as DeviceCodeResponse; } catch (error) { ErrorHandler.logError('GitHubAuthManager.initiateDeviceFlow', error); // Check if it's a network error if (error instanceof Error) { // Re-throw if it's already a properly formatted error with code if (error.message.startsWith('OAUTH_')) { throw error; } // Format network errors if (error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT') || error.message.includes('ENOTFOUND')) { throw new Error(`OAUTH_NETWORK_ERROR: Unable to reach GitHub servers (https://github.com/login/device/code). Check your internet connection.`); } if (error.message.includes('network')) { throw new Error(`OAUTH_NETWORK_ERROR: Network error while connecting to GitHub. Please check your internet connection.`); } } // Generic fallback (should rarely happen) throw new Error(`OAUTH_UNKNOWN_ERROR: Failed to start GitHub authentication - ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Poll GitHub for OAuth token using device flow. * * Implements OAuth 2.0 Device Authorization Grant (RFC 8628) with proper error handling. * * ## OAuth 2.0 Compliance (RFC 6749/8628) * * ### Terminal Errors (MUST propagate immediately): * - `expired_token` - Authorization code has expired, user must restart flow * - `access_denied` - User explicitly denied authorization * - `unsupported_grant_type` - Invalid grant type (configuration error) * - `invalid_grant` - Invalid or expired device code * * ### Transient Errors (should be retried): * - `authorization_pending` - User hasn't completed authorization yet * - `slow_down` - Polling too frequently, increase interval * - Network errors (ECONNREFUSED, ETIMEDOUT, etc.) * * ### Error Handling Flow: * 1. GitHub returns error code in response (e.g., `{error: "expired_token"}`) * 2. Check if error is terminal using `isTerminalOAuthError()` * 3. Terminal errors throw immediately, stopping polling * 4. Transient errors are logged and polling continues * 5. After MAX_POLL_ATTEMPTS (15 minutes), timeout error is thrown * * @param deviceCode - Device code from GitHub authorization flow * @param interval - Polling interval in milliseconds (default: 5000ms) * @returns Promise resolving to TokenResponse with access token * @throws {Error} Terminal OAuth errors (expired_token, access_denied, etc.) * @throws {Error} Timeout after MAX_POLL_ATTEMPTS (180 attempts = 15 minutes) * @throws {Error} Network errors that persist beyond retry logic * * @see https://datatracker.ietf.org/doc/html/rfc8628 - OAuth 2.0 Device Authorization Grant * @see https://datatracker.ietf.org/doc/html/rfc6749 - OAuth 2.0 Authorization Framework */ async pollForToken(deviceCode: string, interval: number = GitHubAuthManager.DEFAULT_POLL_INTERVAL): Promise<TokenResponse> { // Create new abort controller for this polling session this.activePolling = new AbortController(); const signal = this.activePolling.signal; let attempts = 0; try { while (attempts < GitHubAuthManager.MAX_POLL_ATTEMPTS) { // Check if polling was aborted if (signal.aborted) { throw new Error('Authentication polling was cancelled'); } attempts++; try { const response = await fetch(GitHubAuthManager.TOKEN_URL, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: await GitHubAuthManager.getClientId() || '', device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }) }); const data = await response.json(); // RFC 8628 Section 3.5: Handle OAuth device flow responses if (data.error) { const errorCode = data.error; // Extract error code for robust detection switch (errorCode) { case 'authorization_pending': { // Transient: User hasn't authorized yet, continue polling logger.debug('Authorization pending, continuing to poll', { attempt: attempts }); break; } case 'slow_down': { // Transient: Server requests slower polling, adjust interval interval = Math.min(interval * 1.5, 30000); // Max 30 seconds logger.debug('Slowing down polling interval per server request', { newInterval: interval, attempt: attempts }); break; } case 'expired_token': { // TERMINAL: Authorization code expired (RFC 8628 Section 3.5) throw new Error('The authorization code has expired. Please start over.'); } case 'access_denied': { // TERMINAL: User explicitly denied authorization (RFC 8628 Section 3.5) throw new Error('Authorization was denied. Please try again.'); } case 'unsupported_grant_type': case 'invalid_grant': { // TERMINAL: Configuration or code issue (RFC 6749 Section 5.2) logger.error('OAuth grant error', { error: errorCode, description: data.error_description }); throw new Error('Authentication failed. Please try starting the process again.'); } default: { // Unknown error - treat as terminal to avoid infinite polling logger.debug('Unknown OAuth error, treating as terminal', { error: errorCode, description: data.error_description }); // Embed error code in Error object for isTerminalOAuthError detection const unknownError = new Error('Authentication failed. Please try starting the process again.'); (unknownError as any).code = errorCode; throw unknownError; } } } else if (data.access_token) { // Success! User authorized and token is ready logger.info('OAuth device flow completed successfully', { attempts }); return data as TokenResponse; } // No error and no token - wait and continue polling await this.waitWithAbort(interval, signal); } catch (error) { // RFC 6749/8628 Compliance: Terminal errors MUST propagate immediately // Use helper function for robust terminal error detection // Pass error code if available for priority detection const errorCode = (error as any)?.code; if (error instanceof Error && GitHubAuthManager.isTerminalOAuthError(error, errorCode)) { logger.debug('Terminal OAuth error detected, stopping polling', { error: error.message, errorCode: errorCode, attempt: attempts }); throw error; // Terminal error - propagate immediately, stop polling } // Transient errors (network failures, etc.) - log and retry // These shouldn't stop polling as they may be temporary issues ErrorHandler.logError('GitHubAuthManager.pollForToken', error, { attempt: attempts, willRetry: true }); await this.waitWithAbort(interval, signal); } } throw new Error('Authentication timed out. Please try again.'); } finally { // Clear active polling reference this.activePolling = null; } } /** * Complete the authentication flow and store the token */ async completeAuthentication(tokenResponse: TokenResponse): Promise<AuthStatus> { // Store token securely await this.storeToken(tokenResponse.access_token); // Get user info const userInfo = await this.fetchUserInfo(tokenResponse.access_token); // Log successful authentication completion SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'GitHubAuthManager.completeAuthentication', details: 'GitHub OAuth device flow completed successfully', metadata: { username: userInfo.login, scopes: tokenResponse.scope.split(' '), tokenType: TokenManager.getTokenType(tokenResponse.access_token) } }); return { isAuthenticated: true, hasToken: true, username: userInfo.login, scopes: tokenResponse.scope.split(' ') }; } /** * Clear stored authentication and revoke token */ async clearAuthentication(): Promise<void> { try { // Get the token before clearing it const token = await TokenManager.getGitHubTokenAsync(); if (token) { // Attempt to revoke the token on GitHub // Note: GitHub OAuth tokens don't have a revocation endpoint for device flow tokens // But we'll clear the cache and remove from storage // Clear cached user info this.apiCache.clear(); // Log security event for audit trail SecurityMonitor.logSecurityEvent({ type: 'TOKEN_CACHE_CLEARED', severity: 'LOW', source: 'GitHubAuthManager.clearAuthentication', details: 'GitHub authentication cleared by user request', metadata: { hadToken: true, tokenPrefix: TokenManager.getTokenPrefix(token) } }); } // Remove from secure storage await TokenManager.removeStoredToken(); logger.info('GitHub authentication cleared successfully'); } catch (error) { ErrorHandler.logError('GitHubAuthManager.clearAuthentication', error); throw ErrorHandler.createError('Failed to clear authentication', ErrorCategory.AUTH_ERROR, undefined, error); } } /** * Store token securely using encrypted file storage */ private async storeToken(token: string): Promise<void> { try { await TokenManager.storeGitHubToken(token); logger.info('GitHub token stored securely. You are now authenticated!'); } catch (error) { ErrorHandler.logError('GitHubAuthManager.storeToken', error); // Fallback to environment variable instructions logger.info('For manual setup, you can set GITHUB_TOKEN environment variable.'); throw ErrorHandler.wrapError(error, 'Failed to store GitHub token', ErrorCategory.AUTH_ERROR); } } /** * Fetch user information from GitHub */ private async fetchUserInfo(token: string): Promise<any> { // Check cache first const cached = this.apiCache.get(GitHubAuthManager.USER_URL); if (cached) { return cached; } const response = await this.fetchWithRetry(GitHubAuthManager.USER_URL, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github.v3+json' } }); if (!response.ok) { const errorMessage = this.getErrorMessageForStatus(response.status, 'user information fetch'); logger.debug('Failed to fetch user info', { status: response.status }); throw new Error(errorMessage); } const data = await response.json(); // Normalize username and other text fields to prevent Unicode attacks if (data.login) { const validation = UnicodeValidator.normalize(data.login); if (!validation.isValid) { SecurityMonitor.logSecurityEvent({ type: 'UNICODE_VALIDATION_ERROR', severity: 'MEDIUM', source: 'GitHubAuthManager.fetchUserInfo', details: 'GitHub username contains invalid Unicode', metadata: { originalLength: data.login.length, detectedIssues: validation.detectedIssues } }); throw new Error('Invalid username format from GitHub'); } data.login = validation.normalizedContent; } // Normalize display name if present if (data.name) { const nameValidation = UnicodeValidator.normalize(data.name); if (nameValidation.isValid) { data.name = nameValidation.normalizedContent; } else { // Don't fail on display name, just remove it delete data.name; } } // Add scopes from response headers const scopeHeader = response.headers.get('x-oauth-scopes'); if (scopeHeader) { data.scopes = scopeHeader.split(',').map(s => s.trim()); } // Log successful authentication for audit trail SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'GitHubAuthManager.fetchUserInfo', details: 'GitHub user authenticated successfully', metadata: { username: data.login, hasEmail: !!data.email, scopes: data.scopes || [] } }); // Cache the result this.apiCache.set(GitHubAuthManager.USER_URL, data); return data; } /** * Format authentication instructions for users */ formatAuthInstructions(deviceResponse: DeviceCodeResponse): string { return `šŸ” **GitHub Authentication Required** To access all DollhouseMCP features, please authenticate with GitHub: 1. Visit: **${deviceResponse.verification_uri}** 2. Enter code: **${deviceResponse.user_code}** 3. Authorize 'DollhouseMCP Collection' This will grant access to: āœ… Browse the public collection āœ… Install community content āœ… Submit your own creations Don't have a GitHub account? You'll be prompted to create one (it's free!) ā±ļø This code expires in ${Math.floor(deviceResponse.expires_in / 60)} minutes.`; } /** * Check if user needs to authenticate for a specific action */ needsAuthForAction(action: string): boolean { const authRequiredActions = ['submit', 'create_pr', 'manage_content']; return authRequiredActions.includes(action.toLowerCase()); } /** * Wait with abort signal support */ private async waitWithAbort(ms: number, signal: AbortSignal): Promise<void> { return new Promise((resolve, reject) => { const timeout = setTimeout(resolve, ms); // Listen for abort signal const abortHandler = () => { clearTimeout(timeout); reject(new Error('Wait aborted')); }; signal.addEventListener('abort', abortHandler, { once: true }); // Clean up after timeout setTimeout(() => { signal.removeEventListener('abort', abortHandler); }, ms); }); } /** * Clean up any active operations (called on server shutdown) */ async cleanup(): Promise<void> { // Abort any active polling if (this.activePolling) { this.activePolling.abort(); this.activePolling = null; SecurityMonitor.logSecurityEvent({ type: 'TOKEN_CACHE_CLEARED', severity: 'LOW', source: 'GitHubAuthManager.cleanup', details: 'GitHub auth manager cleaned up on shutdown', metadata: { hadActivePolling: true } }); logger.info('GitHub authentication polling cancelled due to shutdown'); } // Clear API cache this.apiCache.clear(); } /** * Get user-friendly error message based on HTTP status code * Avoids exposing sensitive information while providing helpful guidance */ private getErrorMessageForStatus(status: number, operation: string): string { switch (status) { case 400: return `Invalid request to GitHub. Please ensure the OAuth app is properly configured.`; case 401: return `GitHub OAuth is not configured. Please report this issue at: https://github.com/DollhouseMCP/mcp-server/issues`; case 403: return `Access denied by GitHub. The OAuth app may lack required permissions.`; case 404: return `GitHub service not found. This may indicate an API change.`; case 422: return `Invalid parameters sent to GitHub. Please check your configuration.`; case 429: return `Too many requests to GitHub. Please wait a moment and try again.`; case 500: case 502: case 503: case 504: return `GitHub service temporarily unavailable. Please try again in a few moments.`; default: // Log the actual status for debugging, but don't expose it to users logger.debug(`Unexpected status code during ${operation}`, { status }); return `Failed to complete ${operation}. Please check your internet connection and try again.`; } } }

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/DollhouseMCP/mcp-server'

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