UNPKG

scimgateway

Version:

Using SCIM protocol as a gateway for user provisioning to other endpoints

762 lines (728 loc) 29.6 kB
// ================================== // File: utils.js // // Author: Jarle Elshaug // // ================================== import * as crypto from 'node:crypto' import id from 'node-machine-id' import fs from 'node:fs' import path from 'node:path' import { EventEmitter } from 'node:events' /** Lock implements mutual exclusion * reference: https://thecodebarbarian.com/mutual-exclusion-patterns-with-node-promises */ export class Lock { private _locked = false private _ee: any constructor() { this._locked = false this._ee = new EventEmitter() } /** If nobody has the lock, take it and resolve immediately else wait until released */ acquire() { return new Promise((resolve) => { if (!this._locked) { // Safe because JS doesn't interrupt you on synchronous operations, // so no need for compare-and-swap or anything like that. this._locked = true return resolve(null) } // Otherwise, wait until somebody releases the lock and try again const tryAcquire = () => { if (!this._locked) { this._locked = true this._ee.removeListener('release', tryAcquire) return resolve(null) } } this._ee.on('release', tryAcquire) }) } /** Release the lock immediately */ release() { this._locked = false setImmediate(() => this._ee.emit('release')) } /** Return status of lock true/false */ isLocked(): boolean { return this._locked } } export const getSecret = function (dotNotationAttr: string, configFile: string) { // get password from json-file. // if cleartext then encrypt and save to file + return cleartext secret // else return decrypted secret const configString = fs.readFileSync(configFile).toString() const config = JSON.parse(configString) const pw = objProp(config, dotNotationAttr, null) let pwclear let seed try { seed = path.basename(configFile) + (process.env.SEED || id.machineIdSync(true)) } catch (err: any) { throw (new Error(`consider using SEED environment because machineId can't be found - error: ${err.message}`)) } const ivLength = 16 if (seed.length < ivLength) throw (new Error('Password seed length too short')) if (pw) { if (pw.includes('process.')) { // password based on external reference e.g. environment or json file // syntax environment = "process.env.<ENVIRONMENT>" e.g. scimgateway.password could have value "process.env.PORT", then using environment variable PORT // syntax file = "process.file.<PATH>" e.g. scimgateway.password could have value "process.file./tmp/myconf.json" const processText = 'process.text.' const processEnv = 'process.env.' const processFile = 'process.file.' if (pw.constructor === String && pw.includes(processEnv)) { const envKey = pw.substring(processEnv.length) pwclear = process.env[envKey] } else if (pw && pw.constructor === String && pw.includes(processText)) { const filePath = pw.substring(processFile.length) try { pwclear = fs.readFileSync(filePath, 'utf8') } catch (err) { pwclear = undefined } // can't read external configuration file } else if (pw && pw.constructor === String && pw.includes(processFile)) { const filePath = pw.substring(processFile.length) try { const content = fs.readFileSync(filePath, 'utf8') try { const pluginName = path.basename(configFile, '.json') const jContent = JSON.parse(content) pwclear = objProp(jContent, `${pluginName}.${dotNotationAttr}`, null) } catch (err) { pwclear = undefined } // can't JSON parse external file } catch (err) { pwclear = undefined } // can't read external configuration file } else pwclear = undefined } else { // password based on local configuration file try { // decrypt const pwencr = Buffer.from(pw, 'base64').toString('utf8') const textParts: any = pwencr.split(':') const iv = Buffer.from(textParts.shift(), 'hex') const encryptedText = Buffer.from(textParts.join(':'), 'hex') const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(seed.substr(-ivLength)), iv) pwclear = decipher.update(encryptedText) pwclear = Buffer.concat([pwclear, decipher.final()]) pwclear = pwclear.toString() } catch (err) { // password considered as cleartext and needs to be encrypted and written back to file pwclear = pw const iv = crypto.randomBytes(ivLength) const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(seed.substr(-ivLength), 'utf8'), iv) let encrypted = cipher.update(pwclear) encrypted = Buffer.concat([encrypted, cipher.final()]) let pwencr = iv.toString('hex') + ':' + encrypted.toString('hex') pwencr = Buffer.from(pwencr).toString('base64') objProp(config, dotNotationAttr, pwencr) let fileContent = JSON.stringify(config, null, 2) // removing white space, but use 2 space separator fileContent = fileContent.replace(/\n/g, '\r\n') // cr-lf instead of lf try { fs.writeFileSync(configFile, fileContent) } catch (err) { const newErr = err throw newErr } } } } else { pwclear = undefined // can't be found in configuration file or having value null/undefined } return pwclear } export const timestamp = function () { // new Date() do not handle current timezone const pad = (n: number) => { return n < 10 ? '0' + n : n } const d = new Date() return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds()) } /** * Fxn that returns a JSON stringified version of an object. * This fxn uses a custom replacer function to handle circular references * see http://stackoverflow.com/a/11616993/3043369 */ export const JSONStringify = function (object: any) { let cache: any = [] const str = JSON.stringify(object, // custom replacer fxn - gets around "TypeError: Converting circular structure to JSON" function (key, value) { if (typeof value === 'object' && value !== null) { if (cache.indexOf(value) !== -1) { return // Circular reference found, discard key } cache.push(value) // Store value in our collection } return value }, 2) cache = null // enable garbage collection return str } const objProp = function (obj: Record<string, any>, prop: string, val: any) { // return obj value based on json dot notation formatted prop if (Object.prototype.hasOwnProperty.call(obj, prop)) return obj[prop] const props = prop.split('.') // scimgateway.auth.basic[0].password const final = props.pop() as string for (let i = 0; i < props.length; i++) { const p = props[i] const arr = p.match(/^(.*)\[(.*)\]$/i) if (Array.isArray(arr) && arr.length === 3) { // basic[0] => [ "basic[0]", "basic", "0" ] obj = obj[arr[1]][Number(arr[2])] // obj['basic'][0] } else obj = obj[p] if (typeof obj === 'undefined') { return undefined } } return val ? (obj[final] = val) : obj[final] } export const copyObj = (o: any): any => { // deep copy/clone faster than JSON.parse(JSON.stringify(o)) let v, key const output: any = Array.isArray(o) ? [] : {} for (key in o) { v = o[key] if (typeof v === 'object' && v !== null) { const objProp = Object.getPrototypeOf(v) // e.g. HttpsProxyAgent {} if (objProp !== null && objProp !== Object.getPrototypeOf({}) && objProp !== Object.getPrototypeOf([])) { output[key] = Object.assign(Object.create(v), v) // e.g. { HttpsProxyAgent {...} } } else output[key] = copyObj(v) } else output[key] = v } return output } const _extendObj = (obj: Record<any, any>, src: Record<any, any>) => { Object.keys(src).forEach((key) => { if (typeof src[key] === 'object' && src[key] !== null) { if (Object.keys(src[key]).length === 0) return if (typeof obj[key] === 'undefined') obj[key] = src[key] else if (Array.isArray(src[key])) { if (!Array.isArray(obj[key])) obj[key] = src[key] else { for (let i = 0; i < src[key].length; i++) { const val = src[key][i] if (typeof val === 'object') { if (Object.prototype.hasOwnProperty.call(val, 'type')) { if (!obj[key]) obj[key] = [val] else { for (let j = 0; j < obj[key].length; j++) { const el = obj[key][j] if (el.type === val.type) { obj[key].splice(j, 1) j -= 1 break } } obj[key].push(val) } } else if (Object.prototype.hasOwnProperty.call(val, 'value')) { if (!obj[key]) obj[key] = [val] else { for (let j = 0; j < obj[key].length; j++) { const el = obj[key][j] if (el.value === val.value) { obj[key].splice(j, 1) j -= 1 break } } obj[key].push(val) } } } else if (!obj[key].includes(val)) obj[key].push(val) } } } else obj[key] = extendObj(obj[key], src[key]) } else if (src[key] != null) obj[key] = src[key] }) return obj } export const extendObj = (obj: any, src: any) => { // copy src content into obj if (typeof src !== 'object' || src === null || Array.isArray(src)) return obj if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return src return _extendObj(obj, src) } // extendObjClear extends obj with cleared src // if isSoftSync, extend without cleared export const extendObjClear = (obj: Record<string, any>, src: Record<string, any>, isSoftSync?: boolean) => { Object.keys(src).forEach((key) => { if (src[key] === null) return if (typeof src[key] !== 'object') { // last key if (Object.prototype.hasOwnProperty.call(obj, key)) return if (isSoftSync) obj[key] = src[key] else { switch (typeof src[key]) { case 'string': obj[key] = '' break case 'boolean': obj[key] = false break case 'number': obj[key] = 0 break default: obj[key] = '' } } return } if (!Array.isArray(src[key])) { if (!obj[key]) obj[key] = {} obj[key] = extendObjClear(obj[key], src[key], isSoftSync) } else { // array if (!Array.isArray(obj[key])) obj[key] = [] for (let i = 0; i < src[key].length; i++) { const val = src[key][i] if (typeof val !== 'object') { if (!obj[key].includes(val)) obj[key].push(val) // e.g. ["value1", "value2"] } else { if (Object.prototype.hasOwnProperty.call(val, 'type') && key !== 'members' && key !== 'groups') { if (obj[key].length < 1) { const v: any = copyObj(val) if (!isSoftSync) v.operation = 'delete' obj[key].push(v) } else { let found = false for (const k in obj[key]) { const el = obj[key][k] if (el.type === val.type) { found = true for (const kv in val) { if (kv === 'type' || kv === 'value' || isSoftSync) continue // don't clear type/value if (Object.prototype.hasOwnProperty.call(el, kv)) continue switch (typeof val[kv]) { case 'string': el[kv] = '' break case 'boolean': el[kv] = false break case 'number': el[kv] = 0 break default: el[kv] = '' } } } } if (!found) { const v: any = copyObj(val) if (!isSoftSync) v.operation = 'delete' obj[key].push(v) } } } else if (Object.prototype.hasOwnProperty.call(val, 'value')) { // no type if (obj[key].length < 1) { const v: any = copyObj(val) if (!isSoftSync) v.operation = 'delete' obj[key].push(v) } else { const addArr: any = [] let found = false for (let j = 0; j < obj[key].length; j++) { const el = obj[key][j] if (el.value === val.value) { obj[key].splice(j, 1) j -= 1 found = true break } } if (!found) { const v: any = copyObj(val) if (!isSoftSync) v.operation = 'delete' addArr.push(v) } if (addArr.length > 0) { obj[key] = [...obj[key], ...addArr] } } } else { obj[key].push(val) } } } } }) return obj // recursive } // deltaObj removes from obj what matches with src, only delta remains in obj export const deltaObj = (obj: Record<string, any>, src: Record<string, any>) => { for (const key in obj) { if (Array.isArray(obj[key])) { if (!src[key] || !Array.isArray(src[key])) continue const arr = obj[key] for (let i = 0; i < arr.length; i++) { const el = arr[i] if (el.operation) continue // keep operation if (el.type) { if (Object.prototype.hasOwnProperty.call(el, 'value')) { const a = src[key].filter(o => o.type === el.type && o.value === el.value) if (a.length === 1) { arr.splice(i, 1) i -= 1 } } else { // only type const a = src[key].filter((o) => { for (const k in el) { if (el[k] !== o[k]) return false } return true }) if (a.length === 1) { arr.splice(i, 1) i -= 1 } } } else if (Object.prototype.hasOwnProperty.call(el, 'value')) { const a = src[key].filter(o => o.value === el.value) if (a.length === 1) { arr.splice(i, 1) i -= 1 } } } if (arr.length === 0) { delete obj[key] } } else { if (typeof (obj[key]) === 'object') { for (const keySub in obj[key]) { if (typeof (obj[key][keySub]) === 'object') { for (const keySubSub in obj[key][keySub]) { if (src[key] && src[key][keySub] && src[key][keySub][keySubSub] === obj[key][keySub][keySubSub]) { delete obj[key][keySub][keySubSub] } } if (Object.keys(obj[key][keySub]).length === 0) delete obj[key][keySub] } else { if (src[key] && src[key][keySub] === obj[key][keySub]) { delete obj[key][keySub] } } } if (Object.keys(obj[key]).length === 0) delete obj[key] } else if (obj[key] === src[key]) delete obj[key] } } } // stripObj strips and return a new object according to attributes or excludedAttributes - comma separated dot object list export const stripObj = (obj: Record<string, any>, attributes?: string, excludedAttributes?: string) => { if (!attributes && !excludedAttributes) return obj if (!obj || typeof obj !== 'object') return obj let arrObj if (!Array.isArray(obj)) arrObj = [obj] else { if (obj.length < 1) return obj arrObj = obj } let arrRet = [] const arrCheckEmpty: any = [] if (attributes) { const arrAttr = attributes.split(',').filter(Boolean).map(item => item.trim()) if (!arrAttr.includes('id')) arrAttr.push('id') // always include id if (!arrAttr.includes('meta')) arrAttr.push('meta') // include meta if supported by endpoint arrRet = arrObj.map((obj) => { const ret: Record<string, any> = {} for (let i = 0; i < arrAttr.length; i++) { const attr = arrAttr[i].split('.') // title / name.familyName / emails.value if (Object.prototype.hasOwnProperty.call(obj, attr[0])) { if (attr.length === 1) ret[attr[0]] = obj[attr[0]] else if (Object.prototype.hasOwnProperty.call(obj[attr[0]], attr[1])) { // name.familyName if (!ret[attr[0]]) ret[attr[0]] = {} ret[attr[0]][attr[1]] = obj[attr[0]][attr[1]] } else if (Array.isArray(obj[attr[0]])) { // emails.value / phoneNumbers.type if (!ret[attr[0]]) ret[attr[0]] = [] const arr = obj[attr[0]] for (let j = 0; j < arr.length; j++) { if (typeof arr[j] !== 'object') { ret[attr[0]].push(arr[j]) } else if (Object.prototype.hasOwnProperty.call(arr[j], attr[1])) { if (ret[attr[0]].length !== arr.length) { // initiate for (let i = 0; i < arr.length; i++) ret[attr[0]].push({}) // need arrCheckEmpty } ret[attr[0]][j][attr[1]] = arr[j][attr[1]] if (!arrCheckEmpty.includes(attr[0])) arrCheckEmpty.push(attr[0]) } } } } } if (arrCheckEmpty.length > 0) { for (let i = 0; i < arrCheckEmpty.length; i++) { const arr = ret[arrCheckEmpty[i]] for (let j = 0; j < arr.length; j++) { try { if (JSON.stringify(arr[j]) === '{}') arr.splice(j, 1) } catch (err) { return } } } } return ret }) } else if (excludedAttributes) { const arrAttr = excludedAttributes.split(',').filter(Boolean).map(item => item.trim()).filter(item => item !== 'id' && item !== 'meta') arrRet = arrObj.map((obj) => { const ret: any = copyObj(obj) for (let i = 0; i < arrAttr.length; i++) { const attr = arrAttr[i].split('.') // title / name.familyName / emails.value if (Object.prototype.hasOwnProperty.call(ret, attr[0])) { if (attr.length === 1) delete ret[attr[0]] else if (Object.prototype.hasOwnProperty.call(ret[attr[0]], attr[1])) delete ret[attr[0]][attr[1]] // name.familyName else if (Array.isArray(ret[attr[0]])) { // emails.value / phoneNumbers.type const arr = ret[attr[0]] for (let j = 0; j < arr.length; j++) { if (Object.prototype.hasOwnProperty.call(arr[j], attr[1])) { const index = arr.findIndex((el: Record<string, any>) => ((Object.prototype.hasOwnProperty.call(el, attr[1])))) if (index > -1) { delete arr[index][attr[1]] try { if (JSON.stringify(arr[index]) === '{}') arr.splice(index, 1) } catch (err) { return } } } } } } } return ret }) } else { // should not be here arrRet = [] } if (!Array.isArray(obj)) return arrRet[0] return arrRet } // sortByKey will string-sort array of objects by spesific key // myArr.sort(sortByKey('name.familyName', 'ascending')) export const sortByKey = (key: string, order: string = 'ascending') => { return (a: any, b: any) => { // inner sort const val: any = [undefined, undefined] const arrIter = [a, b] const levels = key.split('.') if (!Object.prototype.hasOwnProperty.call(a, levels[0]) || !Object.prototype.hasOwnProperty.call(b, levels[0])) return 0 arrIter.forEach((el, index) => { let parent = el for (let i = 0; i < levels.length; i++) { if (Array.isArray(parent[levels[i]])) { if (i === levels.length - 1) { parent = undefined break } parent = parent[levels[i]][0][levels[i + 1]] // using first array element istead of primary attribute e.g key=emails.value break } else parent = parent[levels[i]] } val[index] = parent }) if (typeof val[0] !== 'string') return 0 const comparison = val[0].localeCompare(val[1]) return ( (order === 'descending') ? (comparison * -1) : comparison ) } } // getEncrypted returns encrypted or cleartext secret // same as getPassword method, but seed passed as argument and not using json configuration file // if pw is cleartext, return encrypted secret // if pw is encrypted, return cleartext secret export const getEncrypted = function (pw: string, seed: string) { if (!pw || !seed) return undefined const ivLength = 16 if (seed.length < ivLength) { const addStr = 'aB1cD2eF3gH4iJ5kL7' const diff = ivLength - seed.length if (diff > 0) seed += addStr.substring(0, diff) } const pwencr = Buffer.from(pw, 'base64').toString('utf8') try { // decrypt const textParts: any = pwencr.split(':') const iv = Buffer.from(textParts.shift(), 'hex') const encryptedText = Buffer.from(textParts.join(':'), 'hex') const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(seed.substr(-ivLength)), iv) const pw = decipher.update(encryptedText) const pwClear: any = Buffer.concat([pw, decipher.final()]) return pwClear.toString() } catch (err) { // password considered as cleartext and needs to be encrypted const pwclear = pw const iv = crypto.randomBytes(ivLength) const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(seed.substr(-ivLength), 'utf8'), iv) let encrypted = cipher.update(pwclear) encrypted = Buffer.concat([encrypted, cipher.final()]) const pwencr = iv.toString('hex') + ':' + encrypted.toString('hex') return Buffer.from(pwencr).toString('base64') } return undefined } // fsExistsSync checks if file/directory exist and returns true/false export const fsExistsSync = function (f: string) { try { fs.accessSync(f) return true } catch (e) { return false } } // createRandomPassword creates a random password, syntax: // utils.createRandomPassword(12) => 12 characters: lower, upper, numeric and special // utils.createRandomPassword(12, utils.createRandomPassword.alphaLower, utils.createRandomPassword.alphaUpper) // https://gist.github.com/6174/6062387 export const createRandomPassword = function (len: number, ...set: string[]) { const gen = (min: number, max: number) => max++ && [...Array(max - min)].map((s, i) => String.fromCharCode(min + i)) const sets = { num: gen(48, 57), alphaLower: gen(97, 122), alphaUpper: gen(65, 90), special: [...'~!@#$%^&*()_+-=[]{}|;:\'",./<>?'], } function* iter(len: number, set: any) { if (set.length < 1) { set = Object.values(sets).flat() } for (let i = 0; i < len; i++) { yield set[Math.random() * set.length | 0] } } let res if (len > 3 && set.length === 0) { // ensure all variants are included: lower, upper, numeric and special res = Object.assign((len: number, set: any) => [...iter(len, set.flat())].join(''), sets)(len, set) let pos = Math.random() * len | 0 if (pos > len - 4) pos = len - 4 res = res.split('') res.splice(pos, 1, Object.assign((len: number, set: any) => [...iter(len, set.flat())].join(''), sets)(1, sets.num)) res.splice(pos + 1, 1, Object.assign((len: number, set: any) => [...iter(len, set.flat())].join(''), sets)(1, sets.alphaUpper)) res.splice(pos + 2, 1, Object.assign((len: number, set: any) => [...iter(len, set.flat())].join(''), sets)(1, sets.special)) res.splice(pos + 3, 1, Object.assign((len: number, set: any) => [...iter(len, set.flat())].join(''), sets)(1, sets.alphaLower)) res = res.join('') } else { res = Object.assign((len: number, set: any) => [...iter(len, set.flat())].join(''), sets)(len, set) } return res } /** * formUrlEncodedToJSON converts application/x-www-form-urlencoded request body to json * @param body http request body string * @returns json formatted body */ export const formUrlEncodedToJSON = function (body?: string): Record<string, any> { if (!body) return {} const arr = body.split('&') // "grant_type=client_credentials&client_id=id&client_secret=secret" const json: Record<string, any> = {} for (const kv of arr) { const a = kv.split('=') if (a.length === 2) { try { json[a[0]] = decodeURIComponent(a[1]) } catch (err) { return {} } } } return json } /** * formDataMultipartToJSON converts multipart/form-data request body to json - Content-Type: multipart/form-data;boundary="<some-delimiter>" * @param body request body * @param boundary the boundary/delimiter defiend in header Content-Type: multipart/form-data;boundary="<some-delimiter>" * @returns json formatted body */ export const formDataMultipartToJSON = function (body?: string, boundary?: string): Record<string, any> { if (!body) return {} // --delimiter123\nContent-Disposition: form-data; name="field1"\n\nvalue1\n--delimiter123\nContent-Disposition: form-data; name="field2"; filename="example.txt"\n\nvalue2 if (!boundary) { // if boundary is missing, try to infer it const inferredBoundary = body.match(/^--([^\r\n]+)/) if (inferredBoundary) { boundary = inferredBoundary[1] } else return {} // No boundary found } const parts = body.split(`--${boundary}`).filter(part => part.trim() && !part.includes('--')) const json: Record<string, any> = {} for (const part of parts) { const [headers, value] = part.split(/\r?\n\r?\n/) const nameMatch = headers.match(/name="([^"]+)"/) if (nameMatch) { const key = nameMatch[1] json[key] = value.trim() try { json[key] = decodeURIComponent(json[key]) } catch (err) { return {} } } } return json } /** * getBase64CertificateThumbprintconverts return Base64url-encoded SHA thumbprint of the X.509 certificate's DER encoding * @param pemCertContent PEM formatted certificate content * @param shaVersion sha1 or sha256, default sha1 * @returns Base64url-encoded SHA thumbprint */ export const getBase64CertificateThumbprint = function (pemCertContent: string, shaVersion: 'sha1' | 'sha256' = 'sha1'): string { if (!pemCertContent) return '' let certMatch = pemCertContent.match(/-----BEGIN CERTIFICATE-----([\s\S]+?)-----END CERTIFICATE-----/) if (!certMatch) { certMatch = pemCertContent.match(/-----BEGIN PUBLIC KEY-----([\s\S]+?)-----END PUBLIC KEY-----/) if (!certMatch) return '' } const certBase64 = certMatch[1].replace(/\s+/g, '') // remove whitespace and newlines const certDer = Buffer.from(certBase64, 'base64') // decode the PEM to DER (Base64 decode) const hash = crypto.createHash(shaVersion).update(certDer).digest() // compute the SHA-256 hash of the DER // thumbprint = hash.toString('hex') const base64Url = hash .toString('base64') // convert binary hash to standard Base64 .replace(/\+/g, '-') // replace '+' with '-' .replace(/\//g, '_') // replace '/' with '_' .replace(/=+$/, '') // remove '=' padding return base64Url } /** * getEtag returns an ETag for the given object and updates the object with the ETag in meta.version * @param obj full object to calculate ETag from * @returns ETag string as W/"<hash>" */ export const getEtag = function (obj: Record<string, any>): string { if (typeof obj !== 'object' || obj === null) return '' const hash = crypto .createHash('sha256') .update(JSON.stringify(obj), 'utf8') .digest('base64url') .substring(0, 22) let eTag = '' if (obj?.meta?.version) eTag = obj.meta.version else { eTag = `W/"${hash}"` if (!obj.meta) obj.meta = {} obj.meta.version = eTag } return eTag } /** * TimerMapCache caches items for specified time and also clear items on valdation * @param obj full object to calculate ETag from * @returns ETag string as W/"<hash>" */ export class TimerMapCache { private itemCache = new Map<string, NodeJS.Timeout>() private itemTimeout: number constructor(itemTimeout: number = 5 * 1000) { // default 5 seconds in cache this.itemCache = new Map<string, NodeJS.Timeout>() this.itemTimeout = itemTimeout } /** * createItem creates an item in cache that will be cleared when using isItemValid() or after timeout set by class initialization * @param item * @returns item */ createItem(item: string): string { if (!item) return '' this.itemCache.set(item, setTimeout(() => { console.log('deleting item') this.itemCache.delete(item) }, this.itemTimeout)) return item } /** * isItemValid checks if item can be found in cache. If found, it will be removed from cache and return true * @param item * @returns true if valid, else false */ isItemValid(item: string): boolean { if (!item) return false const timeout = this.itemCache.get(item) const res = !!timeout if (res) { clearTimeout(timeout) this.itemCache.delete(item) } return res } }