@camunda8/sdk
Version:
[](https://www.npmjs.com/package/@camunda8/sdk)
713 lines • 32.6 kB
JavaScript
"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