Skip to main content
Glama
tokenManager.ts26.3 kB
import { OAuth2Client, Credentials } from 'google-auth-library'; import fs from 'fs/promises'; import { getSecureTokenPath, getAccountMode, getLegacyTokenPath } from './utils.js'; import { GaxiosError } from 'gaxios'; import { mkdir } from 'fs/promises'; import { dirname } from 'path'; // Cached calendar info interface CachedCalendar { id: string; summary: string; summaryOverride?: string; accessRole: string; primary: boolean; backgroundColor?: string; } // Extended credentials with cached email and calendars interface CachedCredentials extends Credentials { cached_email?: string; cached_calendars?: CachedCalendar[]; calendars_cached_at?: number; } // Interface for multi-account token storage // Now supports arbitrary account IDs interface MultiAccountTokens { [accountId: string]: CachedCredentials; } export class TokenManager { private oauth2Client: OAuth2Client; private tokenPath: string; private accountMode: string; private accounts: Map<string, OAuth2Client> = new Map(); private credentials: { clientId: string; clientSecret: string; redirectUri: string; }; private writeQueue: Promise<void> = Promise.resolve(); constructor(oauth2Client: OAuth2Client) { this.oauth2Client = oauth2Client; this.tokenPath = getSecureTokenPath(); this.accountMode = getAccountMode(); // Store credentials to avoid accessing private properties later this.credentials = { clientId: (oauth2Client as any)._clientId, clientSecret: (oauth2Client as any)._clientSecret, redirectUri: (oauth2Client as any)._redirectUri }; this.setupTokenRefresh(); } // Method to expose the token path public getTokenPath(): string { return this.tokenPath; } // Method to get current account mode public getAccountMode(): string { return this.accountMode; } // Method to switch account mode (supports arbitrary account IDs) public setAccountMode(mode: string): void { this.accountMode = mode; } private async ensureTokenDirectoryExists(): Promise<void> { try { await mkdir(dirname(this.tokenPath), { recursive: true }); } catch (error) { process.stderr.write(`Failed to create token directory: ${error}\n`); } } private async loadMultiAccountTokens(): Promise<MultiAccountTokens> { try { const fileContent = await fs.readFile(this.tokenPath, "utf-8"); const parsed = JSON.parse(fileContent); // Check if this is the old single-account format if (parsed.access_token || parsed.refresh_token) { // Convert old format to new multi-account format const multiAccountTokens: MultiAccountTokens = { normal: parsed }; await this.saveMultiAccountTokens(multiAccountTokens); return multiAccountTokens; } // Already in multi-account format return parsed as MultiAccountTokens; } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { // File doesn't exist, return empty structure return {}; } throw error; } } /** * Raw token file read without migration logic. * Used for atomic read-modify-write operations where we need to re-read current state. */ private async loadMultiAccountTokensRaw(): Promise<MultiAccountTokens> { try { const fileContent = await fs.readFile(this.tokenPath, "utf-8"); return JSON.parse(fileContent) as MultiAccountTokens; } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return {}; } throw error; } } private async saveMultiAccountTokens(multiAccountTokens: MultiAccountTokens): Promise<void> { return this.enqueueTokenWrite(async () => { await this.ensureTokenDirectoryExists(); await fs.writeFile(this.tokenPath, JSON.stringify(multiAccountTokens, null, 2), { mode: 0o600, }); }); } private enqueueTokenWrite(operation: () => Promise<void>): Promise<void> { const pendingWrite = this.writeQueue .catch(() => undefined) .then(operation); this.writeQueue = pendingWrite .catch(error => { process.stderr.write(`Error writing token file: ${error instanceof Error ? error.message : error}\n`); throw error; }) .catch(() => undefined); return pendingWrite; } private setupTokenRefresh(): void { this.setupTokenRefreshForAccount(this.oauth2Client, this.accountMode); } /** * Set up token refresh handler for a specific account * Uses enqueueTokenWrite to prevent race conditions when multiple accounts refresh simultaneously */ private setupTokenRefreshForAccount(client: OAuth2Client, accountId: string): void { client.on("tokens", async (newTokens) => { try { // Wrap entire read-modify-write in the queue to prevent race conditions await this.enqueueTokenWrite(async () => { const multiAccountTokens = await this.loadMultiAccountTokens(); const currentTokens = multiAccountTokens[accountId] || {}; const updatedTokens = { ...currentTokens, ...newTokens, refresh_token: newTokens.refresh_token || currentTokens.refresh_token, }; multiAccountTokens[accountId] = updatedTokens; await this.ensureTokenDirectoryExists(); await fs.writeFile(this.tokenPath, JSON.stringify(multiAccountTokens, null, 2), { mode: 0o600, }); }); if (process.env.NODE_ENV !== 'test') { process.stderr.write(`Tokens updated and saved for ${accountId} account\n`); } } catch (error: unknown) { process.stderr.write("Error saving updated tokens: "); if (error instanceof Error) { process.stderr.write(error.message); } else if (typeof error === 'string') { process.stderr.write(error); } process.stderr.write("\n"); } }); } private async migrateLegacyTokens(): Promise<boolean> { const legacyPath = getLegacyTokenPath(); try { // Check if legacy tokens exist if (!(await fs.access(legacyPath).then(() => true).catch(() => false))) { return false; // No legacy tokens to migrate } // Read legacy tokens const legacyTokens = JSON.parse(await fs.readFile(legacyPath, "utf-8")); if (!legacyTokens || typeof legacyTokens !== "object") { process.stderr.write("Invalid legacy token format, skipping migration\n"); return false; } // Ensure new token directory exists await this.ensureTokenDirectoryExists(); // Copy to new location await fs.writeFile(this.tokenPath, JSON.stringify(legacyTokens, null, 2), { mode: 0o600, }); process.stderr.write(`Migrated tokens from legacy location: ${legacyPath} to: ${this.tokenPath}\n`); // Optionally remove legacy file after successful migration try { await fs.unlink(legacyPath); process.stderr.write("Removed legacy token file\n"); } catch (unlinkErr) { process.stderr.write(`Warning: Could not remove legacy token file: ${unlinkErr}\n`); } return true; } catch (error) { process.stderr.write(`Error migrating legacy tokens: ${error}\n`); return false; } } async loadSavedTokens(): Promise<boolean> { try { await this.ensureTokenDirectoryExists(); // Check if current token file exists const tokenExists = await fs.access(this.tokenPath).then(() => true).catch(() => false); // If no current tokens, try to migrate from legacy location if (!tokenExists) { const migrated = await this.migrateLegacyTokens(); if (!migrated) { process.stderr.write(`No token file found at: ${this.tokenPath}\n`); return false; } } const multiAccountTokens = await this.loadMultiAccountTokens(); const tokens = multiAccountTokens[this.accountMode]; if (!tokens || typeof tokens !== "object") { process.stderr.write(`No tokens found for ${this.accountMode} account in file: ${this.tokenPath}\n`); return false; } this.oauth2Client.setCredentials(tokens); process.stderr.write(`Loaded tokens for ${this.accountMode} account\n`); return true; } catch (error: unknown) { process.stderr.write(`Error loading tokens for ${this.accountMode} account: `); if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') { try { await fs.unlink(this.tokenPath); process.stderr.write("Removed potentially corrupted token file\n"); } catch (unlinkErr) { /* ignore */ } } return false; } } async refreshTokensIfNeeded(): Promise<boolean> { const expiryDate = this.oauth2Client.credentials.expiry_date; const isExpired = expiryDate ? Date.now() >= expiryDate - 5 * 60 * 1000 // 5 minute buffer : !this.oauth2Client.credentials.access_token; // No token means we need one if (isExpired && this.oauth2Client.credentials.refresh_token) { if (process.env.NODE_ENV !== 'test') { process.stderr.write(`Auth token expired or nearing expiry for ${this.accountMode} account, refreshing...\n`); } try { const response = await this.oauth2Client.refreshAccessToken(); const newTokens = response.credentials; if (!newTokens.access_token) { throw new Error("Received invalid tokens during refresh"); } // The 'tokens' event listener should handle saving this.oauth2Client.setCredentials(newTokens); if (process.env.NODE_ENV !== 'test') { process.stderr.write(`Token refreshed successfully for ${this.accountMode} account\n`); } return true; } catch (refreshError) { if (refreshError instanceof GaxiosError && refreshError.response?.data?.error === 'invalid_grant') { process.stderr.write(`Error refreshing auth token for ${this.accountMode} account: Invalid grant. Token likely expired or revoked. Please re-authenticate.\n`); return false; // Indicate failure due to invalid grant } else { // Handle other refresh errors process.stderr.write(`Error refreshing auth token for ${this.accountMode} account: `); if (refreshError instanceof Error) { process.stderr.write(refreshError.message); } else if (typeof refreshError === 'string') { process.stderr.write(refreshError); } process.stderr.write("\n"); return false; } } } else if (!this.oauth2Client.credentials.access_token && !this.oauth2Client.credentials.refresh_token) { process.stderr.write(`No access or refresh token available for ${this.accountMode} account. Please re-authenticate.\n`); return false; } else { // Token is valid or no refresh token available return true; } } async validateTokens(accountMode?: string): Promise<boolean> { // For unit tests that don't need real authentication, they should mock at the handler level // Integration tests always need real tokens const modeToValidate = accountMode || this.accountMode; const currentMode = this.accountMode; try { // Temporarily switch to the mode we want to validate if different if (modeToValidate !== currentMode) { this.accountMode = modeToValidate; } if (!this.oauth2Client.credentials || !this.oauth2Client.credentials.access_token) { // Try loading first if no credentials set if (!(await this.loadSavedTokens())) { return false; // No saved tokens to load } // Check again after loading if (!this.oauth2Client.credentials || !this.oauth2Client.credentials.access_token) { return false; // Still no token after loading } } const result = await this.refreshTokensIfNeeded(); return result; } finally { // Always restore the original account mode if (modeToValidate !== currentMode) { this.accountMode = currentMode; } } } async saveTokens(tokens: Credentials, email?: string): Promise<void> { try { // Wrap entire read-modify-write in the queue to prevent race conditions await this.enqueueTokenWrite(async () => { const multiAccountTokens = await this.loadMultiAccountTokens(); const cachedTokens: CachedCredentials = { ...tokens }; // Cache the email if provided if (email) { cachedTokens.cached_email = email; } multiAccountTokens[this.accountMode] = cachedTokens; await this.ensureTokenDirectoryExists(); await fs.writeFile(this.tokenPath, JSON.stringify(multiAccountTokens, null, 2), { mode: 0o600, }); }); this.oauth2Client.setCredentials(tokens); process.stderr.write(`Tokens saved successfully for ${this.accountMode} account to: ${this.tokenPath}\n`); } catch (error: unknown) { process.stderr.write(`Error saving tokens for ${this.accountMode} account: ${error}\n`); throw error; } } async clearTokens(): Promise<void> { try { this.oauth2Client.setCredentials({}); // Clear in memory // Wrap entire read-modify-write in the queue to prevent race conditions await this.enqueueTokenWrite(async () => { const multiAccountTokens = await this.loadMultiAccountTokens(); delete multiAccountTokens[this.accountMode]; // If no accounts left, delete the entire file if (Object.keys(multiAccountTokens).length === 0) { await fs.unlink(this.tokenPath); process.stderr.write(`All tokens cleared, file deleted\n`); } else { await this.ensureTokenDirectoryExists(); await fs.writeFile(this.tokenPath, JSON.stringify(multiAccountTokens, null, 2), { mode: 0o600, }); process.stderr.write(`Tokens cleared for ${this.accountMode} account\n`); } }); } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { // File already gone, which is fine process.stderr.write("Token file already deleted\n"); } else { process.stderr.write(`Error clearing tokens for ${this.accountMode} account: ${error}\n`); // Don't re-throw, clearing is best-effort } } } // Method to list available accounts async listAvailableAccounts(): Promise<string[]> { try { const multiAccountTokens = await this.loadMultiAccountTokens(); return Object.keys(multiAccountTokens); } catch (error) { return []; } } /** * Remove a specific account's tokens from storage. * @param accountId - The account ID to remove * @throws Error if account doesn't exist or removal fails */ async removeAccount(accountId: string): Promise<void> { const normalizedId = accountId.toLowerCase(); await this.enqueueTokenWrite(async () => { const multiAccountTokens = await this.loadMultiAccountTokens(); if (!multiAccountTokens[normalizedId]) { throw new Error(`Account "${normalizedId}" not found`); } delete multiAccountTokens[normalizedId]; // If no accounts left, delete the entire file if (Object.keys(multiAccountTokens).length === 0) { await fs.unlink(this.tokenPath); process.stderr.write(`All tokens cleared, file deleted\n`); } else { await this.ensureTokenDirectoryExists(); await fs.writeFile(this.tokenPath, JSON.stringify(multiAccountTokens, null, 2), { mode: 0o600, }); process.stderr.write(`Account "${normalizedId}" removed successfully\n`); } // Remove from in-memory accounts map if present this.accounts.delete(normalizedId); }); } // Method to switch to a different account (supports arbitrary account IDs) async switchAccount(newMode: string): Promise<boolean> { this.accountMode = newMode; return this.loadSavedTokens(); } /** * Load all authenticated accounts from token file * Returns a Map of account ID to OAuth2Client * * Reuses existing OAuth2Client instances to prevent memory leaks * Sets up token refresh handlers for new accounts */ async loadAllAccounts(): Promise<Map<string, OAuth2Client>> { try { const multiAccountTokens = await this.loadMultiAccountTokens(); // Remove accounts that no longer exist in token file for (const accountId of this.accounts.keys()) { if (!multiAccountTokens[accountId]) { const client = this.accounts.get(accountId); if (client) { // Clean up event listeners before removing client.removeAllListeners('tokens'); } this.accounts.delete(accountId); } } // Add or update accounts for (const [accountId, tokens] of Object.entries(multiAccountTokens)) { // Validate account ID try { const { validateAccountId } = await import('./paths.js') as any; validateAccountId(accountId); // Skip invalid token entries if (!tokens || typeof tokens !== 'object' || !tokens.access_token) { continue; } // Check if we already have a client for this account (reuse it to prevent memory leak) let client = this.accounts.get(accountId); if (!client) { // Create a new OAuth2Client for this account using stored credentials client = new OAuth2Client( this.credentials.clientId, this.credentials.clientSecret, this.credentials.redirectUri ); // Set up token refresh handler for this new client this.setupTokenRefreshForAccount(client, accountId); this.accounts.set(accountId, client); } // Update credentials (for both new and existing clients) client.setCredentials(tokens); } catch (error) { // Skip invalid account IDs if (process.env.NODE_ENV !== 'test') { process.stderr.write(`Skipping invalid account "${accountId}": ${error}\n`); } continue; } } return this.accounts; } catch (error: any) { // Check for file not found error (works with both Error objects and plain objects) if (error && error.code === 'ENOENT') { // No token file exists, return empty map return new Map(); } throw error; } } /** * Get OAuth2Client for a specific account * @param accountId The account ID to retrieve * @throws Error if account not found or invalid */ getClient(accountId: string): OAuth2Client { // Validate account ID first const { validateAccountId } = require('./paths.js'); validateAccountId(accountId); const client = this.accounts.get(accountId); if (!client) { throw new Error(`Account "${accountId}" not found. Please authenticate this account first.`); } return client; } /** * List all authenticated accounts with their email addresses, status, and calendars * Uses cached data when available to avoid repeated API calls */ async listAccounts(): Promise<Array<{ id: string; email: string; status: string; calendars: CachedCalendar[]; }>> { try { const multiAccountTokens = await this.loadMultiAccountTokens(); const accountList: Array<{ id: string; email: string; status: string; calendars: CachedCalendar[]; }> = []; let tokensUpdated = false; // Cache TTL: 5 minutes for calendars const CALENDAR_CACHE_TTL = 5 * 60 * 1000; for (const [accountId, tokens] of Object.entries(multiAccountTokens)) { // Skip invalid entries if (!tokens || typeof tokens !== 'object') { continue; } let client: OAuth2Client | null = null; // Create client and refresh if needed if (tokens.access_token || tokens.refresh_token) { try { client = new OAuth2Client( this.credentials.clientId, this.credentials.clientSecret, this.credentials.redirectUri ); client.setCredentials(tokens); // Try to refresh token if access token is expired or missing if (tokens.refresh_token && (!tokens.access_token || (tokens.expiry_date && tokens.expiry_date < Date.now()))) { try { const response = await client.refreshAccessToken(); client.setCredentials(response.credentials); Object.assign(tokens, response.credentials); tokensUpdated = true; } catch { // Refresh failed } } } catch { client = null; } } // Get email address - use cached value if available let email = tokens.cached_email || 'unknown'; if (!tokens.cached_email && client) { try { email = await this.getUserEmail(client); if (email !== 'unknown') { tokens.cached_email = email; tokensUpdated = true; } } catch { // Email retrieval failed } } // Get calendars - use cached if fresh, otherwise fetch let calendars: CachedCalendar[] = tokens.cached_calendars || []; const cacheExpired = !tokens.calendars_cached_at || (Date.now() - tokens.calendars_cached_at) > CALENDAR_CACHE_TTL; if (cacheExpired && client) { try { calendars = await this.fetchCalendarsForClient(client); tokens.cached_calendars = calendars; tokens.calendars_cached_at = Date.now(); tokensUpdated = true; } catch { // Calendar fetch failed, use cached or empty } } // Determine status let status = 'active'; if (!tokens.refresh_token) { if (!tokens.access_token || (tokens.expiry_date && tokens.expiry_date < Date.now())) { status = 'expired'; } } accountList.push({ id: accountId, email, status, calendars }); } // Save updated tokens with cached data using atomic read-modify-write // This prevents race conditions when multiple listAccounts() calls run concurrently if (tokensUpdated) { await this.enqueueTokenWrite(async () => { // Re-read current token state to preserve any concurrent auth changes const latestTokens = await this.loadMultiAccountTokensRaw(); // Merge our cached metadata updates into the latest token state for (const accountId of Object.keys(multiAccountTokens)) { const localUpdates = multiAccountTokens[accountId]; const latestAccount = latestTokens[accountId]; if (latestAccount && localUpdates) { // Only update cached metadata, not auth tokens if (localUpdates.cached_email) { latestAccount.cached_email = localUpdates.cached_email; } if (localUpdates.cached_calendars) { latestAccount.cached_calendars = localUpdates.cached_calendars; latestAccount.calendars_cached_at = localUpdates.calendars_cached_at; } } } await this.ensureTokenDirectoryExists(); await fs.writeFile(this.tokenPath, JSON.stringify(latestTokens, null, 2), { mode: 0o600, }); }); } return accountList; } catch (error) { return []; } } /** * Fetch calendars for a specific OAuth2Client */ private async fetchCalendarsForClient(client: OAuth2Client): Promise<CachedCalendar[]> { const { google } = await import('googleapis'); const calendar = google.calendar({ version: 'v3', auth: client }); const response = await calendar.calendarList.list(); const items = response.data.items || []; const calendars: CachedCalendar[] = items.map(cal => ({ id: cal.id || '', summary: cal.summary || '', summaryOverride: cal.summaryOverride || undefined, accessRole: cal.accessRole || 'reader', primary: cal.primary || false, backgroundColor: cal.backgroundColor || undefined })); // Sort: primary first, then by name calendars.sort((a, b) => { if (a.primary && !b.primary) return -1; if (!a.primary && b.primary) return 1; return (a.summaryOverride || a.summary).localeCompare(b.summaryOverride || b.summary); }); return calendars; } /** * Get user email address from OAuth2Client * First tries getTokenInfo, then falls back to primary calendar ID */ private async getUserEmail(client: OAuth2Client): Promise<string> { try { // Try getTokenInfo first (only works if token has email/openid scope) const tokenInfo = await client.getTokenInfo(client.credentials.access_token || ''); if (tokenInfo.email) { return tokenInfo.email; } } catch { // Token info failed, try calendar fallback } // Fallback: Get primary calendar ID (usually the user's email) try { const { google } = await import('googleapis'); const calendar = google.calendar({ version: 'v3', auth: client }); const response = await calendar.calendars.get({ calendarId: 'primary' }); const primaryId = response.data.id; // Primary calendar ID is typically the user's email if (primaryId && primaryId.includes('@')) { return primaryId; } } catch { // Calendar fallback also failed } return 'unknown'; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nspady/google-calendar-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server