UNPKG

@civic/hub-bridge

Version:

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

147 lines 6.99 kB
/** * serviceAuthorization.ts * * Handles service authorization flows and job continuation * as described in section 4.5 of the Civic Hub Bridge spec. */ import open from 'open'; import { logger } from "../utils/index.js"; import * as config from "../config/index.js"; import { encryptToken } from "../utils/encryption.js"; const isAuthorizationRequiredResponse = (result) => { return (result.content?.length === 3 && result.content[0].type === 'text' && result.content[1].type === 'resource' && result.content[2].type === 'resource' && result.content[1].resource.name === 'authorization_url' && result.content[2].resource.name === 'continue_job_id'); }; /** * Checks if a tools/call response contains authorization and job continuation info * @param result The response to check * @returns Object with auth URL and job ID, or null if not found */ const detectServiceAuthorizationFlow = (result) => { logger.debug("Checking for service authorization flow in response"); // Ensure we have a valid content array with at least two items if (!isAuthorizationRequiredResponse(result)) return null; logger.info("Service authorization flow detected"); const authUrl = result.content[1].resource.text; const continueJobId = result.content[2].resource.text; logger.debug(`Authorization URL: ${authUrl}`); logger.debug(`Continue job ID: ${continueJobId}`); return { authUrl, continueJobId }; }; /** * Class to handle the service authorization process * Instantiated per request flow */ export class ServiceAuthorizationHandler { remoteClient; authProvider; maxRetries = 10; // max retry time = 10*5s = 50s initialWaitMs = 5000; // + 5 = 55s (total timeout) retryIntervalMs = 5000; /** * Create a new ServiceAuthorizationHandler * @param remoteClient The MCP client to use for communication with the remote server * @param authProvider The auth provider to get tokens from */ constructor(remoteClient, authProvider) { this.remoteClient = remoteClient; this.authProvider = authProvider; } /** * Main method to handle service authorization - assumes auth is already detected * @param response The MCPResponse that requires authorization * @returns The final response after auth flow completion */ async handleServiceAuthorization(response) { // Extract auth info from the response (caller has already verified this is an auth response) const authInfo = detectServiceAuthorizationFlow(response); if (!authInfo) { throw new Error("handleServiceAuthorization called on response that doesn't require auth"); } const { authUrl, continueJobId } = authInfo; logger.info(`[ServiceAuthHandler] Starting authorization flow for job ${continueJobId}`); const fullAuthUrl = new URL(authUrl); // If NO_AUTH_CAPTURE is enabled, skip the authorization flow and just return the original response if (config.NO_AUTH_CAPTURE) { logger.info(`[ServiceAuthHandler] NO_AUTH_CAPTURE is enabled, skipping authorization flow`); logger.info(`[ServiceAuthHandler] Authorization URL that would have been opened: ${authUrl}`); return response; } // Add Civic access token to the auth URL if available (now with encryption) if (!config.NO_LOGIN) { const tokens = await this.authProvider.tokens(); if (tokens && tokens.access_token) { try { // Encrypt the access token before adding it to the URL const encryptedToken = await encryptToken(tokens.access_token); logger.info(`[ServiceAuthHandler] Encrypted access token for secure transmission`); // Add encrypted access token to the auth URL fullAuthUrl.searchParams.set('civic_access_token', encryptedToken); logger.info(`[ServiceAuthHandler] Added encrypted access token to auth URL for automatic sign-in`); } catch (error) { logger.error(`[ServiceAuthHandler] Error encrypting access token:`, error); throw error; } } } // 1. Open the authorization URL in browser logger.info(`[ServiceAuthHandler] Opening authorization URL in browser`); await open(fullAuthUrl.toString()); // 2. Wait for the initial period to allow user to complete auth logger.info(`[ServiceAuthHandler] Waiting ${this.initialWaitMs}ms for user to complete authorization`); await new Promise(resolve => setTimeout(resolve, this.initialWaitMs)); // 3. Poll for completion logger.info(`[ServiceAuthHandler] Beginning to poll for authorization completion`); return await this.pollForCompletion(continueJobId); } /** * Poll the server to check if authorization is complete * @param jobId The job ID to continue * @returns The final MCPResponse from the server */ async pollForCompletion(jobId) { let retryCount = 0; let lastResult = null; while (retryCount < this.maxRetries) { try { logger.info(`[ServiceAuthHandler] Poll attempt ${retryCount + 1}/${this.maxRetries}`); // Call the continue job tool with the job ID const result = await this.remoteClient.callTool({ name: "continue_job", arguments: { jobId: jobId } }); lastResult = result; // Check if the result still requires authorization const authInfo = detectServiceAuthorizationFlow(result); if (!authInfo) { // No authUrl found - authorization is complete logger.info(`[ServiceAuthHandler] Authorization completed successfully after ${retryCount + 1} attempts`); return result; } // Still needs authorization, wait and retry logger.info(`[ServiceAuthHandler] Authorization still in progress, waiting ${this.retryIntervalMs}ms before next attempt`); await new Promise(resolve => setTimeout(resolve, this.retryIntervalMs)); retryCount++; } catch (error) { logger.error(`[ServiceAuthHandler] Error during polling:`, error); throw error; } } // Max retries exceeded if (!lastResult) { throw new Error("No response received after maximum retries"); } logger.warn(`[ServiceAuthHandler] Max retry count (${this.maxRetries}) exceeded, returning last response`); return lastResult; } } //# sourceMappingURL=service-authorization.js.map