UNPKG

@civic/nexus-bridge

Version:

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

158 lines 7.27 kB
/** * serviceAuthorization.ts * * Handles service authorization flows and job continuation * as described in section 4.5 of the Civic Nexus Bridge spec. */ import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import open from 'open'; import * as config from './config.js'; import { authProvider } from './authProvider.js'; import { logger } from './utils/logger.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 }; }; /** * Creates a request to continue a job after authorization * @param jobId The job ID to continue * @returns A new tools/call request to continue the job */ const createContinueJobRequest = (jobId) => ({ jsonrpc: "2.0", id: 0, // This will be overridden by the request system method: "tools/call", params: { name: "continue_job", arguments: { jobId: jobId } } }); /** * Class to handle the service authorization process * Instantiated per request flow */ export class ServiceAuthorizationHandler { remoteClient; 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 */ constructor(remoteClient) { this.remoteClient = remoteClient; } /** * Main method to handle service authorization if needed * @param response The MCPResponse to check and potentially handle * @returns The original response if no auth needed, or the final response after auth */ async handleServiceAuthorization(response) { // Check if this response requires auth const authInfo = detectServiceAuthorizationFlow(response); // If no auth required, just return the original response if (!authInfo) return response; 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 ID token to the auth URL if available (now with encryption) if (!config.NO_LOGIN) { const tokens = await authProvider.tokens(); if (tokens && tokens.id_token) { try { // Encrypt the ID token before adding it to the URL const encryptedToken = await encryptToken(tokens.id_token); logger.info(`[ServiceAuthHandler] Encrypted ID token for secure transmission`); // Add encrypted ID token to the auth URL fullAuthUrl.searchParams.set('civic_id_token', encryptedToken); logger.info(`[ServiceAuthHandler] Added encrypted ID token to auth URL for automatic sign-in`); } catch (error) { logger.error(`[ServiceAuthHandler] Error encrypting ID 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 request = createContinueJobRequest(jobId); const result = await this.remoteClient.request({ method: request.method, params: request.params }, CallToolResultSchema); 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=serviceAuthorization.js.map