import { renderErrorPage, renderSuccessPage } from './templates.js'; /** * OAuth callback result interface */ export interface CallbackResult { /** Authorization code returned by OAuth provider */ code?: string; /** State parameter for CSRF protection */ state?: string; /** OAuth error code (e.g., 'access_denied', 'invalid_request') */ error?: string; /** Human-readable error description */ error_description?: string; /** URI with additional error information */ error_uri?: string; /** Additional query parameters from OAuth provider */ [key: string]: string | undefined; } /** * Configuration options for the OAuth callback server */ export interface CallbackServerOptions { /** Port number to bind the server to */ port: number; /** Hostname to bind the server to (default: "localhost") */ hostname?: string; /** Custom HTML content for successful authorization */ successHtml?: string; /** Custom HTML template for error pages */ errorHtml?: string; /** AbortSignal for cancelling the server operation */ signal?: AbortSignal; /** Callback function called for each HTTP request */ onRequest?: (req: Request) => void; } /** * OAuth callback server implementation using Bun */ export class OAuthCallbackServer { private server?: { stop: () => void }; private callbackListeners = new Map< string, { resolve: (result: CallbackResult) => void; reject: (error: Error) => void; } >(); private options: CallbackServerOptions | undefined; /** * Start the HTTP server */ async start(options: CallbackServerOptions): Promise { this.options = options; if (this.server) { throw new Error('Server is already running'); } // Handle abort signal if (options.signal?.aborted) { throw new Error('Operation aborted'); } const abortHandler = () => { this.stop().catch(() => { // Ignore errors during cleanup }); }; if (options.signal) { options.signal.addEventListener('abort', abortHandler); } try { this.server = Bun.serve({ port: options.port, hostname: options.hostname || 'localhost', fetch: request => this.handleRequest(request), error: error => { return new Response(`Server error: ${error.message}`, { status: 500, }); }, }); // eslint-disable-next-line no-console console.log( `OAuth callback server running on http://${options.hostname || 'localhost'}:${options.port}` ); } catch (error) { if (options.signal) { options.signal.removeEventListener('abort', abortHandler); } throw error; } } /** * Handle incoming HTTP requests */ private handleRequest(request: Request): Response { this.options?.onRequest?.(request); const url = new URL(request.url); const listener = this.callbackListeners.get(url.pathname); if (!listener) { return new Response('Not Found', { status: 404 }); } // Extract OAuth callback parameters from URL const params: CallbackResult = {}; for (const [key, value] of url.searchParams.entries()) { params[key] = value; } // Generate appropriate response const hasError = Boolean(params.error); const html = hasError ? renderErrorPage( params.error, params.error_description, params.error_uri, this.options?.errorHtml ) : renderSuccessPage(this.options?.successHtml); // Resolve the waiting promise with params (including error params) // The caller can check for params.error to determine if it was an OAuth error listener.resolve(params); // Return HTML response with appropriate status code return new Response(html, { status: hasError ? 400 : 200, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache', Expires: '0', }, }); } /** * Wait for OAuth callback on the specified path */ async waitForCallback( path: string, timeout: number ): Promise { if (!this.server) { throw new Error('Server is not running'); } try { return await Promise.race([ // Promise resolved by handleRequest method new Promise((resolve, reject) => { this.callbackListeners.set(path, { resolve, reject }); }), // Timeout promise new Promise((_, reject) => { setTimeout(() => { reject( new Error( `OAuth callback timeout after ${timeout}ms waiting for ${path}` ) ); }, timeout); }), ]); } finally { // Always clean up the listener this.callbackListeners.delete(path); } } /** * Stop the server and clean up resources */ async stop(): Promise { if (this.server) { // Reject any pending promises const error = new Error('Server stopped'); for (const listener of this.callbackListeners.values()) { // Reject in a try-catch to prevent unhandled promise rejections try { listener.reject(error); } catch { // Ignore - the caller may have already handled or abandoned this promise } } this.callbackListeners.clear(); // Stop the server await this.server.stop(); this.server = undefined; } } /** * Check if server is running */ isRunning(): boolean { return Boolean(this.server); } /** * Get server URL if running */ getServerUrl(): string | undefined { if (!this.server || !this.options) { return undefined; } const { hostname = 'localhost', port } = this.options; return `http://${hostname}:${port}`; } } /** * Create and start a temporary OAuth callback server */ export async function createCallbackServer( options: CallbackServerOptions ): Promise { const server = new OAuthCallbackServer(); await server.start(options); return server; } /** * Convenience function to start server, wait for callback, and stop server */ export async function waitForOAuthCallback( path: string, options: CallbackServerOptions & { timeout?: number } ): Promise { const server = await createCallbackServer(options); const timeout = options.timeout ?? 30000; // 30 second default try { return await server.waitForCallback(path, timeout); } finally { await server.stop(); } }