@camunda8/sdk
Version:
[](https://www.npmjs.com/package/@camunda8/sdk)
363 lines • 17 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 (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