@civic/nexus-bridge
Version:
Stdio <-> HTTP/SSE MCP bridge with Civic auth handling
158 lines • 7.27 kB
JavaScript
/**
* 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