/** * Webex Service App OAuth singleton. * * Mirrors the collabFinder reference but ported to CommonJS + this project's * structured logger. Once a tokens file exists at `tokensFilePath`, this * singleton transparently refreshes the access token (Cisco rotates the * refresh token on every refresh, so we always write back the new pair). * * Bootstrap is handled out-of-band by scripts/seedWebexTokens.js — this * singleton refuses to call Webex without a refresh token already on disk. */ const axios = require('axios'); const fs = require('fs').promises; const path = require('path'); const logger = require('../../utils/logger'); const TOKEN_URL = 'https://webexapis.com/v1/access_token'; const SAFETY_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before stated expiry class WebexServiceAppAuth { static #instance = null; /** * Resolve a singleton bound to the given config. Subsequent calls ignore * the args and return the original instance — caller controls the lifetime * by calling resetForTests() between unit tests. */ static getInstance(opts = {}) { if (!WebexServiceAppAuth.#instance) { WebexServiceAppAuth.#instance = new WebexServiceAppAuth(opts); } return WebexServiceAppAuth.#instance; } static resetForTests() { WebexServiceAppAuth.#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'), httpClient = axios, } = {}) { 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; // Resolve to an absolute path so logs and fs ops are unambiguous whether // running on the host or inside Docker. this.tokensFilePath = path.resolve(tokensFilePath); this.http = httpClient; this.accessToken = null; this.refreshToken = null; this.expiresAt = 0; logger.debug('WebexServiceAppAuth initialized', { tokensFilePath: this.tokensFilePath }); } async loadTokens() { try { const raw = await fs.readFile(this.tokensFilePath, 'utf8'); const tokens = JSON.parse(raw); this.accessToken = tokens.accessToken || null; this.refreshToken = tokens.refreshToken || null; this.expiresAt = tokens.expiresAt || 0; logger.info('Webex tokens loaded from file', { tokensFilePath: this.tokensFilePath }); } catch (err) { if (err.code === 'ENOENT') { logger.warn('No Webex tokens file found — run `npm run webex:seed` to bootstrap', { tokensFilePath: this.tokensFilePath, }); } else { logger.error('Failed to load Webex tokens file', { tokensFilePath: this.tokensFilePath, error: err.message, }); } throw err; } } async saveTokens() { const payload = { accessToken: this.accessToken, refreshToken: this.refreshToken, expiresAt: this.expiresAt, updatedAt: new Date().toISOString(), }; await fs.mkdir(path.dirname(this.tokensFilePath), { recursive: true }); await fs.writeFile(this.tokensFilePath, JSON.stringify(payload, null, 2), 'utf8'); logger.debug('Webex tokens saved', { tokensFilePath: this.tokensFilePath }); } /** * Exchange the current refresh token for a fresh pair. Persists the result. * Throws if no refresh token is available. */ async refresh() { if (!this.refreshToken) { throw new Error( 'No refresh token available. ' + 'Bootstrap initial tokens first via `npm run webex:seed`.' ); } const params = new URLSearchParams({ grant_type: 'refresh_token', client_id: this.clientId, client_secret: this.clientSecret, refresh_token: this.refreshToken, }); try { const response = await this.http.post(TOKEN_URL, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 10000, }); const data = response.data || {}; this.accessToken = data.access_token; this.refreshToken = data.refresh_token || this.refreshToken; this.expiresAt = Date.now() + Number(data.expires_in || 0) * 1000 - SAFETY_BUFFER_MS; await this.saveTokens(); logger.info('Webex tokens refreshed', { expiresInSec: data.expires_in }); return this.accessToken; } catch (err) { const status = err.response?.status; const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message; logger.error('Webex token refresh failed', { status, detail }); if (status === 400 || status === 401) { throw new Error( 'Webex refresh token rejected (400/401). ' + 'It may be expired or revoked — re-seed via `npm run webex:seed`.', { cause: err } ); } throw err; } } /** * Return a currently valid access token. Lazy-loads the tokens file on * first use and refreshes automatically when within the safety buffer. */ async getAccessToken() { if (!this.accessToken && !this.refreshToken) { await this.loadTokens(); } if (!this.accessToken || Date.now() >= this.expiresAt) { logger.debug('Webex access token missing or expired — refreshing'); return this.refresh(); } return this.accessToken; } async forceRefresh() { logger.warn('Forcing Webex token refresh'); return this.refresh(); } clearTokens() { this.accessToken = null; this.refreshToken = null; this.expiresAt = 0; } } module.exports = WebexServiceAppAuth;