Skip to main content
Glama
oauth-server.ts12.7 kB
/** * OAuth Server for Browser-Based Authentication * Handles the OAuth flow like GitHub/JIRA MCPs */ import * as http from 'http'; import * as crypto from 'crypto'; import { spawn } from 'child_process'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; interface OAuthConfig { clientId?: string; redirectUri: string; authUrl: string; tokenUrl: string; scope?: string; } // Claude MCP client uses port 6274 for OAuth callbacks const CLAUDE_OAUTH_CALLBACK_PORT = 6274; // Fallback ports if 6274 is unavailable const FALLBACK_PORTS = [6274, 6275, 3001, 0]; export class OAuthServer { private server?: http.Server; private port: number = 0; private state: string; private codeVerifier?: string; private codeChallenge?: string; private credentialsPath: string; constructor() { this.state = crypto.randomBytes(32).toString('base64url'); // Generate PKCE challenge this.codeVerifier = crypto.randomBytes(32).toString('base64url'); const hash = crypto.createHash('sha256').update(this.codeVerifier).digest(); this.codeChallenge = hash.toString('base64url'); // Credentials path const configDir = path.join(os.homedir(), '.aidd-mcp'); this.credentialsPath = path.join(configDir, 'oauth-tokens.json'); } /** * Start OAuth flow - opens browser for authentication */ async authenticate(): Promise<any> { return new Promise(async (resolve, reject) => { // Start local server to receive callback this.server = http.createServer(async (req, res) => { const url = new URL(req.url!, `http://localhost:${this.port}`); // Handle both /callback and /oauth/callback paths for Claude compatibility if (url.pathname === '/callback' || url.pathname === '/oauth/callback' || url.pathname === '/oauth/callback/debug') { // Handle OAuth callback const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: system-ui; padding: 40px; text-align: center;"> <h1>❌ Authentication Failed</h1> <p>Error: ${error}</p> <p>You can close this window and try again.</p> </body> </html> `); reject(new Error(`OAuth error: ${error}`)); this.cleanup(); return; } if (state !== this.state) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: system-ui; padding: 40px; text-align: center;"> <h1>❌ Security Error</h1> <p>Invalid state parameter. Please try again.</p> </body> </html> `); reject(new Error('Invalid state parameter')); this.cleanup(); return; } if (code) { // Exchange code for tokens try { const tokens = await this.exchangeCodeForTokens(code); // Save tokens await this.saveTokens(tokens); // Send success response res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` <html> <head> <meta charset="utf-8"> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px; text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; height: 100vh; margin: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; } .container { background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); padding: 40px; border-radius: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); } h1 { font-size: 48px; margin-bottom: 20px; } .checkmark { font-size: 72px; animation: bounce 0.5s; } @keyframes bounce { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } .message { font-size: 18px; margin-top: 20px; opacity: 0.9; } .close-hint { margin-top: 30px; font-size: 14px; opacity: 0.7; } </style> <script> setTimeout(() => window.close(), 3000); </script> </head> <body> <div class="container"> <div class="checkmark">✅</div> <h1>Authentication Successful!</h1> <p class="message">You're now connected to AiDD</p> <p class="close-hint">You can close this window and return to Claude Desktop</p> </div> </body> </html> `); resolve(tokens); setTimeout(() => this.cleanup(), 1000); } catch (error) { res.writeHead(500, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: system-ui; padding: 40px; text-align: center;"> <h1>❌ Token Exchange Failed</h1> <p>${error}</p> </body> </html> `); reject(error); this.cleanup(); } } } else { // Default response res.writeHead(404); res.end('Not found'); } }); // Try Claude's preferred port first (6274), then fallbacks await this.tryListenOnPorts(FALLBACK_PORTS); }); } /** * Try to listen on preferred ports in order */ private async tryListenOnPorts(ports: number[]): Promise<void> { for (const port of ports) { try { await new Promise<void>((resolve, reject) => { this.server!.once('error', (err: any) => { if (err.code === 'EADDRINUSE') { console.error(`Port ${port} in use, trying next...`); reject(err); } else { reject(err); } }); this.server!.listen(port, '127.0.0.1', () => { const address = this.server!.address(); this.port = (address as any).port; console.error(`OAuth callback server listening on port ${this.port}`); resolve(); }); }); // Successfully bound to port, open browser this.openAuthUrl(); return; } catch { // Try next port continue; } } throw new Error('Could not bind to any available port'); } /** * Open browser with OAuth authorization URL */ private async openAuthUrl() { const authUrl = new URL('https://aidd-backend-prod-739193356129.us-central1.run.app/oauth/authorize'); authUrl.searchParams.append('response_type', 'code'); authUrl.searchParams.append('client_id', 'aidd-mcp-client'); // Use Claude-compatible callback path authUrl.searchParams.append('redirect_uri', `http://localhost:${this.port}/oauth/callback`); authUrl.searchParams.append('state', this.state); authUrl.searchParams.append('code_challenge', this.codeChallenge!); authUrl.searchParams.append('code_challenge_method', 'S256'); authUrl.searchParams.append('scope', 'profile email subscriptions tasks'); const url = authUrl.toString(); // Open in default browser const platform = process.platform; let command: string; if (platform === 'darwin') { command = 'open'; } else if (platform === 'win32') { command = 'start'; } else { command = 'xdg-open'; } console.error(`Opening browser for authentication: ${url}`); spawn(command, [url], { detached: true }); } /** * Exchange authorization code for tokens */ private async exchangeCodeForTokens(code: string): Promise<any> { const response = await fetch('https://aidd-backend-prod-739193356129.us-central1.run.app/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ grant_type: 'authorization_code', code: code, redirect_uri: `http://localhost:${this.port}/oauth/callback`, client_id: 'aidd-mcp-client', code_verifier: this.codeVerifier, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Token exchange failed: ${error}`); } return await response.json(); } /** * Save tokens to disk */ private async saveTokens(tokens: any) { const configDir = path.dirname(this.credentialsPath); await fs.mkdir(configDir, { recursive: true }); const data = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + (tokens.expires_in * 1000), tokenType: tokens.token_type, scope: tokens.scope, user: tokens.user, }; // Encrypt and save const encrypted = this.encrypt(JSON.stringify(data)); await fs.writeFile(this.credentialsPath, encrypted, 'utf-8'); await fs.chmod(this.credentialsPath, 0o600); } /** * Load saved tokens */ async loadTokens(): Promise<any | null> { try { const encrypted = await fs.readFile(this.credentialsPath, 'utf-8'); const decrypted = this.decrypt(encrypted); return JSON.parse(decrypted); } catch { return null; } } /** * Refresh access token */ async refreshToken(refreshToken: string): Promise<any> { const response = await fetch('https://aidd-backend-prod-739193356129.us-central1.run.app/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: 'aidd-mcp-client', }), }); if (!response.ok) { throw new Error('Token refresh failed'); } const tokens = await response.json(); await this.saveTokens(tokens); return tokens; } /** * Revoke tokens (sign out) */ async revokeTokens(token: string): Promise<void> { await fetch('https://aidd-backend-prod-739193356129.us-central1.run.app/oauth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ token: token, client_id: 'aidd-mcp-client', }), }); // Delete local tokens try { await fs.unlink(this.credentialsPath); } catch { // File might not exist } } private encrypt(text: string): string { const algorithm = 'aes-256-cbc'; const key = crypto.scryptSync('aidd-oauth-key', 'salt', 32); const iv = Buffer.alloc(16, 0); const cipher = crypto.createCipheriv(algorithm, key, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); return encrypted; } private decrypt(encrypted: string): string { const algorithm = 'aes-256-cbc'; const key = crypto.scryptSync('aidd-oauth-key', 'salt', 32); const iv = Buffer.alloc(16, 0); const decipher = crypto.createDecipheriv(algorithm, key, iv); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } private cleanup() { if (this.server) { this.server.close(); this.server = undefined; } } }

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

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