UNPKG

@camunda8/sdk

Version:

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

363 lines 17 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 (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __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; class OAuthProvider { constructor(options) { this.tokenCache = {}; this.failed = false; this.failureCount = 0; this.getCacheKey = (audience) => `${this.clientId}-${audience}`; this.getCachedTokenFileName = (clientId, audience) => `${this.cacheDir}/oauth-token-${clientId}-${audience}.json`; const config = lib_1.CamundaEnvironmentConfigurator.mergeConfigWithEnvironment(options?.config ?? {}); this.log = (0, C8Logger_1.getLogger)(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; 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, https: { certificateAuthority, }, handlers: [lib_1.gotErrorHandler], hooks: { beforeError: [lib_1.gotBeforeErrorHook], }, })); 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)) { fs.mkdirSync(this.cacheDir, { recursive: true, }); } // 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 = this.authServerUrl.includes('https://login.cloud.camunda.io/oauth/token'); } async getToken(audienceType) { (0, debug_1.debug)(`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(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); 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); } } } if (!this.inflightTokenRequest) { this.inflightTokenRequest = new Promise((resolve, reject) => { const failureBackoff = Math.min(BACKOFF_TOKEN_ENDPOINT_FAILURE * this.failureCount, 15000); 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) => { this.failed = false; this.failureCount = 0; this.inflightTokenRequest = undefined; resolve(res); }) .catch((e) => { this.failureCount++; this.failed = true; this.inflightTokenRequest = undefined; reject(e); }); }, this.failed ? failureBackoff : 0); }); } return this.inflightTokenRequest; } flushMemoryCache() { this.tokenCache = {}; } flushFileCache() { if (this.useFileCache) { fs.readdirSync(this.cacheDir).forEach((file) => { if (fs.existsSync(file)) { fs.unlinkSync(file); } }); } } /** 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); return this.rest.then((rest) => rest .post(this.authServerUrl, options) .catch((e) => { e.message = `Error requesting token for Client Id ${clientIdToUse}: ${e.message}`; this.log.error(`Error requesting token for Client Id ${clientIdToUse}`); this.log.error(e); throw e; }) .then((res) => JSON.parse(res.body)) .then((t) => { trace(`Got token for Client Id ${clientIdToUse}: ${JSON.stringify(t, 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) { console.error(audienceType, 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 }); return this.addBearer(token.access_token); })); } sendToMemoryCache({ audience, token, }) { const key = this.getCacheKey(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}`); token.expiry = decoded.exp ?? 0; this.tokenCache[key] = token; } catch (e) { console.error('audience', audience); console.error('token', token.access_token); 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}`); token = JSON.parse(fs.readFileSync(this.getCachedTokenFileName(clientId, audience), 'utf8')); trace(`Retrieved token from file cache`); if (this.isExpired(token)) { trace(`File cached token is expired`); return null; } this.sendToMemoryCache({ audience, token }); return token; } catch (_) { return null; } } sendToFileCache({ audience, token, clientId, }) { const file = this.getCachedTokenFileName(clientId, audience); const decoded = (0, jwt_decode_1.jwtDecode)(token.access_token); fs.writeFile(file, JSON.stringify({ ...token, expiry: decoded.exp ?? 0, }), (e) => { if (!e) { trace(`Wrote OAuth token to file ${file}`); return; } // tslint:disable-next-line console.error('Error writing OAuth token to file' + file); // tslint:disable-next-line console.error(e); }); } isExpired(token) { const d = new Date(); const currentTime = d.setSeconds(d.getSeconds()); // 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) { const key = this.getCacheKey(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 `Bearer ${token}`; } } exports.OAuthProvider = OAuthProvider; OAuthProvider.defaultTokenCache = `${homedir}/.camunda`; //# sourceMappingURL=OAuthProvider.js.map