scimgateway 5.0.9 → 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 CHANGED
@@ -1111,6 +1111,19 @@ 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
+
1121
+ ### v5.0.10
1122
+
1123
+ [Improved]
1124
+
1125
+ - OAuth token request now accept missing or invalid Content-Type header
1126
+
1114
1127
  ### v5.0.9
1115
1128
 
1116
1129
  [Improved]
@@ -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
- if (!urlObj && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || retryAfter)) {
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 // CA IM retries getUser failure once (retry 6 times on ECONNREFUSED)
554
+ throw err // Symantec IM retries getUser 6 times on ECONNREFUSED
551
555
  }
552
556
  }
553
557
  }
@@ -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 (utils.getEncrypted(authToken, arr[i].client_secret) === arr[i].client_secret && !arr[i].isTokenRequested) {
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') ctx.response.headers.set('WWW-Authenticate', 'Bearer realm=""')
778
- else ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
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}`)
@@ -830,7 +846,20 @@ export class ScimGateway {
830
846
  let jsonBody = ctx.request.body
831
847
  try {
832
848
  if (!jsonBody) throw new Error('missing body')
833
- if (typeof jsonBody !== 'object') throw new Error('body is not JSON')
849
+ if (typeof jsonBody !== 'object') { // might have application/x-www-form-urlencoded body, but incorrect Content-Type header
850
+ logger.debug(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] continue request validation even though incorrect body vs header Content-Type: ${ctx.request.headers.get('content-type')}`)
851
+ const arr = jsonBody.split('&')
852
+ const body: Record<string, any> = {}
853
+ arr.forEach((kv: string) => {
854
+ const a = kv.split('=')
855
+ if (a.length === 2) {
856
+ body[a[0]] = decodeURIComponent(a[1])
857
+ }
858
+ })
859
+ if (Object.keys(body).length < 1) throw new Error('body is not JSON nor application/x-www-form-urlencoded')
860
+ ctx.request.body = body // now json - ensure final info log will be masked
861
+ jsonBody = body
862
+ }
834
863
  jsonBody = utils.copyObj(jsonBody) // no changes to original
835
864
  } catch (err: any) {
836
865
  logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] token request error: ${err.message}`)
@@ -875,7 +904,7 @@ export class ScimGateway {
875
904
  }
876
905
  if (!token) {
877
906
  err = 'invalid_client'
878
- errDescr = 'incorrect or missing clientId/clientSecret'
907
+ errDescr = 'incorrect or missing client_id/client_secret'
879
908
  if (pwErrCount < 3) {
880
909
  pwErrCount += 1
881
910
  } else { // delay brute force attempts
@@ -1008,7 +1037,6 @@ export class ScimGateway {
1008
1037
  }
1009
1038
  }
1010
1039
 
1011
- userObj = utilsScim.addPrimaryAttrs(userObj)
1012
1040
  scimdata = utils.stripObj(userObj, ctx.query.attributes, ctx.query.excludedAttributes)
1013
1041
  scimdata = utilsScim.addSchemas(scimdata, isScimv2, handle.description, undefined)
1014
1042
 
@@ -1259,7 +1287,6 @@ export class ScimGateway {
1259
1287
  if (this.config.scimgateway.scim.skipMetaLocation) location = undefined
1260
1288
  else if (ctx.query.attributes || (ctx.query.excludedAttributes && ctx.query.excludedAttributes.includes('meta'))) location = undefined
1261
1289
  for (let i = 0; i < scimdata.Resources.length; i++) {
1262
- scimdata.Resources[i] = utilsScim.addPrimaryAttrs(scimdata.Resources[i])
1263
1290
  scimdata.Resources[i] = utils.stripObj(scimdata.Resources[i], ctx.query.attributes, ctx.query.excludedAttributes)
1264
1291
  }
1265
1292
  scimdata = utilsScim.addResources(scimdata, ctx.query.startIndex, ctx.query.sortBy, ctx.query.sortOrder)
@@ -1440,7 +1467,6 @@ export class ScimGateway {
1440
1467
  ctx.response.headers.set('Location', location)
1441
1468
  }
1442
1469
  delete jsonBody.password
1443
- jsonBody = utilsScim.addPrimaryAttrs(jsonBody)
1444
1470
  jsonBody = utilsScim.addSchemas(jsonBody, isScimv2, handle.description, undefined)
1445
1471
  ctx.response.status = 201
1446
1472
  ctx.response.body = JSON.stringify(jsonBody)
@@ -1658,7 +1684,7 @@ export class ScimGateway {
1658
1684
  const location = ctx.origin + ctx.path
1659
1685
  ctx.response.headers.set('Location', location)
1660
1686
  }
1661
- const userObj = utilsScim.addPrimaryAttrs(scimdata.Resources[0])
1687
+ const userObj = scimdata.Resources[0]
1662
1688
  scimdata = utils.stripObj(userObj, ctx.query.attributes, ctx.query.excludedAttributes)
1663
1689
  scimdata = utilsScim.addSchemas(scimdata, isScimv2, handle.description, undefined)
1664
1690
  ctx.response.status = 200
@@ -2283,13 +2309,13 @@ export class ScimGateway {
2283
2309
  body = JSON.parse(bodyString)
2284
2310
  } catch (err: any) {
2285
2311
  const contentType = request.headers.get('content-type')
2286
- if (contentType && contentType.toLowerCase() === 'application/x-www-form-urlencoded') {
2312
+ if (contentType && contentType.toLowerCase().startsWith('application/x-www-form-urlencoded')) {
2287
2313
  const arr = bodyString.split('&') // "grant_type=client_credentials&client_id=id&client_secret=secret"
2288
2314
  body = {}
2289
2315
  arr.forEach((kv: string) => {
2290
2316
  const a = kv.split('=')
2291
2317
  if (a.length === 2) {
2292
- body[a[0]] = a[1]
2318
+ body[a[0]] = decodeURIComponent(a[1])
2293
2319
  }
2294
2320
  })
2295
2321
  } else if (bodyString) body = bodyString
package/lib/utils-scim.ts CHANGED
@@ -793,7 +793,19 @@ export function addSchemas(data: Record<string, any>, isScimv2: boolean, type?:
793
793
  } else if (key === 'password') delete data.Resources[i].password // exclude password, null and empty object/array
794
794
  else if (data.Resources[i][key] === null) delete data.Resources[i][key]
795
795
  else if (JSON.stringify(data.Resources[i][key]) === '{}') delete data.Resources[i][key]
796
- else if (Array.isArray(data.Resources[i][key]) && data.Resources[i][key].length < 1) delete data.Resources[i][key]
796
+ else if (Array.isArray(data.Resources[i][key])) {
797
+ if (data.Resources[i][key].length < 1) delete data.Resources[i][key]
798
+ else if (key !== 'members' && key !== 'groups') { // any primary attribute should be boolean
799
+ for (let j = 0; j < data.Resources[i][key].length; j++) {
800
+ let el = data.Resources[i][key][j]
801
+ if (typeof el !== 'object') break
802
+ if (el.type && el.primary && typeof el.primary === 'string') {
803
+ if (el.primary.toLowerCase() === 'true') el.primary = true
804
+ else if (el.primary.toLowerCase() === 'false') el.primary = false
805
+ }
806
+ }
807
+ }
808
+ }
797
809
  }
798
810
  if (Object.keys(data.Resources[i]).length === 0) {
799
811
  data.Resources.splice(i, 1) // delete
@@ -827,37 +839,25 @@ export function addSchemas(data: Record<string, any>, isScimv2: boolean, type?:
827
839
  } else if (key === 'password') delete data.password // exclude password, null and empty object/array
828
840
  else if (data[key] === null) delete data[key]
829
841
  else if (JSON.stringify(data[key]) === '{}') delete data[key]
830
- else if (Array.isArray(data[key]) && data[key].length < 1) delete data[key]
842
+ else if (Array.isArray(data[key])) {
843
+ if (data[key].length < 1) delete data[key]
844
+ else if (key !== 'members' && key !== 'groups') { // any primary attribute should be boolean
845
+ for (let j = 0; j < data[key].length; j++) {
846
+ let el = data[key][j]
847
+ if (typeof el !== 'object') break
848
+ if (el.type && el.primary && typeof el.primary === 'string') {
849
+ if (el.primary.toLowerCase() === 'true') el.primary = true
850
+ else if (el.primary.toLowerCase() === 'false') el.primary = false
851
+ }
852
+ }
853
+ }
854
+ }
831
855
  }
832
856
  }
833
857
 
834
858
  return data
835
859
  }
836
860
 
837
- // addPrimaryAttrs cheks for primary attributes (only for roles) and add them as standalone attributes
838
- // some IdP's may check for these e.g. Azure
839
- // e.g. {roles: [{value: "val1", primary: "True"}]}
840
- // gives:
841
- // { roles: [{value: "val1", primary: "True"}],
842
- // roles[primary eq "True"].value: "val1",
843
- // roles[primary eq "True"].primary: "True"}]
844
- // }
845
- export function addPrimaryAttrs(obj: Record<string, any>) {
846
- const key = 'roles'
847
- if (!obj || typeof obj !== 'object') return obj
848
- if (!obj[key] || !Array.isArray(obj[key])) return obj
849
- const o = utils.copyObj(obj)
850
- const index = o[key].findIndex((el: Record<string, any>) => (el.primary === true || (typeof el.primary === 'string' && el.primary.toLowerCase() === 'true')))
851
- if (index >= 0) {
852
- const prim = o[key][index]
853
- for (const k in prim) {
854
- const primKey = `${key}[primary eq ${typeof prim.primary === 'string' ? `"${prim.primary}"` : prim.primary}].${k}` // roles[primary eq true].value / roles[primary eq "True"].value``
855
- o[primKey] = prim[k] // { roles[primary eq true].value : "some-value" }
856
- }
857
- }
858
- return o
859
- }
860
-
861
861
  //
862
862
  // SCIM error formatting
863
863
  //
@@ -900,7 +900,7 @@ export function jsonErr(scimVersion: string | number, pluginName: string, htmlEr
900
900
 
901
901
  if (scimVersion !== '2.0' && scimVersion !== 2) { // v1.1
902
902
  errJson
903
- = {
903
+ = {
904
904
  Errors: [
905
905
  {
906
906
  description: msg,
@@ -910,7 +910,7 @@ export function jsonErr(scimVersion: string | number, pluginName: string, htmlEr
910
910
  }
911
911
  } else { // v2.0
912
912
  errJson
913
- = {
913
+ = {
914
914
  schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
915
915
  scimType,
916
916
  detail: msg,
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
- // mutual exclusion ref: https://thecodebarbarian.com/mutual-exclusion-patterns-with-node-promises
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: boolean
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.0.9",
3
+ "version": "5.0.11",
4
4
  "type": "module",
5
5
  "description": "Using SCIM protocol as a gateway for user provisioning to other endpoints",
6
6
  "author": "Jarle Elshaug <jarle.elshaug@gmail.com> (https://elshaug.xyz)",