// src/integrations/webex/WebexServiceAppAuth.js import axios from 'axios'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { Mutex } from 'async-mutex'; import { logger } from '../../utils/logger.js'; // ← Your new custom logger class WebexServiceAppAuth { static #instance = null; constructor({ clientId = process.env.WEBEX_CLIENT_ID, clientSecret = process.env.WEBEX_CLIENT_SECRET, tokensFilePath = process.env.WEBEX_TOKENS_PATH || path.join(process.cwd(), 'tokens', 'webex-service-tokens.json'), } = {}) { // Singleton pattern if (WebexServiceAppAuth.#instance) { return WebexServiceAppAuth.#instance; } // Required validation if (!clientId) { throw new Error('WEBEX_CLIENT_ID is required (set it in environment variables)'); } if (!clientSecret) { throw new Error('WEBEX_CLIENT_SECRET is required (set it in environment variables)'); } this.clientId = clientId; this.clientSecret = clientSecret; // Always resolve to an absolute path (based on cwd at startup). // This makes error logs and fs operations unambiguous whether running locally or in Docker. // .env should prefer a *relative* path like ./config/webex-service-tokens.json // so the same .env works both on host (cwd=project root) and inside container (cwd=/app + volume mount). this.tokensFilePath = path.resolve(tokensFilePath); // Token state this.accessToken = null; this.refreshToken = null; this.expiresAt = 0; // Unix timestamp in ms // Serializes loadTokens() + refresh() so concurrent callers (cron, webhook, // HTTP request, framework event firing at the same time) don't issue // parallel refresh requests. Cisco rotates the refresh_token on every use, // so a race here would invalidate one of the in-flight refreshes and we'd // lose our credentials until manual re-bootstrap. this._authMutex = new Mutex(); WebexServiceAppAuth.#instance = this; logger('webex:auth', 'WebexServiceAppAuth initialized with environment variables'); } /** * Load persisted tokens from file */ async loadTokens() { try { const data = await fs.readFile(this.tokensFilePath, 'utf8'); const tokens = JSON.parse(data); this.accessToken = tokens.accessToken; this.refreshToken = tokens.refreshToken; this.expiresAt = tokens.expiresAt || 0; logger('webex:auth', 'Webex tokens successfully loaded from file'); } catch (err) { if (err.code === 'ENOENT') { logger('webex:auth', `No tokens file found at ${this.tokensFilePath}`, 'warn'); logger('webex:auth', 'You need to bootstrap initial tokens once (see documentation)', 'warn'); } else { logger('webex:auth', `Failed to load tokens file: ${err.message}`, 'error'); } throw err; } } /** * Save current token state to file */ async saveTokens() { const payload = { accessToken: this.accessToken, refreshToken: this.refreshToken, expiresAt: this.expiresAt, updatedAt: new Date().toISOString(), }; try { await fs.mkdir(path.dirname(this.tokensFilePath), { recursive: true }); await fs.writeFile(this.tokensFilePath, JSON.stringify(payload, null, 2), 'utf8'); logger('webex:auth', `Webex tokens saved to ${this.tokensFilePath}`); } catch (err) { logger('webex:auth', `Failed to save tokens: ${err.message}`, 'error'); throw err; } } /** * Refresh access token using the current refresh token */ async refresh() { if (!this.refreshToken) { throw new Error( 'No refresh token available. ' + 'Bootstrap initial access_token + refresh_token first ' + '(via Developer Portal or Applications Token API).' ); } try { const params = new URLSearchParams({ grant_type: 'refresh_token', client_id: this.clientId, client_secret: this.clientSecret, refresh_token: this.refreshToken, }); const response = await axios.post('https://webexapis.com/v1/access_token', params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 10000, }); const data = response.data; this.accessToken = data.access_token; this.refreshToken = data.refresh_token; // Cisco rotates refresh tokens this.expiresAt = Date.now() + (data.expires_in * 1000) - (5 * 60 * 1000); // 5 min safety buffer await this.saveTokens(); logger('webex:auth', `Tokens refreshed successfully — new access token expires in ${data.expires_in} seconds`); return this.accessToken; } catch (err) { const errorDetail = err.response?.data || err.message; logger('webex:auth', `Token refresh failed: ${errorDetail}`, 'error'); if (err.response?.status === 400 || err.response?.status === 401) { throw new Error( 'Refresh token may be invalid or revoked. ' + 'You will need to bootstrap new tokens manually.' ); } throw err; } } /** * Get a currently valid access token. * Will refresh automatically if expired or near expiry. * * The whole "check + refresh" path runs inside a mutex so concurrent callers * never trigger overlapping token rotations. */ async getAccessToken() { // Fast path: already-valid in-memory token, no lock needed. if (this.accessToken && Date.now() < this.expiresAt) { return this.accessToken; } return this._authMutex.runExclusive(async () => { // Re-check inside the critical section — another caller may have // already loaded/refreshed while we were waiting for the lock. if (this.accessToken && Date.now() < this.expiresAt) { return this.accessToken; } // Lazy-load on first use if (!this.accessToken && !this.refreshToken) { try { await this.loadTokens(); } catch (err) { throw new Error('Tokens not loaded and no file present – bootstrap required'); } } if (this.accessToken && Date.now() < this.expiresAt) { return this.accessToken; } logger('webex:auth', 'Access token expired or missing → refreshing...'); return this.refresh(); }); } /** * Force a refresh (useful for testing or recovery). * Also serialized through the auth mutex so it can't race a normal getAccessToken(). */ async forceRefresh() { logger('webex:auth', 'Forcing token refresh...', 'warn'); return this._authMutex.runExclusive(() => this.refresh()); } /** * Clear all token state (for logout/testing) */ clearTokens() { this.accessToken = null; this.refreshToken = null; this.expiresAt = 0; logger('webex:auth', 'Webex token state cleared', 'warn'); } } export default WebexServiceAppAuth;