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