UNPKG

@camunda8/sdk

Version:

[![NPM](https://nodei.co/npm/@camunda8/sdk.png)](https://www.npmjs.com/package/@camunda8/sdk)

713 lines 32.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OAuthProvider = void 0; const crypto_1 = require("crypto"); const fs = __importStar(require("fs")); const os = __importStar(require("os")); const path_1 = __importDefault(require("path")); const debug_1 = require("debug"); const got_1 = __importDefault(require("got")); const jwt_decode_1 = require("jwt-decode"); const C8Logger_1 = require("../../c8/lib/C8Logger"); const lib_1 = require("../../lib"); const trace = (0, debug_1.debug)('camunda:oauth'); const homedir = os.homedir(); const BACKOFF_TOKEN_ENDPOINT_FAILURE = 1000; const TOKEN_ENDPOINT_REQUEST_TIMEOUT_MS = 30000; /** * The `OAuthProvider` class is an implementation of the {@link IHeadersProvider} * interface that uses the OAuth 2.0 client credentials grant to authenticate * with the Camunda Platform 8 Identity service. It handles token expiration * and renewal, and caches tokens in memory and on disk. * * It is used by the SDK to authenticate with the Camunda Platform 8. You will * rarely need to use this class directly, as it is used internally by the SDK. * * @example * ```typescript * const authProvider = new OAuthProvider({ * config: { * CAMUNDA_OAUTH_URL: 'https://login.cloud.camunda.io/oauth/token', * ZEEBE_CLIENT_ID: 'your-client-id', * ZEEBE_CLIENT_SECRET: 'your-client-secret', * }, * }) * * const token = await authProvider.getToken('ZEEBE') * ``` */ class OAuthProvider { /** * * @param dir Optional directory to clear the cache from. If not provided, the default cache directory is used. * @description Clears the OAuth token cache directory. This will remove all cached tokens from the specified directory. */ static clearCacheDir(dir) { const cacheDir = dir ?? OAuthProvider.defaultTokenCache; if (fs.existsSync(cacheDir)) { fs.rmSync(cacheDir, { recursive: true, force: true, }); } } constructor(options) { this.tokenCache = {}; this.failed = false; this.failureCount = 0; this.inflightTokenRequests = {}; /** Memoized 401 responses for SaaS cooldown buffering */ this.memoized401 = {}; /** Persistent tarpit flag files for SaaS 401 (keyed by clientId+secret+audience) */ this.tarpit401 = new Set(); this.getCacheKey = (clientId, audience) => `${clientId}-${audience}`; this.getCachedTokenFileName = (clientId, audience) => path_1.default.join(this.cacheDir, `oauth-token-${clientId}-${audience}.json`); const config = lib_1.CamundaEnvironmentConfigurator.mergeConfigWithEnvironment(options?.config ?? {}); this.log = (0, C8Logger_1.getLogger)(config); this.authServerUrl = (0, lib_1.RequireConfiguration)(config.CAMUNDA_OAUTH_URL, 'CAMUNDA_OAUTH_URL'); this.clientId = config.ZEEBE_CLIENT_ID; this.clientSecret = config.ZEEBE_CLIENT_SECRET; this.mTLSPrivateKey = config.CAMUNDA_CUSTOM_PRIVATE_KEY_PATH ? fs.readFileSync(config.CAMUNDA_CUSTOM_PRIVATE_KEY_PATH).toString() : undefined; this.mTLSCertChain = config.CAMUNDA_CUSTOM_CERT_CHAIN_PATH ? fs.readFileSync(config.CAMUNDA_CUSTOM_CERT_CHAIN_PATH).toString() : undefined; this.consoleClientId = config.CAMUNDA_CONSOLE_CLIENT_ID; this.consoleClientSecret = config.CAMUNDA_CONSOLE_CLIENT_SECRET; this.refreshWindow = config.CAMUNDA_OAUTH_TOKEN_REFRESH_THRESHOLD_MS; // https://github.com/camunda/camunda-8-js-sdk/issues/605 // The SaaS token endpoint returns successive 401 responses after a 30s cooldown now. So we turn off token endpoint backoff when // running against SaaS unless the user explicitly turns it on. Otherwise, the token endpoint backoff is enabled by default for Self-Managed // to prevent DDOS of the endpoint by misconfigured workers. this.failOnError = config.CAMUNDA_OAUTH_FAIL_ON_ERROR ?? OAuthProvider.isSaaSUrl(config.CAMUNDA_OAUTH_URL); if (!this.clientId && !this.consoleClientId) { throw new Error(`You need to supply a value for at at least one of ZEEBE_CLIENT_ID or CAMUNDA_CONSOLE_CLIENT_ID`); } if (!this.clientSecret && !this.consoleClientSecret) { throw new Error(`You need to supply a value for at least one of ZEEBE_CLIENT_SECRET or CAMUNDA_CONSOLE_CLIENT_SECRET`); } if (!((!!this.clientId && !!this.clientSecret) || (!!this.consoleClientId && !!this.consoleClientSecret))) { throw new Error('You need to supply both a client ID and a client secret'); } this.rest = (0, lib_1.GetCustomCertificateBuffer)(config).then((certificateAuthority) => got_1.default.extend({ retry: lib_1.GotRetryConfig, timeout: { request: TOKEN_ENDPOINT_REQUEST_TIMEOUT_MS, }, https: { certificateAuthority, }, handlers: [lib_1.beforeCallHook], hooks: { beforeError: [(0, lib_1.gotBeforeErrorHook)(config)], beforeRetry: [lib_1.gotBeforeRetryHook], }, })); this.scope = config.CAMUNDA_TOKEN_SCOPE; this.useFileCache = !config.CAMUNDA_TOKEN_DISK_CACHE_DISABLE; this.cacheDir = config.CAMUNDA_TOKEN_CACHE_DIR ?? OAuthProvider.defaultTokenCache; this.userAgentString = (0, lib_1.createUserAgentString)(config); /** * CAMUNDA_MODELER_OAUTH_AUDIENCE is optional, and only needed if the Modeler is running on Self-Managed * and needs an audience. If it is not set, we will not include an audience in the token request. */ this.audienceMap = { OPERATE: config.CAMUNDA_OPERATE_OAUTH_AUDIENCE, ZEEBE: config.CAMUNDA_ZEEBE_OAUTH_AUDIENCE ?? config.ZEEBE_TOKEN_AUDIENCE, OPTIMIZE: config.CAMUNDA_OPTIMIZE_OAUTH_AUDIENCE, TASKLIST: config.CAMUNDA_TASKLIST_OAUTH_AUDIENCE, CONSOLE: config.CAMUNDA_CONSOLE_OAUTH_AUDIENCE, MODELER: config.CAMUNDA_MODELER_OAUTH_AUDIENCE, }; this.camundaModelerOAuthAudience = config.CAMUNDA_MODELER_OAUTH_AUDIENCE; if (this.useFileCache) { try { if (!fs.existsSync(this.cacheDir)) { // Mode 0o700: only the current user may read, write, or list the // cached token files. See #737. (POSIX-only; Node ignores mode on // Windows, where per-user profile ACLs already restrict access.) fs.mkdirSync(this.cacheDir, { recursive: true, mode: 0o700, }); } else if (process.platform !== 'win32') { // Tighten an existing cache directory that was created by an older // SDK version (or by another process) with a wider mode. Only // strip group/other bits — never widen the user's existing mode. try { const current = fs.statSync(this.cacheDir).mode; if (current & 0o077) { fs.chmodSync(this.cacheDir, current & ~0o077); } } catch (_) { /* best-effort */ } } // Try to write a temporary file to the directory const tempfilename = path_1.default.join(this.cacheDir, `${(0, crypto_1.randomUUID)()}.tmp`); if (fs.existsSync(tempfilename)) { fs.unlinkSync(tempfilename); // Remove the temporary file } fs.writeFileSync(tempfilename, 'test'); fs.unlinkSync(tempfilename); // Remove the temporary file } catch (e) { throw new Error(`FATAL: Cannot write to OAuth cache dir ${this.cacheDir}\n` + 'If you are running on AWS Lambda, set the HOME environment variable of your lambda function to /tmp\n'); } } this.isCamundaSaaS = OAuthProvider.isSaaSUrl(this.authServerUrl); // Load any existing tarpit files (persistent 401 memoization) if (this.useFileCache) { try { const files = fs.readdirSync(this.cacheDir); for (const f of files) { if (f.startsWith('oauth-401-tarpit-')) { this.tarpit401.add(path_1.default.join(this.cacheDir, f)); } } } catch (_) { /* ignore */ } } // Register instance for static clear401Tarpit cleanup OAuthProvider.instances.push(this); } async getHeaders(audienceType) { trace(`Token request for ${audienceType}`); // We use the Console credential set if it we are requesting from // the SaaS OAuth endpoint, and it is a Modeler or Admin Console token. // Otherwise we use the application credential set, unless a Console credential set exists. // See: https://github.com/camunda/camunda-8-js-sdk/issues/60 const requestingFromSaaSConsole = this.isCamundaSaaS && (audienceType === 'CONSOLE' || audienceType === 'MODELER'); const clientIdToUse = requestingFromSaaSConsole ? (0, lib_1.RequireConfiguration)(this.consoleClientId, 'CAMUNDA_CONSOLE_CLIENT_ID') : (0, lib_1.RequireConfiguration)(this.clientId, 'ZEEBE_CLIENT_ID'); const clientSecretToUse = requestingFromSaaSConsole ? (0, lib_1.RequireConfiguration)(this.consoleClientSecret, 'CAMUNDA_CONSOLE_CLIENT_SECRET') : (0, lib_1.RequireConfiguration)(this.clientSecret, 'ZEEBE_CLIENT_SECRET'); const key = this.getCacheKey(clientIdToUse, audienceType); if (this.tokenCache[key]) { const token = this.tokenCache[key]; // check expiry and evict in-memory and file cache if expired if (this.isExpired(token)) { this.evictFromMemoryCache(audienceType, clientIdToUse); trace(`In-memory token ${token.audience} is expired`); } else { trace(`Using in-memory cached token ${token.audience}`); return this.addBearer(this.tokenCache[key].access_token); } } if (this.useFileCache) { const cachedToken = this.retrieveFromFileCache(clientIdToUse, audienceType); if (cachedToken) { // check expiry and evict in-memory and file cache if expired if (this.isExpired(cachedToken)) { this.evictFromFileCache({ audienceType, clientId: clientIdToUse }); trace(`File cached token ${cachedToken.audience} is expired`); } else { trace(`Using file cached token ${cachedToken.audience}`); return this.addBearer(cachedToken.access_token); } } } // Persistent tarpit check (SaaS 401 permanent memoization) const tarpitFile = this.getTarpitFilePath({ clientId: clientIdToUse, clientSecret: clientSecretToUse, audienceType, }); if (this.isCamundaSaaS && this.isTarpitted(tarpitFile)) { throw new Error(`401 Unauthorized (tarpit) for clientId ${clientIdToUse}. Persistent memoization in effect. Clear with OAuthProvider.clear401Tarpit().`); } // Legacy in-memory cooldown memoization (will be deprecated by tarpit behaviour) const credentialKey = this.getCredentialAudienceKey({ clientId: clientIdToUse, audienceType, }); const memo = this.memoized401[credentialKey]; if (memo) { const now = Date.now(); if (now - memo.timestamp < OAuthProvider.SAAS_401_COOLDOWN_MS) { // Within cooldown window: surface cached error without hitting endpoint throw memo.error; } else { // Expired memoization; remove and continue to request delete this.memoized401[credentialKey]; } } if (!this.inflightTokenRequests[credentialKey]) { this.inflightTokenRequests[credentialKey] = new Promise((resolve, reject) => { const failureBackoff = Math.min(BACKOFF_TOKEN_ENDPOINT_FAILURE * this.failureCount, 15000); const delay = this.failOnError ? 0 : this.failed ? failureBackoff : 0; if (this.failed) { this.log.warn(`Backing off token endpoint due to previous failure. Requesting token in ${failureBackoff}ms...`); } setTimeout(() => { this.makeDebouncedTokenRequest({ audienceType, clientIdToUse, clientSecretToUse, }) .then((res) => { // Successful token acquisition clears any memoized 401 for this credential/audience delete this.memoized401[credentialKey]; this.failed = false; this.failureCount = 0; delete this.inflightTokenRequests[credentialKey]; resolve(res); }) .catch((e) => { // Permanent tarpit SaaS 401 responses; create persistent file & suppress backoff if (this.isCamundaSaaS && this.is401Error(e)) { try { this.createTarpitFile({ clientId: clientIdToUse, clientSecret: clientSecretToUse, audienceType, reason: e.message, }); } catch (_) { /* ignore file write errors */ } this.memoized401[credentialKey] = { timestamp: Date.now(), error: e, }; // Suppress token endpoint backoff/failure counters for permanent SaaS 401 responses. // This ensures we do not apply retry delays for credentials that are permanently invalid. this.failed = false; delete this.inflightTokenRequests[credentialKey]; return reject(e); } if (!this.failOnError) { this.failureCount++; this.failed = true; } delete this.inflightTokenRequests[credentialKey]; reject(e); }); }, delay); }); } return this.inflightTokenRequests[credentialKey]; } flushMemoryCache() { this.tokenCache = {}; } flushFileCache() { if (!this.useFileCache) { return; } try { fs.readdirSync(this.cacheDir) .filter((file) => file.startsWith('oauth-token-') && file.endsWith('.json')) .forEach((file) => { const filePath = path_1.default.join(this.cacheDir, file); try { fs.unlinkSync(filePath); } catch (e) { const err = e; if (err.code !== 'ENOENT') { this.log.warn(`Failed to delete token cache file ${filePath}`); this.log.warn(err.message, err); } } }); } catch (e) { const err = e; if (err.code !== 'ENOENT') { this.log.warn(`Failed to list OAuth token cache dir ${this.cacheDir}`); this.log.warn(err.message, err); } } } /** Camunda SaaS needs an audience for a Modeler token request, and Self-Managed does not. */ addAudienceIfNeeded(audienceType) { /** If we are running on Self-Managed (ie: not Camunda SaaS), and no explicit audience was set, * we should not include an audience in the token request. * See: https://github.com/camunda/camunda-8-js-sdk/issues/60 */ if (audienceType === 'MODELER' && !this.isCamundaSaaS && // Self-Managed !this.camundaModelerOAuthAudience // User didn't set an audience ) { return ''; // No audience in token request } if (audienceType === 'MODELER' && this.isCamundaSaaS) { return 'audience=api.cloud.camunda.io&'; } return `audience=${this.getAudience(audienceType)}&`; } makeDebouncedTokenRequest({ audienceType, clientIdToUse, clientSecretToUse, }) { const body = `${this.addAudienceIfNeeded(audienceType)}client_id=${encodeURIComponent(clientIdToUse)}&client_secret=${encodeURIComponent(clientSecretToUse)}&grant_type=client_credentials`; /* Add a scope to the token request, if one is set */ const bodyWithScope = this.scope ? `${body}&scope=${this.scope}` : body; const options = { body: bodyWithScope, headers: { 'content-type': 'application/x-www-form-urlencoded', 'user-agent': this.userAgentString, accept: '*/*', }, https: { key: this.mTLSPrivateKey, cert: this.mTLSCertChain, }, }; trace(`Making token request to the token endpoint: `); trace(` ${this.authServerUrl}`); trace({ ...options, body: this.redactClientSecret(bodyWithScope), }); return this.rest.then((rest) => rest .post(this.authServerUrl, options) .then((res) => { // If status 401 from SaaS, throw to trigger memoization upstream if (this.isCamundaSaaS && res.statusCode === 401) { const err = new Error(`401 Unauthorized requesting token for Client Id ${clientIdToUse}`); throw err; } return JSON.parse(res.body); }) .catch((e) => { e.message = `Error requesting token for Client Id ${clientIdToUse}: ${e.message}`; this.log.error(e); throw e; }) .then((t) => { trace(`Got token for Client Id ${clientIdToUse}: ${JSON.stringify({ ...t, access_token: '[REDACTED]' }, null, 2)}`); const isTokenError = (t) => !!t.error; if (isTokenError(t)) { throw new Error(`Failed to get token: ${t.error} - ${t.error_description}`); } if (t.access_token === undefined) { this.log.error(`Failed to get token: no access_token in response for audience ${audienceType}`); this.log.error(JSON.stringify(t)); throw new Error('Failed to get token: no access_token in response'); } const token = { ...t, audience: audienceType }; if (this.useFileCache) { this.sendToFileCache({ audience: audienceType, token, clientId: clientIdToUse, }); } this.sendToMemoryCache({ audience: audienceType, token, clientId: clientIdToUse, }); return this.addBearer(token.access_token); })); } sendToMemoryCache({ audience, token, clientId, }) { const key = this.getCacheKey(clientId, audience); try { const decoded = (0, jwt_decode_1.jwtDecode)(token.access_token); // Keeping this in the code base to help with debugging // trace(`Caching token in memory: ${JSON.stringify(decoded, null, 2)}`) trace(`Caching token for ${audience} in memory. Expiry: ${decoded.exp}`); this.tokenCache[key] = { ...token, expiry: decoded.exp ?? 0 }; } catch (e) { const err = e; this.log.error(`Failed to cache token in memory for audience ${audience}`); this.log.error(err.message, err); throw e; } } retrieveFromFileCache(clientId, audience) { let token; const tokenFileName = this.getCachedTokenFileName(clientId, audience); const tokenCachedInFile = fs.existsSync(tokenFileName); if (!tokenCachedInFile) { trace(`No file cached token for ${audience} found`); return null; } try { trace(`Reading file cached token for ${audience}`); // If the file was created by an older SDK with a wider mode (e.g. // 0o644), tighten it now. Only strip group/other bits — never widen // the user's existing mode. POSIX-only. See #737. if (process.platform !== 'win32') { try { const current = fs.statSync(tokenFileName).mode; if (current & 0o077) { fs.chmodSync(tokenFileName, current & ~0o077); } } catch (_) { /* best-effort */ } } token = JSON.parse(fs.readFileSync(tokenFileName, 'utf8')); trace(`Retrieved token from file cache`); if (this.isExpired(token)) { trace(`File cached token is expired`); return null; } this.sendToMemoryCache({ audience, token, clientId }); return token; } catch (e) { const err = e; trace(`Failed to read token from file cache for audience ${audience}: ${err.message}`); this.log.warn(`Failed to read token from file cache for audience ${audience}. Ignoring cache entry.`); this.log.warn(err.message, err); return null; } } sendToFileCache({ audience, token, clientId, }) { const file = this.getCachedTokenFileName(clientId, audience); let decoded; try { decoded = (0, jwt_decode_1.jwtDecode)(token.access_token); } catch (e) { const err = e; this.log.warn(`Failed to decode OAuth token before writing to file cache for audience ${audience}`); this.log.warn(err.message, err); return; } fs.writeFile(file, JSON.stringify({ ...token, expiry: decoded.exp ?? 0, }), // Mode 0o600: bearer tokens are confidentiality-class secrets and // must not be readable by other local users. See #737. { mode: 0o600 }, (e) => { if (!e) { trace(`Wrote OAuth token to file ${file}`); // If the file already existed with a wider mode (older SDK or // external process), {mode} on writeFile only affects creation. // Strip group/other bits without widening user bits. POSIX-only. if (process.platform !== 'win32') { try { const current = fs.statSync(file).mode; if (current & 0o077) { fs.chmodSync(file, current & ~0o077); } } catch (_) { /* best-effort */ } } return; } this.log.error(`Error writing OAuth token to file ${file}`); this.log.error(e.message, e); }); } isExpired(token) { const currentTime = Date.now(); // token.expiry is seconds since Unix Epoch // The Date constructor expects milliseconds since Unix Epoch const tokenExpiryMs = token.expiry * 1000; trace(`Checking token expiry for ${token.audience}`); trace(` Current time: ${currentTime}`); trace(` Token expiry: ${tokenExpiryMs}`); // If the token has 10 seconds (by default) or less left, renew it. // The Identity server token cache is cleared 30 seconds before the token expires, allowing us to renew it // See: https://github.com/camunda/camunda-8-js-sdk/issues/125 const tokenIsExpired = currentTime >= tokenExpiryMs - this.refreshWindow; return tokenIsExpired; } evictFromMemoryCache(audience, clientId) { const key = this.getCacheKey(clientId, audience); delete this.tokenCache[key]; } evictFromFileCache({ audienceType, clientId, }) { const filename = this.getCachedTokenFileName(clientId, audienceType); if (this.useFileCache && fs.existsSync(filename)) { fs.unlinkSync(filename); } } getAudience(audience) { return this.audienceMap[audience]; } addBearer(token) { return { authorization: `Bearer ${token}` }; } redactClientSecret(body) { return body.replace(/(client_secret=)[^&]*/g, '$1[REDACTED]'); } /** * Determines whether the given OAuth URL belongs to a Camunda SaaS environment. * Normalises the URL by stripping trailing slashes, query params, and fragments * so that logically equivalent URLs are detected consistently. */ static isSaaSUrl(oauthUrl) { try { const parsed = new URL(oauthUrl ?? ''); const normalizedPath = parsed.pathname.replace(/\/+$/, ''); return (OAuthProvider.SAAS_OAUTH_HOSTS.includes(parsed.host) && normalizedPath === '/oauth/token'); } catch { return false; } } getCredentialAudienceKey({ clientId, audienceType, }) { return `${clientId}::${audienceType}`; } is401Error(e) { if (!e || typeof e !== 'object') { return false; } const obj = e; const statusCode = obj.statusCode ?? obj.response?.statusCode; const msg = obj.message ?? ''; return (statusCode === 401 || /\b401\b/.test(msg) || /Unauthorized/i.test(msg)); } /** Persistent 401 tarpit helpers */ getTarpitFilePath({ clientId, clientSecret, audienceType, }) { const hash = this.hashSecret(clientSecret); return path_1.default.join(this.cacheDir, `oauth-401-tarpit-${clientId}-${audienceType}-${hash}.json`); } isTarpitted(file) { return this.tarpit401.has(file) && fs.existsSync(file); } hashSecret(secret) { // Deterministic, computationally expensive derivation using PBKDF2. // We use a constant salt for filename determinism while increasing cost via iterations. // Output truncated to 16 hex chars (same length as previous SHA-256 truncation) to keep filename compact. // NOTE: This is not for security storage of the secret (never store raw secret); just obfuscation + cost hardening. const SALT = 'camunda-oauth-tarpit-filename-salt-v1'; try { const derived = (0, crypto_1.pbkdf2Sync)(secret, SALT, 100000, 32, 'sha256'); return derived.toString('hex').slice(0, 16); } catch (err) { // Fail if PBKDF2 is unavailable; do not fall back to insecure hash. throw new Error('PBKDF2 algorithm is unavailable for hashing secret: ' + (err instanceof Error ? err.message : String(err))); } } createTarpitFile({ clientId, clientSecret, audienceType, reason, }) { if (!this.useFileCache) return; const file = this.getTarpitFilePath({ clientId, clientSecret, audienceType, }); if (fs.existsSync(file)) { this.tarpit401.add(file); return; } const payload = { createdAt: new Date().toISOString(), clientId, audienceType, reason, message: 'Persistent 401 tarpit – clear manually to retry', }; try { fs.writeFileSync(file, JSON.stringify(payload, null, 2)); this.tarpit401.add(file); trace(`Created persistent 401 tarpit file ${file}`); } catch (e) { trace(`Failed to write tarpit file ${file}: ${e.message}`); } } /** Public static helper to clear a specific persistent 401 tarpit */ static clear401Tarpit({ cacheDir = OAuthProvider.defaultTokenCache, clientId, clientSecret, audienceType, }) { try { // Reuse instance hashing logic deterministically (without needing an instance): replicate PBKDF2 parameters. const SALT = 'camunda-oauth-tarpit-filename-salt-v1'; const hash = (0, crypto_1.pbkdf2Sync)(clientSecret, SALT, 100000, 32, 'sha256') .toString('hex') .slice(0, 16); const file = path_1.default.join(cacheDir, `oauth-401-tarpit-${clientId}-${audienceType}-${hash}.json`); if (fs.existsSync(file)) { fs.unlinkSync(file); } // Best-effort in-memory cleanup for existing instances for (const inst of Array.from(OAuthProvider.instances)) { try { inst.tarpit401.delete(file); const credentialKey = inst.getCredentialAudienceKey({ clientId, audienceType, }); delete inst.memoized401[credentialKey]; } catch (_) { /* ignore */ } } } catch (_) { /* ignore */ } } } exports.OAuthProvider = OAuthProvider; OAuthProvider.defaultTokenCache = `${homedir}/.camunda`; // Track live instances for best-effort in-memory tarpit clearing OAuthProvider.instances = []; // Mutable for test overrides; legacy cooldown window (no longer used for tarpit persistence, retained for backward compatibility of tests) OAuthProvider.SAAS_401_COOLDOWN_MS = 30000; /** * Known Camunda SaaS OAuth hosts. Used for centralised SaaS detection. */ OAuthProvider.SAAS_OAUTH_HOSTS = [ 'login.cloud.camunda.io', 'login.cloud.dev.ultrawombat.com', ]; //# sourceMappingURL=OAuthProvider.js.map