UNPKG

@civic/nexus-bridge

Version:

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

395 lines 17 kB
/** * authProvider.ts * * Implements the OAuth client interface for authentication with Civic services. * Handles PKCE flow and token management through the OAuthClientProvider interface. */ import * as config from './config.js'; import { getTokens, setTokens } from './tokenStore.js'; import { fetchOidcConfig } from './oidc.js'; import { CallbackServer } from './callbackServer.js'; import crypto from 'crypto'; import { logger } from './utils/logger.js'; import { messageFromError } from "./utils.js"; /** * Implementation of OAuthClientProvider for Civic authentication * Handles PKCE flow and token management */ export class CivicAuthProvider { // Store PKCE code verifier during auth flow _codeVerifier = null; // Store OIDC configuration promise _oidcConfigPromise = null; // Flag to prevent concurrent auth flows _authInProgress = false; // Flag to prevent concurrent refresh operations _refreshInProgress = false; // Callbacks when tokens change _tokenChangedCallbacks = []; // Flag to indicate if we are in install mode _isInstallMode = false; constructor() { // Check if we're in install mode this._isInstallMode = process.argv.includes("install"); if (this._isInstallMode) { logger.info("Running in install mode, authentication flows will be disabled"); return; } // Start loading OIDC configuration immediately this._oidcConfigPromise = fetchOidcConfig(); // Generate a PKCE code verifier on startup this._codeVerifier = this.generateCodeVerifier(); // Check for refresh token on startup and refresh if available this.refreshTokensIfAvailable().catch(error => { logger.error("Error during startup token refresh:", error); }); } /** * Check for refresh token on startup and refresh tokens if available */ async refreshTokensIfAvailable() { logger.info("Checking for refresh token on startup"); if (this._isInstallMode) { logger.info("Install mode enabled, skipping token refresh"); return false; } // Skip if NO_LOGIN is true if (config.NO_LOGIN) { logger.info("NO_LOGIN enabled, skipping token refresh"); return false; } // Get stored tokens const tokens = await getTokens(); // Check if we have a refresh token if (!tokens.refresh_token) { logger.info("No refresh token available, skipping refresh"); return false; } // Attempt to refresh tokens logger.info("Refresh token found, attempting to refresh tokens"); return this.refreshTokens(tokens.refresh_token); } /** * Refresh tokens using the refresh_token grant type * @param refreshToken The refresh token to use */ async refreshTokens(refreshToken) { // Prevent concurrent refresh operations if (this._refreshInProgress) { logger.warn("Token refresh already in progress, skipping duplicate request"); return false; } this._refreshInProgress = true; try { logger.info("Starting token refresh"); // Get token endpoint from OpenID configuration logger.info("Waiting for OIDC configuration to load..."); const oidcConfig = await this._oidcConfigPromise; if (!oidcConfig) { logger.error("OIDC configuration not loaded, skipping token refresh"); return false; } const tokenUrl = new URL(oidcConfig.token_endpoint); logger.info(`Refreshing tokens at: ${tokenUrl.toString()}`); // Create token refresh parameters const tokenParams = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: config.CLIENT_ID }); // Log params for debugging (excluding sensitive values) logger.debug('Token refresh parameters:', { grant_type: 'refresh_token', client_id: config.CLIENT_ID, refresh_token: '[PRESENT]' }); // Exchange the refresh token for new tokens const tokenResponse = await fetch(tokenUrl.toString(), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: tokenParams }); console.log(`Token response status: ${tokenResponse.status}`); if (tokenResponse.ok) { // Parse the response const tokens = await tokenResponse.json(); logger.info('Successfully refreshed tokens'); // Store the tokens WITHOUT triggering token change event // This prevents race conditions during refresh await this.saveTokens({ id_token: tokens.id_token, access_token: tokens.access_token, refresh_token: tokens.refresh_token, }, false); logger.debug('Refreshed tokens stored successfully'); return true; } else { // Handle token refresh error const errorText = await tokenResponse.text(); logger.error(`Failed to refresh tokens: HTTP ${tokenResponse.status}`); logger.debug(`Error response: ${errorText}`); // If the refresh token is invalid or expired, we should clear it if (tokenResponse.status === 400 || tokenResponse.status === 401) { logger.warn("Invalid or expired refresh token, clearing stored refresh token"); const currentTokens = await getTokens(); await setTokens({ ...currentTokens, refresh_token: undefined }); } return false; } } catch (error) { logger.error('Error during token refresh:', error); return false; } finally { this._refreshInProgress = false; } } /** * Generate a random PKCE code verifier * This is a cryptographically random string used for PKCE flow */ generateCodeVerifier() { // Generate a random string of 48-128 chars as per RFC7636 return crypto.randomBytes(64).toString('base64url'); } /** * Generate a code challenge from the code verifier * This is the SHA256 hash of the verifier, encoded as base64url */ generateCodeChallenge(codeVerifier) { const hash = crypto.createHash('sha256').update(codeVerifier).digest(); return Buffer.from(hash).toString('base64url'); } // Register a callback to be invoked when tokens change onTokensChanged(callback) { this._tokenChangedCallbacks.push(callback); } /** * The URL to redirect to after authorization * This is where the browser will be redirected after the user authenticates */ get redirectUrl() { // This will be dynamically set by CallbackServer return ''; } /** * Metadata about this OAuth client * Used during dynamic registration if supported */ get clientMetadata() { return { client_name: config.REMOTE_CLIENT_INFO.name, redirect_uris: [`http://localhost:${config.CALLBACK_PORT}/callback`] }; } /** * Return static client information * Civic auth service uses pre-registered clients */ clientInformation() { logger.debug(`Providing client information with ID: ${config.CLIENT_ID}`); return { client_id: config.CLIENT_ID, client_name: config.REMOTE_CLIENT_INFO.name }; } /** * Load any existing OAuth tokens from storage * Used by SSEClientTransport to include tokens in requests * This is called by the SDK when making authenticated requests * * If NO_LOGIN is set to true, we'll use the CLIENT_ID as the bearer token * instead of requiring user authentication */ async tokens() { // If NO_LOGIN is true, return the CLIENT_ID as the bearer token if (config.NO_LOGIN) { logger.info("NO_LOGIN enabled, using CLIENT_ID as bearer token"); return { id_token: config.CLIENT_ID, access_token: config.CLIENT_ID, token_type: 'Bearer' }; } // Normal flow - get all stored tokens const tokens = await getTokens(); if (tokens.id_token) { logger.debug("Providing stored tokens to remote server"); return { ...tokens, // although the access token is here, at present we use the id token as the access token // so that we can correctly define the audience = the civic auth app // TODO we may want to clean this up later access_token: tokens.id_token, // not accessToken token_type: 'Bearer' }; } logger.debug("No stored tokens available"); return undefined; } /** * Store new OAuth tokens after successful authorization * Called by the auth flow when new tokens are received * @param tokens The tokens to store * @param triggerTokenChange Whether to trigger token change callbacks (defaults to false) */ async saveTokens(tokens, triggerTokenChange = false) { logger.info("Storing tokens locally"); await setTokens(tokens); // Only notify listeners if triggerTokenChange is true if (triggerTokenChange) { logger.info(`Notifying ${this._tokenChangedCallbacks.length} listeners that tokens changed`); // Execute all callbacks - they could be sync or async try { await Promise.all(this._tokenChangedCallbacks.map(async (callback) => { try { await callback(); } catch (error) { logger.error('Error in token change callback:', error); } })); logger.info("All token change notifications completed"); } catch (error) { logger.error('Error during token change notifications:', error); } } else { logger.info("Token change notifications skipped (triggerTokenChange=false)"); } } // Track the time of the last opened browser window to prevent excessive popups _lastBrowserOpenTime = 0; // Minimum time between browser windows in milliseconds (30 minutes) _minBrowserOpenInterval = 30 * 60 * 1000; /** * Open the browser to begin authorization flow * This method is called when authentication is needed */ async redirectToAuthorization(authorizationUrl) { // Prevent multiple auth flows if (this._authInProgress) { logger.warn("Auth flow already in progress, ignoring new request"); return; } // Check if a browser window was recently opened to prevent multiple windows const now = Date.now(); const timeSinceLastBrowser = now - this._lastBrowserOpenTime; if (timeSinceLastBrowser < this._minBrowserOpenInterval) { logger.warn(`Browser window opened too recently (${timeSinceLastBrowser}ms ago), skipping this auth attempt`); return; } this._authInProgress = true; try { // First try to refresh tokens if we have a refresh token const tokens = await getTokens(); if (tokens.refresh_token) { logger.info("Attempting to refresh tokens before starting browser-based auth flow"); const refreshed = await this.refreshTokens(tokens.refresh_token); if (refreshed) { logger.info("Token refresh successful, skipping browser-based authentication"); return; } logger.info("Token refresh failed, falling back to browser-based authentication"); } else { logger.info("No refresh token available, using browser-based authentication flow"); } // Update last browser open time before opening the browser this._lastBrowserOpenTime = Date.now(); logger.info("Starting browser-based auth flow using CallbackServer"); // Ensure we have a code verifier if (!this._codeVerifier) { this._codeVerifier = this.generateCodeVerifier(); } // Generate the code challenge for PKCE const codeChallenge = this.generateCodeChallenge(this._codeVerifier); // Create a new URL using the correct authorization endpoint from OIDC config const oidcConfig = await this._oidcConfigPromise; if (!oidcConfig) { logger.error("OIDC configuration not loaded, skipping auth flow"); return; } const authUrl = new URL(oidcConfig.authorization_endpoint); // Copy all query parameters from the original URL authorizationUrl.searchParams.forEach((value, key) => { // Skip redirect_uri as it will be set by CallbackServer if (key !== 'redirect_uri') { authUrl.searchParams.set(key, value); } }); // Set PKCE parameters on the auth URL authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', config.CLIENT_ID); // Add required scopes, including offline_access for refresh tokens authUrl.searchParams.set('scope', 'openid email profile offline_access'); // needed by the auth server to enable refresh tokens authUrl.searchParams.set('prompt', 'consent'); // Create a new callback server instance with our code verifier const callbackServer = new CallbackServer(this._codeVerifier); // Start the auth flow logger.debug(`Starting auth flow with URL: ${authUrl.toString()}`); const result = await callbackServer.startAuthFlow(authUrl.toString()); // Handle the result logger.info(`Auth flow completed with success=${result.success}`); if (result.success && (result.id_token || result.access_token)) { if (!result.id_token && !result.access_token) { logger.warn("No tokens received from auth flow"); } else { // Store the tokens WITH triggering token change event // The browser auth is user-initiated, so we need a reconnect logger.info("Triggering token change notification after browser auth"); await this.saveTokens(result, true); } // Generate new code verifier for next auth flow this._codeVerifier = this.generateCodeVerifier(); } else { // Error case - log details logger.error(`Auth flow failed: ${result.error} - ${result.errorDescription}`); } } catch (error) { logger.error("Error in redirectToAuthorization:", messageFromError(error)); } finally { // Always mark auth as no longer in progress this._authInProgress = false; } } /** * Save PKCE code verifier * Called during the authorization flow before redirecting */ saveCodeVerifier(codeVerifier) { logger.debug("Saving PKCE code verifier from SDK"); // We already have our own, but we'll use the one provided by the SDK if it exists this._codeVerifier = codeVerifier; } /** * Return the stored PKCE code verifier * Used during token exchange to prove code ownership */ codeVerifier() { if (!this._codeVerifier) { logger.error("No code verifier available - generating a new one"); this._codeVerifier = this.generateCodeVerifier(); } return this._codeVerifier; } } // Export a singleton instance export const authProvider = new CivicAuthProvider(); //# sourceMappingURL=authProvider.js.map