import { createServer, IncomingMessage, ServerResponse, Server } from 'http'; import { URL } from 'url'; export interface OAuthCallbackResult { code?: string; error?: string; state?: string; } export interface OAuthServerOptions { startPort?: number; endPort?: number; timeout?: number; // milliseconds to wait for callback } /** * Starts a temporary HTTP server to capture OAuth callbacks * Automatically finds an available port in the specified range */ export class OAuthServer { private server: Server | null = null; private port: number | null = null; private callbackPromise: Promise | null = null; private callbackResolve: ((result: OAuthCallbackResult) => void) | null = null; private connections: Set = new Set(); /** * Start the OAuth callback server * Returns the port number it's listening on */ async start(options: OAuthServerOptions = {}): Promise { const startPort = options.startPort || 8080; const endPort = options.endPort || 8090; const timeout = options.timeout || 120000; // 2 minutes default // Create promise that will be resolved when callback is received this.callbackPromise = new Promise((resolve, reject) => { this.callbackResolve = resolve; // Set timeout setTimeout(() => { reject(new Error('OAuth callback timeout - no response received')); }, timeout); }); // Try to find an available port for (let port = startPort; port <= endPort; port++) { try { await this.tryStartServer(port); this.port = port; return port; } catch (error: any) { if (port === endPort) { throw new Error( `No available ports found in range ${startPort}-${endPort}. ` + `Please close other applications using these ports and try again.` ); } // Try next port continue; } } throw new Error('Failed to start OAuth server'); } /** * Wait for the OAuth callback * Automatically stops the server after receiving the callback */ async waitForCallback(): Promise { if (!this.callbackPromise) { throw new Error('Server not started. Call start() first.'); } try { const result = await this.callbackPromise; return result; } finally { await this.stop(); } } /** * Stop the server */ async stop(): Promise { if (this.server) { // Force-close all connections for (const conn of this.connections) { conn.destroy(); } this.connections.clear(); return new Promise((resolve, reject) => { this.server!.close((err) => { this.server = null; this.port = null; if (err) reject(err); else resolve(); }); }); } } /** * Get the current port (null if not started) */ getPort(): number | null { return this.port; } /** * Try to start server on a specific port */ private tryStartServer(port: number): Promise { return new Promise((resolve, reject) => { this.server = createServer((req: IncomingMessage, res: ServerResponse) => { this.handleRequest(req, res); }); // Track all connections so we can force-close them this.server.on('connection', (conn) => { this.connections.add(conn); conn.on('close', () => { this.connections.delete(conn); }); }); this.server.on('error', (err: any) => { if (err.code === 'EADDRINUSE') { reject(new Error(`Port ${port} is already in use`)); } else { reject(err); } }); this.server.listen(port, 'localhost', () => { resolve(); }); }); } /** * Handle incoming HTTP requests */ private handleRequest(req: IncomingMessage, res: ServerResponse): void { if (!req.url || !this.port) { this.sendResponse(res, 400, 'Bad Request'); return; } const url = new URL(req.url, `http://localhost:${this.port}`); // Only handle /callback path if (url.pathname !== '/callback') { this.sendResponse(res, 404, 'Not Found'); return; } // Extract OAuth parameters from query string const code = url.searchParams.get('code'); const error = url.searchParams.get('error'); const state = url.searchParams.get('state'); if (error) { // OAuth error const errorDescription = url.searchParams.get('error_description') || error; this.sendResponse( res, 400, 'Authentication Failed', `

Error: ${this.escapeHtml(errorDescription)}

` ); if (this.callbackResolve) { // Delay to ensure response is fully sent before server closes setTimeout(() => { this.callbackResolve!({ error, state: state || undefined }); }, 500); } } else if (code) { // Success this.sendResponse( res, 200, 'Authentication Successful', '

You have been successfully authenticated!

You can close this window and return to your terminal.

' ); if (this.callbackResolve) { // Delay to ensure response is fully sent before server closes setTimeout(() => { this.callbackResolve!({ code, state: state || undefined }); }, 500); } } else { // Missing parameters this.sendResponse( res, 400, 'Bad Request', '

Missing authorization code

' ); if (this.callbackResolve) { // Delay to ensure response is fully sent before server closes setTimeout(() => { this.callbackResolve!({ error: 'missing_code' }); }, 500); } } } /** * Send HTTP response with HTML page */ private sendResponse( res: ServerResponse, statusCode: number, title: string, body: string = '' ): void { const html = ` ${this.escapeHtml(title)}
${statusCode === 200 ? '✓' : '✗'}

${this.escapeHtml(title)}

${body}
`; res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(html), }); res.end(html); } /** * Escape HTML to prevent XSS */ private escapeHtml(text: string): string { const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; return text.replace(/[&<>"']/g, (m) => map[m]); } }