jiraCloud/services/webexOAuth.js
jmcqueen efc64a227c Add Webex inbound OAuth flow and room-to-Jira job request pipeline.
Introduces integration-based webhook registration, message parsing, dry-run
monitoring, JSM ticket creation, and OAuth token refresh for DC Ops spaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 08:27:52 -04:00

206 lines
5.8 KiB
JavaScript

import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';
const TOKEN_URL = 'https://webexapis.com/v1/access_token';
const AUTHORIZE_URL = 'https://webexapis.com/v1/authorize';
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
export function isTokenExpiringSoon(expiresAt, bufferMs = REFRESH_BUFFER_MS) {
if (!expiresAt) {
return true;
}
return Date.parse(expiresAt) <= Date.now() + bufferMs;
}
function defaultTokenData() {
return {
accessToken: null,
refreshToken: null,
expiresAt: null,
refreshExpiresAt: null,
personEmail: null,
webhookIds: {},
};
}
function computeExpiry(expiresInSeconds) {
if (!expiresInSeconds) {
return null;
}
return new Date(Date.now() + Number(expiresInSeconds) * 1000).toISOString();
}
export function createWebexOAuthService(config, log) {
const integration = config.webex.integration;
const tokenFile = integration.tokenFile;
let tokenData = loadTokenData();
function loadTokenData() {
try {
if (!fs.existsSync(tokenFile)) {
return defaultTokenData();
}
const parsed = JSON.parse(fs.readFileSync(tokenFile, 'utf8'));
return { ...defaultTokenData(), ...parsed };
} catch (error) {
log.logError('webexOAuth', `Failed to load token file ${tokenFile}`, error);
return defaultTokenData();
}
}
function saveTokenData() {
const dir = path.dirname(tokenFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tokenFile, JSON.stringify(tokenData, null, 2));
}
function applyTokenResponse(responseData) {
tokenData = {
...tokenData,
accessToken: responseData.access_token,
refreshToken: responseData.refresh_token || tokenData.refreshToken,
expiresAt: computeExpiry(responseData.expires_in),
refreshExpiresAt: computeExpiry(responseData.refresh_token_expires_in) || tokenData.refreshExpiresAt,
};
saveTokenData();
return tokenData;
}
async function postTokenRequest(body) {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body).toString(),
});
const responseText = await response.text();
let data;
try {
data = responseText ? JSON.parse(responseText) : {};
} catch {
throw new Error(`Invalid token response: ${responseText}`);
}
if (!response.ok) {
const error = new Error(data.message || data.error_description || responseText || 'Token request failed');
error.status = response.status;
error.details = data;
throw error;
}
return data;
}
function buildAuthorizeUrl(state) {
const params = new URLSearchParams({
client_id: integration.clientId,
response_type: 'code',
redirect_uri: integration.redirectUri,
scope: integration.scopes,
state,
});
return `${AUTHORIZE_URL}?${params.toString()}`;
}
function createOAuthState() {
return crypto.randomBytes(24).toString('hex');
}
async function exchangeCode(code) {
const data = await postTokenRequest({
grant_type: 'authorization_code',
client_id: integration.clientId,
client_secret: integration.clientSecret,
code,
redirect_uri: integration.redirectUri,
});
applyTokenResponse(data);
log.logger('webexOAuth', 'OAuth tokens stored after authorization');
return tokenData;
}
async function refreshIfNeeded() {
if (!tokenData.refreshToken) {
return false;
}
if (!isTokenExpiringSoon(tokenData.expiresAt)) {
return false;
}
const data = await postTokenRequest({
grant_type: 'refresh_token',
client_id: integration.clientId,
client_secret: integration.clientSecret,
refresh_token: tokenData.refreshToken,
});
applyTokenResponse(data);
log.logger('webexOAuth', 'OAuth access token refreshed');
return true;
}
async function getAccessToken() {
if (!tokenData.accessToken && !tokenData.refreshToken) {
return null;
}
await refreshIfNeeded();
if (!tokenData.accessToken) {
return null;
}
return tokenData.accessToken;
}
function isAuthenticated() {
return Boolean(tokenData.refreshToken || tokenData.accessToken);
}
function getStatus() {
return {
authenticated: isAuthenticated(),
expiresAt: tokenData.expiresAt,
refreshExpiresAt: tokenData.refreshExpiresAt,
personEmail: tokenData.personEmail || null,
webhookIds: { ...tokenData.webhookIds },
tokenFile,
};
}
function setPersonEmail(email) {
tokenData.personEmail = email;
saveTokenData();
}
function setWebhookId(roomId, webhookId) {
tokenData.webhookIds = {
...tokenData.webhookIds,
[roomId]: webhookId,
};
saveTokenData();
}
function getWebhookIds() {
return { ...tokenData.webhookIds };
}
return {
buildAuthorizeUrl,
createOAuthState,
exchangeCode,
refreshIfNeeded,
getAccessToken,
isAuthenticated,
getStatus,
setPersonEmail,
setWebhookId,
getWebhookIds,
loadTokenData,
};
}