// src/integrations/paloalto/client.js // // Palo Alto Prisma SD-WAN (formerly CloudGenix) HTTP client. Wraps // axios with dual-mode auth so the same client works against the // unified SASE OAuth 2.0 surface (`api.sase.paloaltonetworks.com`) // and the legacy CloudGenix session-token surface // (`api.cloudgenix.com`) without any caller-visible differences. // // Auth mode selection lives in `PRISMA_AUTH_MODE`: // - `sase` (default, recommended for production) — service-app // OAuth 2.0 client_credentials grant. Bearer token echoed on // every request. Access tokens are short-lived (~15 min); we // refresh 5 min ahead of expiry and forcibly refresh on the // first 401 with a one-shot retry. // - `legacy` — CloudGenix session-token flow. Interactive-user // credential (email + password), captured as `x-auth-token` // for every subsequent call. Kept for admins who only have // legacy creds provisioned; new deployments should prefer // `sase`. // // The single `paloAltoAxios` export is what business modules // import — same pattern as `scAxios` / `merakiAxios`. Mutex around // token refresh (same reason as Webex — a race would double-spend // the refresh and leave one caller with a stale token). // // Only READ operations are used by /voicediag today (POSTs to the // `/monitor/v2.0/api/monitor/*` metric endpoints). No PUT/DELETE // surface is invoked from this client, so a config mishap during // token refresh cannot mutate SD-WAN state. import axios from 'axios'; import { Mutex } from 'async-mutex'; import { logger } from '../../utils/logger.js'; const AUTH_MODE_SASE = 'sase'; const AUTH_MODE_LEGACY = 'legacy'; const DEFAULT_SASE_BASE_URL = 'https://api.sase.paloaltonetworks.com'; const DEFAULT_SASE_AUTH_URL = 'https://auth.apps.paloaltonetworks.com/oauth2/access_token'; const DEFAULT_LEGACY_BASE_URL = 'https://api.cloudgenix.com'; // Refresh window: 5 minutes before actual token expiry. Prisma // SASE tokens are 900s by default; a request landing at 895s could // otherwise trip a 401 that we then re-authenticate through — not // wrong, just wasteful. const REFRESH_MARGIN_MS = 5 * 60 * 1000; const mutex = new Mutex(); let cachedToken = null; let tokenExpiresAt = 0; // SASE unified SD-WAN calls require a one-shot GET /sdwan/v2.1/api/profile // immediately after every new token is acquired — otherwise subsequent // SD-WAN calls come back with 403 (or 503 "target url lookup failed" // depending on which edge processes the miss). See pan.dev "Unified // SASE SD-WAN APIs" page. This flag tracks whether the priming call // has run since the last token acquisition; it's cleared on refresh. let saseSessionPrimed = false; // ────────────────────────────────────────────── // Auth mode + base URL resolution // ────────────────────────────────────────────── function resolveAuthMode() { const raw = String(process.env.PRISMA_AUTH_MODE || AUTH_MODE_SASE).toLowerCase().trim(); if (raw !== AUTH_MODE_SASE && raw !== AUTH_MODE_LEGACY) { throw new Error( `PRISMA_AUTH_MODE must be "${AUTH_MODE_SASE}" or "${AUTH_MODE_LEGACY}" (got "${raw}")`, ); } return raw; } function resolveBaseUrl() { const mode = resolveAuthMode(); if (mode === AUTH_MODE_SASE) { return process.env.PRISMA_SASE_BASE_URL || DEFAULT_SASE_BASE_URL; } return process.env.PRISMA_LEGACY_BASE_URL || DEFAULT_LEGACY_BASE_URL; } // ────────────────────────────────────────────── // Axios instance — baseURL resolved at import time // ────────────────────────────────────────────── export const paloAltoAxios = axios.create({ // baseURL resolved lazily on the first request so tests can set // PRISMA_SASE_BASE_URL / PRISMA_LEGACY_BASE_URL after import. timeout: 20000, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); // Request interceptor: (a) apply resolved baseURL on the fly if the // caller passed a relative URL; (b) inject the correct auth header // for the current auth mode; (c) prime the SASE unified SD-WAN // session on first use of a new token. paloAltoAxios.interceptors.request.use(async (cfg) => { const mode = resolveAuthMode(); if (cfg.url && !/^https?:\/\//i.test(cfg.url)) { cfg.baseURL = resolveBaseUrl(); } const token = await getPrismaToken(); if (mode === AUTH_MODE_SASE) { if (!cfg.headers.Authorization) { cfg.headers.Authorization = `Bearer ${token}`; } // Prime the unified SD-WAN session — but skip when the current // request IS the profile call itself (avoids infinite recursion) // or when it's the auth POST (which uses raw axios, not this // instance, but belt-and-braces). if (!saseSessionPrimed && !cfg._skipSasePrime && !/\/sdwan\/v2\.1\/api\/profile$/.test(cfg.url || '')) { try { await primeSaseSession(token); } catch (err) { // Priming failed — log and continue. Downstream call will // 403 loudly which is a better signal than swallowing here. logger('paloalto:client', `SASE session prime failed: ${err.message}`, 'warn'); } } } else { if (!cfg.headers['x-auth-token']) { cfg.headers['x-auth-token'] = token; } } logger('paloalto:request', `${cfg.method?.toUpperCase()} ${cfg.baseURL || ''}${cfg.url}`, 'debug'); return cfg; }); // Maximum attempts on a 429 (rate limit). One retry with backoff has // been empirically enough in the observed Prisma tenant — the second // attempt lands after the burst window resets. Bumping past 2 would // stack requests behind an already-throttled endpoint and make things // worse. `Retry-After` (seconds) is honored when present; otherwise // exponential backoff kicks in. const MAX_429_RETRIES = 2; const BASE_429_BACKOFF_MS = 1500; paloAltoAxios.interceptors.response.use( (response) => response, async (error) => { const status = error.response?.status; if (status === 401 && !error.config?._retry) { logger('paloalto:client', '401 → forcing token refresh + one-shot retry', 'warn'); try { await getPrismaToken(true); } catch (refreshErr) { logger('paloalto:client', `Token refresh failed on 401: ${refreshErr.message}`, 'error'); return Promise.reject(error); } const originalRequest = error.config; originalRequest._retry = true; const mode = resolveAuthMode(); if (mode === AUTH_MODE_SASE) { originalRequest.headers.Authorization = `Bearer ${cachedToken}`; } else { originalRequest.headers['x-auth-token'] = cachedToken; } return paloAltoAxios(originalRequest); } // 429 — Prisma throttles bursts of parallel metric calls. Honor // the `Retry-After` header when present; otherwise exponential // backoff (1.5s / 3s / 6s). Only retry idempotent verbs (GET, // POST-with-body-that's-a-query — Prisma metric endpoints are // read-only despite being POSTs). Capped at MAX_429_RETRIES. if (status === 429) { const cfg = error.config || {}; cfg._429retries = (cfg._429retries || 0) + 1; if (cfg._429retries <= MAX_429_RETRIES) { const retryAfterHdr = error.response?.headers?.['retry-after']; const retryAfterMs = retryAfterHdr ? Math.max(0, Number(retryAfterHdr) * 1000) || null : null; const waitMs = retryAfterMs != null ? retryAfterMs : BASE_429_BACKOFF_MS * Math.pow(2, cfg._429retries - 1); logger( 'paloalto:client', `429 on ${cfg.method?.toUpperCase() || 'REQ'} ${cfg.url || ''} — retry ${cfg._429retries}/${MAX_429_RETRIES} in ${waitMs}ms` + (retryAfterMs != null ? ' (Retry-After honored)' : ''), 'warn', ); await new Promise((resolve) => setTimeout(resolve, waitMs)); return paloAltoAxios(cfg); } logger( 'paloalto:client', `429 on ${cfg.method?.toUpperCase() || 'REQ'} ${cfg.url || ''} — retries exhausted (${MAX_429_RETRIES})`, 'warn', ); } const body = error.response?.data; const msg = status ? `${status} — ${typeof body === 'string' ? body : JSON.stringify(body)}` : error.message; logger('paloalto:error', msg, status === 429 ? 'warn' : 'error'); return Promise.reject(error); }, ); // ────────────────────────────────────────────── // Token acquisition (dispatches on auth mode) // ────────────────────────────────────────────── /** * Return a valid Prisma auth token, refreshing if needed. Concurrent * callers coalesce onto a single refresh through the shared mutex. * Any refresh clears `saseSessionPrimed` so the interceptor knows to * re-run the mandatory GET /sdwan/v2.1/api/profile priming call on * the next SD-WAN request. */ export async function getPrismaToken(forceRefresh = false) { const release = await mutex.acquire(); try { const now = Date.now(); if (!forceRefresh && cachedToken && now < tokenExpiresAt) { return cachedToken; } const mode = resolveAuthMode(); if (mode === AUTH_MODE_SASE) { await refreshSaseToken(); } else { await refreshLegacyToken(); } saseSessionPrimed = false; return cachedToken; } finally { release(); } } /** * Mandatory session-init for the SASE unified SD-WAN surface. Per * pan.dev's "Unified SASE SD-WAN APIs" page, this exact GET call * MUST run immediately after obtaining a new access token — without * it, every subsequent SD-WAN call is rejected (usually 403, but * SASE Edge can also respond with 503 "target url lookup failed * for proxy: null in environment prod-global" if the priming state * is missing at the routing layer). * * We bypass the axios instance for this call (raw axios) to avoid * the request interceptor's own priming check firing recursively. * `_skipSasePrime` also protects the instance path if a caller ever * hits the profile URL directly. */ async function primeSaseSession(token) { const baseUrl = process.env.PRISMA_SASE_BASE_URL || DEFAULT_SASE_BASE_URL; logger('paloalto:client', 'Priming SASE SD-WAN session (GET /sdwan/v2.1/api/profile)', 'debug'); await axios.get(`${baseUrl}/sdwan/v2.1/api/profile`, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, timeout: 10000, }); saseSessionPrimed = true; logger('paloalto:client', 'SASE SD-WAN session primed'); } async function refreshSaseToken() { const clientId = process.env.PRISMA_CLIENT_ID; const clientSecret = process.env.PRISMA_CLIENT_SECRET; const tsgId = process.env.PRISMA_TSG_ID; const authUrl = process.env.PRISMA_AUTH_URL || DEFAULT_SASE_AUTH_URL; if (!clientId || !clientSecret || !tsgId) { throw new Error( 'PRISMA_AUTH_MODE=sase requires PRISMA_CLIENT_ID, PRISMA_CLIENT_SECRET, and PRISMA_TSG_ID.', ); } logger('paloalto:client', 'Fetching new SASE OAuth token', 'debug'); const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); const body = new URLSearchParams({ grant_type: 'client_credentials', // SASE requires the TSG (Tenant Service Group) scope so the // token is bound to the right tenant. scope: `tsg_id:${tsgId}`, }).toString(); const res = await axios.post(authUrl, body, { headers: { 'Authorization': `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded', }, timeout: 10000, }); const { access_token, expires_in } = res.data || {}; if (!access_token) { throw new Error(`SASE token response missing access_token: ${JSON.stringify(res.data)}`); } cachedToken = access_token; tokenExpiresAt = Date.now() + ((Number(expires_in) || 900) * 1000) - REFRESH_MARGIN_MS; logger( 'paloalto:client', `SASE token acquired (expires in ~${Math.round((Number(expires_in) || 900) / 60)} min)`, ); } async function refreshLegacyToken() { const email = process.env.PRISMA_EMAIL; const password = process.env.PRISMA_PASSWORD; const baseUrl = process.env.PRISMA_LEGACY_BASE_URL || DEFAULT_LEGACY_BASE_URL; if (!email || !password) { throw new Error('PRISMA_AUTH_MODE=legacy requires PRISMA_EMAIL and PRISMA_PASSWORD.'); } logger('paloalto:client', 'Fetching new legacy CloudGenix session token', 'debug'); const res = await axios.post( `${baseUrl}/v2.0/api/login`, { email, password }, { headers: { 'Content-Type': 'application/json' }, timeout: 10000, }, ); const token = res.data?.x_auth_token || res.data?.token || res.headers?.['x-auth-token']; if (!token) { throw new Error(`Legacy login response missing token: ${JSON.stringify(res.data)}`); } cachedToken = token; // Legacy tokens don't declare expiry in the response body; treat // as 8h with the same refresh margin. If a mid-day 401 happens // the response interceptor still catches it and forces refresh. tokenExpiresAt = Date.now() + (8 * 60 * 60 * 1000) - REFRESH_MARGIN_MS; logger('paloalto:client', 'Legacy session token acquired (~8h validity)'); } /** * Test-only helper — clears the in-memory token cache so a fresh * auth attempt happens on the next request. Not for production use * (the mutex-guarded refresh path already handles all real cases). */ export function _resetPrismaAuthCache() { cachedToken = null; tokenExpiresAt = 0; saseSessionPrimed = false; } export default paloAltoAxios;