scimgateway 5.0.10 → 5.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/lib/helper-rest.ts +13 -9
- package/lib/scimgateway.ts +20 -4
- package/lib/utils.ts +11 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1111,6 +1111,13 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
|
|
|
1111
1111
|
|
|
1112
1112
|
## Change log
|
|
1113
1113
|
|
|
1114
|
+
### v5.0.11
|
|
1115
|
+
|
|
1116
|
+
[Fixed]
|
|
1117
|
+
|
|
1118
|
+
- OAuth token response on error missing error_description in v5
|
|
1119
|
+
- HelperRest doRequest() now also includes retry logic on invalid token that has not expired - will renew token
|
|
1120
|
+
|
|
1114
1121
|
### v5.0.10
|
|
1115
1122
|
|
|
1116
1123
|
[Improved]
|
package/lib/helper-rest.ts
CHANGED
|
@@ -166,13 +166,11 @@ export class HelperRest {
|
|
|
166
166
|
const response = await this.doRequest(baseEntity, method, tokenUrl, form, ctx, connOpt)
|
|
167
167
|
if (!response.body) {
|
|
168
168
|
const err = new Error(`[${action}] No data retrieved from: ${method} ${tokenUrl}`)
|
|
169
|
-
this.lock.release()
|
|
170
169
|
throw (err)
|
|
171
170
|
}
|
|
172
171
|
const jbody = response.body
|
|
173
172
|
if (jbody.error) {
|
|
174
173
|
const err = new Error(`[${action}] Error message: ${jbody.error_description}`)
|
|
175
|
-
this.lock.release()
|
|
176
174
|
throw (err)
|
|
177
175
|
}
|
|
178
176
|
if (this.config_entity[baseEntity]?.connection?.auth?.type === 'token') { // in case response using token instead of access_token
|
|
@@ -180,7 +178,6 @@ export class HelperRest {
|
|
|
180
178
|
else if (jbody.accessToken) jbody.access_token = jbody.accessToken
|
|
181
179
|
}
|
|
182
180
|
if (!jbody.access_token) {
|
|
183
|
-
this.lock.release()
|
|
184
181
|
const err = new Error(`[${action}] Error message: Retrieved invalid token response`)
|
|
185
182
|
throw (err)
|
|
186
183
|
}
|
|
@@ -229,7 +226,7 @@ export class HelperRest {
|
|
|
229
226
|
this._serviceClient[baseEntity][clientIdentifier].accessToken = accessToken
|
|
230
227
|
this._serviceClient[baseEntity][clientIdentifier].options.headers['Authorization'] = ` Bearer ${accessToken.access_token}`
|
|
231
228
|
} catch (err) {
|
|
232
|
-
delete this._serviceClient[baseEntity][clientIdentifier]
|
|
229
|
+
if (this._serviceClient[baseEntity]) delete this._serviceClient[baseEntity][clientIdentifier]
|
|
233
230
|
const newErr = err
|
|
234
231
|
throw newErr
|
|
235
232
|
}
|
|
@@ -517,9 +514,6 @@ export class HelperRest {
|
|
|
517
514
|
} catch (err: any) { // includes failover/retry logic based on config baseUrls array
|
|
518
515
|
let statusCode
|
|
519
516
|
try { statusCode = JSON.parse(err.message).statusCode } catch (e) { void 0 }
|
|
520
|
-
if (statusCode === 404) { // not logged as error, let caller decide e.g. getUser-manager
|
|
521
|
-
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
522
|
-
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
523
517
|
const clientIdentifier = this.getClientIdentifier(ctx)
|
|
524
518
|
if (err.message.includes('ratelimit')) { // have seen throttling not follow standard 429/retry-after, but instead using 500 and error message only
|
|
525
519
|
if (!retryAfter) retryAfter = 60
|
|
@@ -527,13 +521,17 @@ export class HelperRest {
|
|
|
527
521
|
if (!retryCount) retryCount = 0
|
|
528
522
|
let urlObj
|
|
529
523
|
try { urlObj = new URL(path) } catch (err) { void 0 }
|
|
530
|
-
|
|
524
|
+
let isServiceClient = !urlObj && this._serviceClient[baseEntity] && this._serviceClient[baseEntity][clientIdentifier] && !this.lock.isLocked() // !isLocked to avoid retry ongoing doRequest with failing getAccessToken()
|
|
525
|
+
let oAuthTokeErr = statusCode === 401 && this.config_entity[baseEntity].connection?.auth?.type && this.config_entity[baseEntity].connection.auth.type.startsWith('oauth')
|
|
526
|
+
if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || oAuthTokeErr || retryAfter)) {
|
|
527
|
+
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
531
528
|
if (retryAfter) {
|
|
532
529
|
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
|
|
533
530
|
await new Promise(resolve => setTimeout(function () {
|
|
534
531
|
resolve(null)
|
|
535
532
|
}, retryAfter * 1000))
|
|
536
533
|
}
|
|
534
|
+
if (oAuthTokeErr && this._serviceClient[baseEntity]) delete this._serviceClient[baseEntity][clientIdentifier] // ensure new getAccessToken request - token used should not have been expired, but rejected for other reason e.g. token server restart and no persistent token store?
|
|
537
535
|
if (retryCount < this.config_entity[baseEntity].connection.baseUrls.length) {
|
|
538
536
|
retryCount++
|
|
539
537
|
this.updateServiceClient(baseEntity, clientIdentifier, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
|
|
@@ -541,13 +539,19 @@ export class HelperRest {
|
|
|
541
539
|
const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
|
|
542
540
|
return ret // problem fixed
|
|
543
541
|
} else {
|
|
542
|
+
if (statusCode === 404) { // not logged as error e.g. getUser-manager
|
|
543
|
+
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
544
|
+
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} 11Error Response = ${err.message}`)
|
|
544
545
|
throw err
|
|
545
546
|
}
|
|
546
547
|
} else {
|
|
548
|
+
if (statusCode === 404) { // not logged as error e.g. getUser-manager
|
|
549
|
+
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
550
|
+
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
547
551
|
if (statusCode === 401 && this._serviceClient[baseEntity]) {
|
|
548
552
|
delete this._serviceClient[baseEntity][clientIdentifier]
|
|
549
553
|
}
|
|
550
|
-
throw err //
|
|
554
|
+
throw err // Symantec IM retries getUser 6 times on ECONNREFUSED
|
|
551
555
|
}
|
|
552
556
|
}
|
|
553
557
|
}
|
package/lib/scimgateway.ts
CHANGED
|
@@ -698,7 +698,6 @@ export class ScimGateway {
|
|
|
698
698
|
}
|
|
699
699
|
if (tokenObj.baseEntities) {
|
|
700
700
|
if (Array.isArray(tokenObj.baseEntities) && tokenObj.baseEntities.length > 0) {
|
|
701
|
-
if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerOAuth according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
|
|
702
701
|
if (!tokenObj.baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerOAuth according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
|
|
703
702
|
}
|
|
704
703
|
}
|
|
@@ -706,7 +705,11 @@ export class ScimGateway {
|
|
|
706
705
|
return resolve(true)
|
|
707
706
|
} else {
|
|
708
707
|
for (let i = 0; i < arr.length; i++) { // resolve if token memory store have been cleared because of a gateway restart
|
|
709
|
-
if (
|
|
708
|
+
if (arr[i].isTokenRequested || !arr[i].clientSecret) continue
|
|
709
|
+
if (arr[i].baseEntities && Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
|
|
710
|
+
if (!arr[i].baseEntities.includes(baseEntity)) continue
|
|
711
|
+
}
|
|
712
|
+
if (utils.getEncrypted(authToken, arr[i].clientSecret) === arr[i].clientSecret) {
|
|
710
713
|
arr[i].isTokenRequested = true // flagged as true to not allow repeated resolvements because token will also be cleared when expired
|
|
711
714
|
const baseEntities = utils.copyObj(arr[i].baseEntities)
|
|
712
715
|
let expires
|
|
@@ -774,8 +777,21 @@ export class ScimGateway {
|
|
|
774
777
|
if (ctx.request.url !== '/favicon.ico') logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${err.message}`)
|
|
775
778
|
return false
|
|
776
779
|
} catch (err: any) {
|
|
777
|
-
if (authType === 'Bearer')
|
|
778
|
-
|
|
780
|
+
if (authType === 'Bearer') {
|
|
781
|
+
let str = 'realm=""'
|
|
782
|
+
if (err?.name === 'invalid_token') {
|
|
783
|
+
str += `, error="${err.name}"`
|
|
784
|
+
if (err.message) {
|
|
785
|
+
str += `, error_description="${err.message}"`
|
|
786
|
+
const errMsg = {
|
|
787
|
+
error: err.name,
|
|
788
|
+
error_description: err.message,
|
|
789
|
+
}
|
|
790
|
+
ctx.response.body = JSON.stringify(errMsg)
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
ctx.response.headers.set('WWW-Authenticate', `Bearer ${str}`)
|
|
794
|
+
} else ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
|
|
779
795
|
if (pwErrCount < 3) {
|
|
780
796
|
pwErrCount += 1
|
|
781
797
|
logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ctx.request.url} ${err.message}`)
|
package/lib/utils.ts
CHANGED
|
@@ -14,18 +14,20 @@ import fs from 'node:fs'
|
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
import { EventEmitter } from 'node:events'
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/** Lock implements mutual exclusion
|
|
18
|
+
* reference: https://thecodebarbarian.com/mutual-exclusion-patterns-with-node-promises
|
|
19
|
+
*/
|
|
18
20
|
export class Lock {
|
|
19
|
-
private _locked
|
|
21
|
+
private _locked = false
|
|
20
22
|
private _ee: any
|
|
21
23
|
constructor() {
|
|
22
24
|
this._locked = false
|
|
23
25
|
this._ee = new EventEmitter()
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/** If nobody has the lock, take it and resolve immediately else wait until released */
|
|
26
29
|
acquire() {
|
|
27
30
|
return new Promise((resolve) => {
|
|
28
|
-
// If nobody has the lock, take it and resolve immediately
|
|
29
31
|
if (!this._locked) {
|
|
30
32
|
// Safe because JS doesn't interrupt you on synchronous operations,
|
|
31
33
|
// so no need for compare-and-swap or anything like that.
|
|
@@ -45,11 +47,16 @@ export class Lock {
|
|
|
45
47
|
})
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** Release the lock immediately */
|
|
48
51
|
release() {
|
|
49
|
-
// Release the lock immediately
|
|
50
52
|
this._locked = false
|
|
51
53
|
setImmediate(() => this._ee.emit('release'))
|
|
52
54
|
}
|
|
55
|
+
|
|
56
|
+
/** Return status of lock true/false */
|
|
57
|
+
isLocked(): boolean {
|
|
58
|
+
return this._locked
|
|
59
|
+
}
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export const getSecret = function (dotNotationAttr: string, configFile: string) {
|
package/package.json
CHANGED