UNPKG

@civic/nexus-bridge

Version:

Stdio <-> HTTP/SSE MCP bridge with Civic auth handling

333 lines 17 kB
/** * Handles the complete OAuth flow including browser opening, callback receiving, and token processing. */ import express from 'express'; import http from 'http'; import open from 'open'; import * as config from './config.js'; import { setTokens } from './tokenStore.js'; import { fetchOidcConfig } from './oidc.js'; import { Logger, LogLevel } from './utils/logger.js'; import { messageFromError } from "./utils.js"; // Create a component-specific logger const logger = new Logger({ name: 'auth-callback', level: process.env.DEBUG === 'true' ? LogLevel.DEBUG : LogLevel.INFO }); /** * A simple, reliable callback server for handling OAuth flows */ export class CallbackServer { server = null; app = express(); callbackPath = '/callback'; codeVerifier; redirectUri = ''; // Promise resolver for the authentication result // Set during startAuthFlow and called when the callback is received _resolveAuthResult = null; /** * Create a new callback server instance * @param codeVerifier PKCE code verifier to use during token exchange */ constructor(codeVerifier) { this.codeVerifier = codeVerifier; // Set up the callback endpoint this.app.get(this.callbackPath, this.handleCallback.bind(this)); } /** * Starts the OAuth flow by starting the server and opening the browser * @returns Promise that resolves with the authentication result */ async startAuthFlow(authUrl) { logger.info("Starting new OAuth flow"); logger.debug("Starting OAuth flow with CallbackServer"); // Start the callback server and get the redirect URI const { port, closeServer } = await this.startServer(); this.redirectUri = `http://localhost:${port}${this.callbackPath}`; // Create a promise that will resolve when the callback is hit const authResultPromise = new Promise((resolve) => { // Store the resolver in our class property for the callback handler to access this._resolveAuthResult = resolve; }); try { // Setup a timeout for the auth flow const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error("Authentication flow timed out")); }, 60000); // 60 seconds timeout }); // Modify the auth URL to use our redirect URI const authUrlObj = new URL(authUrl); authUrlObj.searchParams.set('redirect_uri', this.redirectUri); logger.debug(`Opening browser with auth URL: ${authUrlObj.toString()}`); // Open the browser with the auth URL (default browser) await open(authUrlObj.toString()); logger.info("Browser opened for authentication"); // Wait for either the auth result or timeout return await Promise.race([authResultPromise, timeoutPromise]); } catch (error) { logger.error("Error in start auth flow:", messageFromError(error)); return { success: false, error: "auth_flow_error", errorDescription: error instanceof Error ? error.message : String(error) }; } finally { // Always close the server when done try { await closeServer(); logger.debug("Auth server closed successfully"); } catch (closeError) { logger.error("Error closing auth server:", closeError); } } } /** * Starts the HTTP server on an available port */ async startServer() { return new Promise((resolve, reject) => { // Try to use the default port first const tryPort = config.CALLBACK_PORT; const server = http.createServer(this.app); // Set up error handling server.on('error', (err) => { if (err.code === 'EADDRINUSE') { // If the port is in use, try a random port logger.info(`Port ${tryPort} is in use, trying random port...`); server.listen(0, 'localhost'); } else { logger.error("Error starting callback server:", err); reject(err); } }); // When the server starts listening server.on('listening', () => { const address = server.address(); logger.info(`Auth callback server listening on port ${address.port}`); logger.debug(`Full callback URL: http://localhost:${address.port}${this.callbackPath}`); // Resolver function to close the server const closeServer = async () => { return new Promise((closeResolve) => { server.close((err) => { if (err) { logger.error("Error closing callback server:", err); } closeResolve(); }); }); }; // Store the server and resolve with the port and close function this.server = server; resolve({ port: address.port, closeServer }); }); // Start listening server.listen(tryPort, 'localhost'); }); } /** * Handles the OAuth callback request */ async handleCallback(req, res) { logger.debug(`Auth callback received: ${req.url}`); // Prepare the default result assuming failure const result = { success: false }; // Function to complete the auth flow const completeAuthFlow = (authResult) => { // Send a success response to the browser res.status(200).send(` <html lang="en"> <head> <title>Civic Nexus</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body { font-family: system-ui, -apple-system, sans-serif; background-color: #13151f; color: white; text-align: center; padding: 0; margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; overflow: hidden; } .container { position: relative; z-index: 10; padding: 2rem; } .logo { margin-bottom: 2rem; text-align: center; } .logo svg { width: 180px; height: auto; } h1 { font-size: 1.5rem; font-weight: normal; margin-bottom: 1rem; } p { margin: 0.5rem 0; font-size: 1.2rem; } .dots-bg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle, rgba(128, 90, 213, 0.1) 2px, transparent 2px); background-size: 25px 25px; opacity: 0.5; z-index: 1; } </style> <script>setTimeout(() => window.close(), 5000);</script> </head> <body> <div class="dots-bg"></div> <div class="container"> <div class="logo"> <svg width="246" height="48" viewBox="0 0 246 48" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M76.6941 20.535C76.6941 17.8747 74.5376 15.7183 71.8771 15.7183C69.2168 15.7183 67.0601 17.8747 67.0601 20.535C67.0601 22.3965 68.1178 24.0083 69.6635 24.8102L67.8783 32.5297H75.8759L74.0908 24.8102C75.6364 24.0083 76.6941 22.3965 76.6941 20.535Z" fill="white"/> <path d="M90.7181 30.1948C89.7825 37.4582 83.563 43.0902 76.0486 43.0902H67.7056C59.5472 43.0902 52.9099 36.4532 52.9099 28.2952V19.9528C52.9099 11.7948 59.5472 5.15785 67.7056 5.15785H76.0486C83.563 5.15785 89.7824 10.7899 90.7181 18.0534H95.6608C94.7019 8.07614 86.2734 0.248047 76.0486 0.248047H67.7056C56.8399 0.248047 48 9.08764 48 19.9528V28.2952C48 39.1605 56.8399 48 67.7056 48H76.0486C86.2734 48 94.7019 40.1717 95.6608 30.1948H90.7181Z" fill="white"/> <path d="M137.674 13.792H132.757V36.7303H137.674V13.792Z" fill="white"/> <path d="M152.582 31.3592L146.326 13.792H141.046L149.514 36.7303H155.471L163.939 13.792H158.748L152.582 31.3592Z" fill="white"/> <path d="M172.228 13.792H167.312V36.7303H172.228V13.792Z" fill="white"/> <path d="M192.801 28.791C192.131 31.3034 190.148 32.7958 187.435 32.7958C183.622 32.7958 181.06 29.7858 181.06 25.3058C181.06 20.7725 183.622 17.7267 187.435 17.7267C190.224 17.7267 192.132 19.1478 192.843 21.7314H197.998C196.946 16.5144 193.147 13.5244 187.524 13.5244C184.121 13.5244 181.23 14.644 179.164 16.7622C177.101 18.8769 176.01 21.8312 176.01 25.3058C176.01 28.8171 177.078 31.7675 179.097 33.8382C181.113 35.9054 183.981 36.9981 187.391 36.9981C193.056 36.9981 196.998 33.9331 198 28.7911H192.801V28.791Z" fill="white"/> <path d="M123.55 28.791C122.881 31.3034 120.897 32.7958 118.185 32.7958C114.372 32.7958 111.81 29.7858 111.81 25.3058C111.81 20.7725 114.372 17.7267 118.185 17.7267C120.974 17.7267 122.881 19.1478 123.592 21.7314H128.748C127.695 16.5144 123.897 13.5244 118.274 13.5244C114.871 13.5244 111.98 14.644 109.913 16.7622C107.85 18.8769 106.76 21.8312 106.76 25.3058C106.76 28.8171 107.827 31.7675 109.846 33.8382C111.862 35.9054 114.73 36.9981 118.14 36.9981C123.806 36.9981 127.748 33.9331 128.749 28.7911H123.55V28.791Z" fill="white"/> </svg> </div> <h1>Connected.</h1> <p>You can close this window.</p> </div> </body> </html> `); // After sending the response, resolve the promise with the result // Use setTimeout to ensure the response is sent first setTimeout(() => { if (this._resolveAuthResult) { this._resolveAuthResult(authResult); this._resolveAuthResult = null; } }, 500); }; try { // Check for error parameters if (req.query.error) { result.error = req.query.error; result.errorDescription = req.query.error_description; logger.error(`Auth error: ${result.error} - ${result.errorDescription || 'No description'}`); completeAuthFlow(result); return; } // Check for direct token parameters (implicit flow) if (req.query.id_token || req.query.access_token) { result.id_token = req.query.id_token ?? undefined; result.access_token = req.query.access_token ?? undefined; result.refresh_token = req.query.refresh_token ?? undefined; result.success = true; logger.debug('Received tokens directly in callback'); // Only store the tokens directly here - the auth provider will // handle triggering the token change events when the result is returned await setTokens(result); logger.info('Authentication tokens received and stored successfully'); completeAuthFlow(result); return; } // Check for authorization code (authorization code flow) if (req.query.code) { const code = req.query.code; logger.debug('Received authorization code. Exchanging for tokens...'); try { // Get token endpoint from OpenID configuration const oidcConfig = await fetchOidcConfig(); const tokenUrl = new URL(oidcConfig.token_endpoint); logger.debug(`Exchanging code for tokens at: ${tokenUrl.toString()}`); // Create token exchange parameters const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: config.CLIENT_ID, redirect_uri: this.redirectUri, code_verifier: this.codeVerifier // Critical for PKCE flow }); // Log params for debugging (excluding sensitive values) logger.debug('Token request parameters:', { grant_type: 'authorization_code', client_id: config.CLIENT_ID, redirect_uri: this.redirectUri, code_verifier: '[PRESENT]' }); // Exchange the code for tokens const tokenResponse = await fetch(tokenUrl.toString(), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: tokenParams }); if (tokenResponse.ok) { // Parse the response const tokens = await tokenResponse.json(); logger.info('Successfully exchanged code for tokens'); // Update the result result.id_token = tokens.id_token; result.access_token = tokens.access_token; result.refresh_token = tokens.refresh_token; result.success = true; // Only store the tokens directly here - the auth provider will // handle triggering the token change events when the result is returned await setTokens(tokens); logger.debug('Tokens stored successfully'); } else { // Handle token exchange error const errorText = await tokenResponse.text(); logger.error(`Failed to exchange code for tokens: HTTP ${tokenResponse.status}`); logger.debug(`Error response: ${errorText}`); result.error = 'token_exchange_failed'; result.errorDescription = errorText; } } catch (error) { logger.error('Error during token exchange:', error); result.error = 'token_exchange_error'; result.errorDescription = error instanceof Error ? error.message : String(error); } completeAuthFlow(result); return; } // No code or tokens received logger.warn('No code or tokens received in callback'); result.error = 'missing_parameters'; result.errorDescription = 'No code or tokens received in callback'; completeAuthFlow(result); } catch (error) { // Handle any unexpected errors logger.error('Unexpected error processing callback:', error); result.error = 'callback_processing_error'; result.errorDescription = error instanceof Error ? error.message : String(error); completeAuthFlow(result); } } } //# sourceMappingURL=callbackServer.js.map