scimgateway 6.1.8 → 6.1.10

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
@@ -1303,6 +1303,19 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1303
1303
 
1304
1304
  ## Change log
1305
1305
 
1306
+ ### v6.1.10
1307
+
1308
+ [Fixed]
1309
+
1310
+ - plugin-entra-id: user group membership now includes nested (transitive) groups (`direct` and `indirect`)
1311
+ - Docker example files `config/docker/.dockerignore` and `docker-compose-mssql.yml` were missing
1312
+
1313
+ ### v6.1.9
1314
+
1315
+ [Improved]
1316
+
1317
+ - Some improvements to createUser/createGroup regarding the response object, which should contain the newly generated ID
1318
+
1306
1319
  ### v6.1.8
1307
1320
 
1308
1321
  [Fixed]
@@ -34,8 +34,8 @@ export class HelperRest {
34
34
  this.scimgateway = scimgateway
35
35
  this.idleTimeout = (scimgateway as any)?.config?.scimgateway.idleTimeout || 120
36
36
  this.idleTimeout = this.idleTimeout - 1
37
- if (optionalEntities && optionalEntities.entity) this.config_entity = utils.copyObj(optionalEntities.entity) ?? {}
38
- else this.config_entity = utils.copyObj(scimgateway.getConfig())?.entity ?? {}
37
+ if (optionalEntities && optionalEntities.entity) this.config_entity = structuredClone(optionalEntities.entity) ?? {}
38
+ else this.config_entity = structuredClone(scimgateway.getConfig())?.entity ?? {}
39
39
 
40
40
  for (const baseEntity in this.config_entity) {
41
41
  const connectionObj = this.config_entity[baseEntity]?.connection
@@ -387,7 +387,7 @@ export class HelperRest {
387
387
  const method = 'POST'
388
388
  let connOpt: any = {}
389
389
  if (connectionObj.options && typeof connectionObj.options === 'object') {
390
- connOpt = utils.copyObj(connectionObj.options)
390
+ connOpt = structuredClone(connectionObj.options)
391
391
  }
392
392
  if (!connOpt.headers) connOpt.headers = {}
393
393
  connOpt.headers['Content-Type'] = 'application/x-www-form-urlencoded' // body must be query string formatted (no JSON)
@@ -494,7 +494,7 @@ export class HelperRest {
494
494
  let orgConnection: any
495
495
  if (opt?.connection) { // allow overriding/extending configuration connection by caller argument opt.connection
496
496
  let org = connectionObj
497
- orgConnection = utils.copyObj(org)
497
+ orgConnection = structuredClone(org)
498
498
  if (!org) org = {}
499
499
  org = utils.extendObj(org, opt.connection)
500
500
  }
@@ -533,7 +533,7 @@ export class HelperRest {
533
533
  }
534
534
 
535
535
  if (connectionObj.options) { // http connect options
536
- const connOpt: any = utils.copyObj(connectionObj.options)
536
+ const connOpt: any = structuredClone(connectionObj.options)
537
537
  try {
538
538
  // using fs.readFileSync().toString() instead of Bun.file().text() for nodejs compability
539
539
  if (connOpt?.tls?.key) connOpt.tls.key = fs.readFileSync(connOpt.tls.key).toString()
@@ -565,7 +565,7 @@ export class HelperRest {
565
565
  if (ctx?.headers?.get) { // Auth PassThrough using ctx header
566
566
  this._serviceClient[baseEntity].options.headers['Authorization'] = ctx.headers.get('authorization')
567
567
  }
568
- const cli: any = utils.copyObj(this._serviceClient[baseEntity]) // client ready
568
+ const cli: any = structuredClone(this._serviceClient[baseEntity]) // client ready
569
569
 
570
570
  // failover support
571
571
  path = this._serviceClient[baseEntity].baseUrl + path
@@ -611,7 +611,7 @@ export class HelperRest {
611
611
 
612
612
  // merge any argument options - basic auth header is supported through {auth:{type:"basic",options:{username:"username",password:"password"}}}
613
613
  if (opt) {
614
- const o: any = utils.copyObj(opt)
614
+ const o: any = structuredClone(opt)
615
615
  if (o?.auth?.type === 'basic') {
616
616
  options.headers['Authorization'] = 'Basic ' + Buffer.from(`${o.auth?.options?.username}:${o.auth?.options?.password}`).toString('base64')
617
617
  delete o.auth
@@ -57,8 +57,6 @@
57
57
  // Members members members
58
58
  // =====================================================================================================================
59
59
 
60
- import querystring from 'querystring'
61
-
62
60
  // start - mandatory plugin initialization
63
61
  import { ScimGateway, HelperRest } from 'scimgateway'
64
62
  const scimgateway = new ScimGateway()
@@ -67,6 +65,8 @@ const config = scimgateway.getConfig()
67
65
  scimgateway.authPassThroughAllowed = false
68
66
  // end - mandatory plugin initialization
69
67
 
68
+ const newHelper = new HelperRest(scimgateway)
69
+
70
70
  if (config.map) { // having licensDetails map here instead of config file
71
71
  config.map.licenseDetails = {
72
72
  servicePlanId: {
@@ -143,6 +143,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
143
143
  let options: Record<string, any> = {}
144
144
  let isExpandManager = true
145
145
 
146
+ if (Object.hasOwn(getObj, 'value')) getObj.value = encodeURIComponent(getObj.value)
146
147
  if (!Object.hasOwn(getObj, 'count')) getObj.count = 200
147
148
  if (getObj.count > 500) getObj.count = 500 // Entra ID max 999
148
149
 
@@ -266,7 +267,7 @@ scimgateway.createUser = async (baseEntity, userObj, ctx) => {
266
267
  const id = res?.body?.id || userObj.userName
267
268
  await scimgateway.modifyUser(baseEntity, id, addonObj, ctx) // manager, proxyAddresses, servicePlan
268
269
  }
269
- return null
270
+ return res?.body
270
271
  } catch (err: any) {
271
272
  const newErr = new Error(`${action} error: ${err.message}`)
272
273
  if (err.message.includes('userPrincipalName already exists')) newErr.name += '#409' // customErrCode
@@ -402,6 +403,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
402
403
  totalResults: null,
403
404
  }
404
405
 
406
+ if (Object.hasOwn(getObj, 'value')) getObj.value = encodeURIComponent(getObj.value)
405
407
  if (attributes.length === 0) attributes = groupAttributes
406
408
  let includeMembers = false
407
409
  if (attributes.includes('members.value') || attributes.includes('members')) {
@@ -413,6 +415,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
413
415
  const body = null
414
416
  let path
415
417
  let options: Record<string, any> = {}
418
+ let isUserMemberOf = getObj?.operator === 'eq' && getObj?.attribute === 'members.value'
416
419
 
417
420
  if (!Object.hasOwn(getObj, 'count')) getObj.count = 500
418
421
  if (getObj.count > 500) getObj.count = 500 // Entra ID max 999
@@ -428,10 +431,10 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
428
431
  if (includeMembers) path = `/groups?$filter=${getObj.attribute} eq '${getObj.value}'&$select=${attrs.join()}&$expand=members($select=id,displayName)`
429
432
  else path = `/groups?$filter=${getObj.attribute} eq '${getObj.value}'&$select=${attrs.join()}`
430
433
  }
431
- } else if (getObj.operator === 'eq' && getObj.attribute === 'members.value') {
434
+ } else if (isUserMemberOf) {
432
435
  // mandatory - return all groups the user 'id' (getObj.value) is member of - correspond to getGroupMembers() in versions < 4.x.x
433
436
  // Resources = [{ id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }]
434
- path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&select=id,displayName`
437
+ path = `/users/${getObj.value}/transitiveMemberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&select=id,displayName`
435
438
  } else {
436
439
  // optional - simpel filtering
437
440
  if (getObj.attribute) {
@@ -471,8 +474,69 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
471
474
  if (!ctx) ctx = { paging }
472
475
  else ctx.paging = paging
473
476
 
477
+ const newCtx = { ...ctx }
478
+ newCtx.paging = { startIndex: 1 }
479
+
474
480
  try {
475
- let response = await helper.doRequest(baseEntity, method, path, body, ctx, options)
481
+ let response: any
482
+ let responseMemberOf: any
483
+ if (!isUserMemberOf) response = await helper.doRequest(baseEntity, method, path, body, ctx, options)
484
+ else {
485
+ // request both the default transitiveMemberOf (includes nested groups) and memberOf because we want to distinguish SCIM type=direct/indirect
486
+ const pathMemberOf = `/users/${getObj.value}/memberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&select=id,displayName`
487
+ const allErrors: string[] = []
488
+ const results = await Promise.allSettled([
489
+ helper.doRequest(baseEntity, method, path, body, ctx, options),
490
+ newHelper.doRequest(baseEntity, method, pathMemberOf, body, newCtx, options), // using newHelper to avoid shared internal helperRest paging
491
+ ])
492
+ const errors = results
493
+ .filter(r => r.status === 'rejected')
494
+ .map(r => (r as PromiseRejectedResult).reason.message)
495
+ .filter(msg => !msg.includes('already exist'))
496
+ allErrors.push(...errors)
497
+
498
+ if (allErrors.length > 0) {
499
+ throw new Error(allErrors.join(', '))
500
+ }
501
+
502
+ response = (results[0] as PromiseFulfilledResult<any>).value // includes all groups (also nested)
503
+ responseMemberOf = (results[1] as PromiseFulfilledResult<any>).value // do not include nested groups
504
+
505
+ let nextStartIndex = scimgateway.getNextStartIndex(responseMemberOf.body.value.length * 2, newCtx.paging.startIndex, responseMemberOf.body.value.length)
506
+ if (nextStartIndex > newCtx.paging.startIndex && responseMemberOf && responseMemberOf.body.value && Array.isArray(responseMemberOf.body.value)) {
507
+ // use paging to ensure responseMemberOf is complete
508
+ let totalResults = responseMemberOf.body.value.length
509
+ let startIndex = 1
510
+ let res: any
511
+ do {
512
+ try {
513
+ startIndex = nextStartIndex
514
+ newCtx.paging.startIndex = startIndex
515
+ res = await newHelper.doRequest(baseEntity, method, pathMemberOf, body, newCtx, options)
516
+ } catch (err) { void 0 }
517
+ if (res?.body && res.body.value && Array.isArray(res.body.value) && res.body.value.length > 0) {
518
+ const count = res.body.value.length
519
+ totalResults += count
520
+ nextStartIndex = scimgateway.getNextStartIndex(totalResults + count, startIndex, count)
521
+ for (let i = 0; i < res.body.value.length; i++) {
522
+ if (!res.body.value[i].id) continue
523
+ responseMemberOf.body.value.push(res.body.value[i])
524
+ }
525
+ }
526
+ } while (nextStartIndex > startIndex)
527
+ }
528
+
529
+ if (response.body && response.body.value && Array.isArray(response.body.value)) {
530
+ const directIds = new Set()
531
+ if (responseMemberOf.body && responseMemberOf.body.value && Array.isArray(responseMemberOf.body.value)) {
532
+ responseMemberOf.body.value.forEach((el: any) => directIds.add(el.id))
533
+ }
534
+ response.body.value.forEach((el: any) => {
535
+ if (directIds.has(el.id)) el.type = 'direct'
536
+ else el.type = 'indirect'
537
+ })
538
+ }
539
+ }
476
540
  if (!response.body) {
477
541
  throw new Error(`invalid response: ${JSON.stringify(response)}`)
478
542
  }
@@ -494,6 +558,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
494
558
  } else if (getObj.operator === 'eq' && getObj.attribute === 'members.value') { // Not using expand-members. Only includes current user as member, but should have requested all...
495
559
  members = [{
496
560
  value: getObj.value,
561
+ type: response.body.value[i].type || 'direct',
497
562
  }]
498
563
  }
499
564
 
@@ -525,7 +590,7 @@ scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
525
590
  scimgateway.logDebug(baseEntity, `handling ${action} groupObj=${JSON.stringify(groupObj)} passThrough=${ctx ? 'true' : 'false'}`)
526
591
 
527
592
  const body: any = { displayName: groupObj.displayName }
528
- body.mailNickName = groupObj.displayName
593
+ body.mailNickName = groupObj.displayName?.replace(/[^a-zA-Z0-9]/g, '')
529
594
  body.mailEnabled = false
530
595
  body.securityEnabled = true
531
596
  const method = 'POST'
@@ -536,8 +601,8 @@ scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
536
601
  if (res && res.Resources && res.Resources.length > 0) {
537
602
  throw new Error(`group ${groupObj.displayName} already exist`)
538
603
  }
539
- await helper.doRequest(baseEntity, method, path, body, ctx)
540
- return null
604
+ const response = await helper.doRequest(baseEntity, method, path, body, ctx)
605
+ return response?.body
541
606
  } catch (err: any) {
542
607
  const newErr = new Error(`${action} error: ${err.message}`)
543
608
  if (err.message.includes('already exist')) newErr.name += '#409' // customErrCode
@@ -743,7 +808,7 @@ const getUser = async (baseEntity: string, uid: string, attributes: string[], ct
743
808
 
744
809
  const userPromise = (async () => {
745
810
  const method = 'GET'
746
- const path = `/users/${querystring.escape(uid)}?$expand=manager($select=userPrincipalName)`
811
+ const path = `/users/${uid}?$expand=manager($select=userPrincipalName)`
747
812
  const body = null
748
813
  const response = await helper.doRequest(baseEntity, method, path, body, ctx)
749
814
  const userObj = response.body
@@ -761,7 +826,7 @@ const getUser = async (baseEntity: string, uid: string, attributes: string[], ct
761
826
  const licensePromise = (async () => {
762
827
  if (!attributes.includes('servicePlans.value')) return null // licenses not requested
763
828
  const method = 'GET'
764
- const path = `/users/${querystring.escape(uid)}/licenseDetails`
829
+ const path = `/users/${uid}/licenseDetails`
765
830
  const body = null
766
831
  const retObj: Record<string, any> = { servicePlan: [] }
767
832
  try {
@@ -53,10 +53,12 @@ if (!fsExistsSync('../../config/wsdls/UserService.wsdl')) {
53
53
  fs.writeFileSync('../../config/wsdls/UserService.wsdl', fs.readFileSync('./config/wsdls/UserService.wsdl'))
54
54
  }
55
55
 
56
- fs.writeFileSync('../../config/docker/docker-compose.yml', fs.readFileSync('./config/docker/docker-compose.yml'))
57
- fs.writeFileSync('../../config/docker/Dockerfile', fs.readFileSync('./config/docker/Dockerfile'))
56
+ fs.writeFileSync('../../config/docker/.dockerignore', fs.readFileSync('./config/docker/.dockerignore'))
58
57
  fs.writeFileSync('../../config/docker/DataDockerfile', fs.readFileSync('./config/docker/DataDockerfile'))
59
58
  fs.writeFileSync('../../config/docker/docker-compose-debug.yml', fs.readFileSync('./config/docker/docker-compose-debug.yml'))
59
+ fs.writeFileSync('../../config/docker/docker-compose-mssql.yml', fs.readFileSync('./config/docker/docker-compose-mssql.yml'))
60
+ fs.writeFileSync('../../config/docker/docker-compose.yml', fs.readFileSync('./config/docker/docker-compose.yml'))
61
+ fs.writeFileSync('../../config/docker/Dockerfile', fs.readFileSync('./config/docker/Dockerfile'))
60
62
 
61
63
  fs.writeFileSync('../../LICENSE', fs.readFileSync('./LICENSE'))
62
64
  if (!fsExistsSync('../../index.ts')) fs.writeFileSync('../../index.ts', fs.readFileSync('./index.ts'))
@@ -757,7 +757,7 @@ export class ScimGateway {
757
757
  }
758
758
  if (utils.getEncrypted(authToken, arr[i].clientSecret) === arr[i].clientSecret) {
759
759
  arr[i].isTokenRequested = true // flagged as true to not allow repeated resolvements because token will also be cleared when expired
760
- const baseEntities = utils.copyObj(arr[i].baseEntities)
760
+ const baseEntities = structuredClone(arr[i].baseEntities)
761
761
  let expires
762
762
  let readOnly = false
763
763
  if (arr[i].readOnly && arr[i].readOnly === true) readOnly = true
@@ -857,7 +857,7 @@ export class ScimGateway {
857
857
  }
858
858
 
859
859
  const getHandlerSchemas = async (ctx: Context) => {
860
- let tx = utils.copyObj(this.scimDef.Schemas)
860
+ let tx = structuredClone(this.scimDef.Schemas)
861
861
  if (this.config.endpoint?.map) {
862
862
  // endpointMapper being used
863
863
  // Schemas returned should instead reflect what is defined in the plugin config file
@@ -891,7 +891,7 @@ export class ScimGateway {
891
891
  uniqueness: (item.mapTo === 'userName') ? 'server' : 'none',
892
892
  }
893
893
  if (item['x-agent-schema']) {
894
- const agentSchema = utils.copyObj(item['x-agent-schema'])
894
+ const agentSchema = structuredClone(item['x-agent-schema'])
895
895
  if (agentSchema.description) {
896
896
  attr.description = agentSchema.description
897
897
  delete agentSchema.description
@@ -909,7 +909,7 @@ export class ScimGateway {
909
909
  if (names.length > 1) {
910
910
  const userNameFound = attr.name.includes('userName')
911
911
  for (let i = 0; i < names.length; i++) {
912
- let attrCopy = utils.copyObj(attr)
912
+ let attrCopy = structuredClone(attr)
913
913
  const name = names[i].trim()
914
914
  attrCopy.name = name
915
915
  if (name === 'id') continue
@@ -959,7 +959,7 @@ export class ScimGateway {
959
959
  delete subAttr.uniqueness
960
960
  }
961
961
  if (item['x-agent-schema']) {
962
- const hints = utils.copyObj(item['x-agent-schema'])
962
+ const hints = structuredClone(item['x-agent-schema'])
963
963
  if (hints.description) {
964
964
  subAttr.description = hints.description
965
965
  delete hints.description
@@ -1147,7 +1147,7 @@ export class ScimGateway {
1147
1147
  ctx.request.body = body // now json - ensure final info log will be masked
1148
1148
  jsonBody = body
1149
1149
  }
1150
- jsonBody = utils.copyObj(jsonBody) // no changes to original
1150
+ jsonBody = structuredClone(jsonBody) // no changes to original
1151
1151
  } catch (err: any) {
1152
1152
  logger.error(`${gwName} [oauth] token request error: ${err.message}`)
1153
1153
  ctx.response.status = 401
@@ -1184,7 +1184,7 @@ export class ScimGateway {
1184
1184
  if (!arr[i].baseEntities.includes(baseEntity)) continue
1185
1185
  }
1186
1186
  token = utils.getEncrypted(jsonBody.client_secret, jsonBody.client_secret)
1187
- baseEntities = utils.copyObj(arr[i].baseEntities)
1187
+ baseEntities = structuredClone(arr[i].baseEntities)
1188
1188
  if (arr[i].readOnly && arr[i].readOnly === true) readOnly = true
1189
1189
  if (arr[i].expires_in && !isNaN(arr[i].expires_in)) expires = arr[i].expires_in
1190
1190
  else expires = oAuthTokenExpire
@@ -1291,7 +1291,7 @@ export class ScimGateway {
1291
1291
  logger.debug(`${gwName} [Get ${handle.description}] ${getObj.attribute}=${getObj.value}`, { baseEntity: ctx?.routeObj?.baseEntity })
1292
1292
 
1293
1293
  try {
1294
- const ob = utils.copyObj(getObj)
1294
+ const ob = structuredClone(getObj)
1295
1295
  const attributes: string[] = ctx.query.attributes ? ctx.query.attributes.split(',').map((item: string) => item.trim()) : []
1296
1296
  if (attributes.length > 0 && !attributes.includes('id')) attributes.push('id')
1297
1297
  logger.debug(`${gwName} calling ${handle.getMethod}`, { baseEntity: ctx?.routeObj?.baseEntity })
@@ -1523,7 +1523,7 @@ export class ScimGateway {
1523
1523
  getObj.count = ctx.query.count ? parseInt(ctx.query.count, 10) : 200 // defaults to 200 (plugin may override)
1524
1524
 
1525
1525
  let res: any
1526
- const obj: any = utils.copyObj(getObj)
1526
+ const obj: any = structuredClone(getObj)
1527
1527
  const attributes: string[] = ctx.query.attributes ? ctx.query.attributes.split(',').map((item: string) => item.trim()) : []
1528
1528
  if (attributes.length > 0 && !attributes.includes('id')) attributes.push('id') // id is mandatory
1529
1529
 
@@ -1659,7 +1659,7 @@ export class ScimGateway {
1659
1659
  try {
1660
1660
  if (!jsonBody) throw new Error('missing body')
1661
1661
  if (typeof jsonBody !== 'object' || jsonBody === null) throw new Error('body is not JSON')
1662
- jsonBody = utils.copyObj(jsonBody) // no changes to original
1662
+ jsonBody = structuredClone(jsonBody) // no changes to original
1663
1663
  } catch (err: any) {
1664
1664
  const [e, statusCode] = utilsScim.jsonErr(this.config.scimgateway.scim.version, pluginName, 500, err)
1665
1665
  ctx.response.status = statusCode
@@ -1707,33 +1707,45 @@ export class ScimGateway {
1707
1707
  }
1708
1708
  }
1709
1709
  logger.debug(`${gwName} calling ${handle.createMethod}`, { baseEntity: ctx?.routeObj?.baseEntity })
1710
- const res = await (this as any)[handle.createMethod](baseEntity, scimdata, ctx.passThrough)
1711
- for (const key in res) { // merge any result e.g: {'id': 'xxxx'}
1712
- jsonBody[key] = res[key]
1713
- }
1710
+ const response = await (this as any)[handle.createMethod](baseEntity, scimdata, ctx.passThrough)
1714
1711
 
1715
- if (!jsonBody.id) { // retrieve all attributes including id
1716
- let res: any
1717
- let obj: any
1718
- try {
1719
- if (handle.createMethod === 'createUser') {
1720
- const attributes: string[] = []
1721
- if (jsonBody.userName) obj = { attribute: 'userName', operator: 'eq', value: jsonBody.userName }
1722
- else if (jsonBody.externalId) obj = { attribute: 'externalId', operator: 'eq', value: jsonBody.externalId }
1723
- res = await (this as any)[handle.getMethod](baseEntity, obj, attributes, ctx.passThrough)
1724
- } else if (handle.createMethod === 'createGroup') {
1725
- const attributes: string[] = []
1726
- if (jsonBody.externalId) obj = { attribute: 'externalId', operator: 'eq', value: jsonBody.externalId }
1727
- else if (jsonBody.displayName) obj = { attribute: 'displayName', operator: 'eq', value: jsonBody.displayName }
1712
+ // lookup user/group created, id should be included in response
1713
+ let res: any
1714
+ let obj: any
1715
+ try {
1716
+ if (handle.createMethod === 'createUser') {
1717
+ const attributes: string[] = []
1718
+ if (response?.id) obj = { attribute: 'id', operator: 'eq', value: response.id }
1719
+ else if (jsonBody.userName) obj = { attribute: 'userName', operator: 'eq', value: jsonBody.userName }
1720
+ else if (jsonBody.externalId) obj = { attribute: 'externalId', operator: 'eq', value: jsonBody.externalId }
1721
+ res = await (this as any)[handle.getMethod](baseEntity, obj, attributes, ctx.passThrough)
1722
+ } else if (handle.createMethod === 'createGroup') {
1723
+ const attributes: string[] = []
1724
+ if (response?.id) obj = { attribute: 'id', operator: 'eq', value: response.id }
1725
+ else if (jsonBody.displayName) obj = { attribute: 'displayName', operator: 'eq', value: jsonBody.displayName }
1726
+ else if (jsonBody.externalId) obj = { attribute: 'externalId', operator: 'eq', value: jsonBody.externalId }
1727
+ if (response?.id && response['@odata.context']?.includes('graph.microsoft.com')) {
1728
+ // Entra ID may experience some latency before a newly created group can be looked up
1729
+ let counter = 0
1730
+ const maxCounter = 20
1731
+ while (true) {
1732
+ counter++
1733
+ if (counter > maxCounter) break
1734
+ res = await (this as any)[handle.getMethod](baseEntity, obj, attributes, ctx.passThrough)
1735
+ if (res?.Resources && Array.isArray(res.Resources) && res.Resources.length === 1) break
1736
+ await new Promise(resolve => setTimeout(resolve, 1000))
1737
+ }
1738
+ } else {
1728
1739
  res = await (this as any)[handle.getMethod](baseEntity, obj, attributes, ctx.passThrough)
1729
1740
  }
1730
- } catch (err: any) {
1731
- logger.warn(`${gwName} ${handle.createMethod} succeeded, but corresponding ${handle.getMethod} ${obj?.value} failed with error: ${err.message}`, { baseEntity: ctx?.routeObj?.baseEntity })
1732
- }
1733
- if (res?.Resources && Array.isArray(res.Resources) && res.Resources.length === 1) {
1734
- if (res.Resources[0]?.id) jsonBody = res.Resources[0] // id found, using returned object
1735
1741
  }
1742
+ } catch (err: any) {
1743
+ logger.warn(`${gwName} ${handle.createMethod} succeeded, but corresponding ${handle.getMethod} ${obj?.value} failed with error: ${err.message}`, { baseEntity: ctx?.routeObj?.baseEntity })
1744
+ }
1745
+ if (res?.Resources && Array.isArray(res.Resources) && res.Resources.length === 1) {
1746
+ jsonBody = res.Resources[0]
1736
1747
  }
1748
+ delete jsonBody.password
1737
1749
 
1738
1750
  const eTag = utils.getEtag(jsonBody)
1739
1751
  if (addGrps.length > 0 && handle.createMethod === 'createUser') { // add group membership
@@ -1966,7 +1978,7 @@ export class ScimGateway {
1966
1978
  if (!this.config.scimgateway.scim.groupMemberOfUser) {
1967
1979
  for (let i = 0; i < scimdata.groups.length; i++) {
1968
1980
  if (!scimdata.groups[i].value) continue
1969
- const obj: any = utils.copyObj(scimdata.groups[i])
1981
+ const obj: any = structuredClone(scimdata.groups[i])
1970
1982
  obj.value = decodeURIComponent(obj.value)
1971
1983
  groups.push(obj)
1972
1984
  }
@@ -1979,7 +1991,7 @@ export class ScimGateway {
1979
1991
  res = await replaceUsrGrp(ctx.routeObj.handle, baseEntity, id, scimdata, this.config.scimgateway.scim.usePutSoftSync, ctx.passThrough, undefined)
1980
1992
  } else {
1981
1993
  logger.debug(`${gwName} calling ${handle.modifyMethod}`, { baseEntity: ctx?.routeObj?.baseEntity })
1982
- finalScimdata = utils.copyObj(scimdata)
1994
+ finalScimdata = structuredClone(scimdata)
1983
1995
  res = await (this as any)[handle.modifyMethod](baseEntity, id, scimdata, ctx.passThrough)
1984
1996
  }
1985
1997
 
@@ -2090,11 +2102,11 @@ export class ScimGateway {
2090
2102
  }
2091
2103
  }
2092
2104
 
2093
- const activeExists = Object.prototype.hasOwnProperty.call(obj, 'active')
2105
+ const activeExists = Object.hasOwn(obj, 'active')
2094
2106
  let objGroups: any
2095
2107
  if (obj.groups) {
2096
2108
  if (!this.config.scimgateway.scim.groupMemberOfUser) {
2097
- objGroups = utils.copyObj(obj.groups)
2109
+ objGroups = structuredClone(obj.groups)
2098
2110
  delete obj.groups
2099
2111
  }
2100
2112
  }
@@ -2270,7 +2282,7 @@ export class ScimGateway {
2270
2282
  const postBulkHandler = async (ctx: Context) => {
2271
2283
  const baseEntity = ctx.routeObj.baseEntity
2272
2284
  logger.debug(`${gwName} [Bulk Operations]`, { baseEntity: ctx?.routeObj?.baseEntity })
2273
- const bulkBody: SCIMBulkRequest = utils.copyObj(ctx.request.body)
2285
+ const bulkBody: SCIMBulkRequest = structuredClone(ctx.request.body)
2274
2286
  try {
2275
2287
  if (!bulkBody) throw new Error('missing body')
2276
2288
  if (typeof bulkBody !== 'object') throw new Error('body is not JSON')
@@ -2746,9 +2758,11 @@ export class ScimGateway {
2746
2758
  if (!res.Resources[i].id) continue
2747
2759
  const el: any = {}
2748
2760
  el.value = res.Resources[i].id
2761
+ const type = (Array.isArray(res.Resources[i].members) && res.Resources[i].members[0]?.type === 'indirect') ? 'indirect' : 'direct'
2762
+ el.type = { value: type }
2749
2763
  if (res.Resources[i].displayName) el.display = res.Resources[i].displayName
2750
- if (isScimv2) el.type = 'direct'
2751
- else el.type = { value: 'direct' }
2764
+ if (isScimv2) el.type = type
2765
+ else el.type = { value: type }
2752
2766
  groups.push(el) // { "value": "Admins", "display": "Admins", "type": "direct"}
2753
2767
  }
2754
2768
  nextStartIndex = utilsScim.getNextStartIndex(res.totalResults, startIndex, res.Resources.length)
@@ -3617,7 +3631,7 @@ export class ScimGateway {
3617
3631
  logger.info(`${gwName} starting SCIM Stream subscribers...`)
3618
3632
  const sub: any = new stream.Subscriber(this, funcHandler)
3619
3633
  for (const baseEntity in this.config.scimgateway.stream.subscriber.entity) {
3620
- const cfgSub: any = utils.copyObj(this.config.scimgateway.stream.subscriber.entity[baseEntity])
3634
+ const cfgSub: any = structuredClone(this.config.scimgateway.stream.subscriber.entity[baseEntity])
3621
3635
  cfgSub.baseUrls = this.config.scimgateway.stream.baseUrls
3622
3636
  cfgSub.certificate = this.config.scimgateway.stream.certificate
3623
3637
  cfgSub.usePutSoftSync = this.config.scimgateway.scim.usePutSoftSync
@@ -3631,7 +3645,7 @@ export class ScimGateway {
3631
3645
  logger.info(`${gwName} starting SCIM Stream publishers...`)
3632
3646
  const pub: any = new stream.Publisher(this)
3633
3647
  for (const baseEntity in this.config.scimgateway.stream.publisher.entity) {
3634
- const cfgPub: any = utils.copyObj(this.config.scimgateway.stream.publisher.entity[baseEntity])
3648
+ const cfgPub: any = structuredClone(this.config.scimgateway.stream.publisher.entity[baseEntity])
3635
3649
  cfgPub.baseUrls = this.config.scimgateway.stream.baseUrls
3636
3650
  cfgPub.certificate = this.config.scimgateway.stream.certificate
3637
3651
  pub.add(baseEntity, cfgPub)
@@ -3783,6 +3797,7 @@ export class ScimGateway {
3783
3797
 
3784
3798
  /**
3785
3799
  * copyObj returns a copy of the object
3800
+ * Note: prefer using structuredClone(obj)
3786
3801
  * @param obj object to be copied
3787
3802
  * @returns copy of object
3788
3803
  **/
@@ -3800,6 +3815,17 @@ export class ScimGateway {
3800
3815
  return utils.extendObj(obj, src)
3801
3816
  }
3802
3817
 
3818
+ /**
3819
+ * getNextStartIndex returns the next SCIM pagination startIndex based on current result set
3820
+ * @param totalResults current totalResults
3821
+ * @param startIndex: current startIndex
3822
+ * @param count: current count
3823
+ * @returns next startIndex
3824
+ **/
3825
+ getNextStartIndex(totalResults: number, startIndex: number, itemsPerPage: number): number {
3826
+ return utilsScim.getNextStartIndex(totalResults, startIndex, itemsPerPage)
3827
+ }
3828
+
3803
3829
  /**
3804
3830
  * Lock for mutual exclusion
3805
3831
  * - const lock = new scimgateway.Lock()
package/lib/utils-scim.ts CHANGED
@@ -26,7 +26,7 @@ type SCIMBulkOperation = {
26
26
  */
27
27
  export function convertedScim(obj: any, multiValueTypes: string[]): any {
28
28
  let err: any = null
29
- const scimdata: any = utils.copyObj(obj)
29
+ const scimdata: any = structuredClone(obj)
30
30
  if (scimdata.schemas) delete scimdata.schemas
31
31
  const newMulti: Record<string, any> = {}
32
32
  if (!multiValueTypes) multiValueTypes = []
@@ -156,7 +156,7 @@ export function convertedScim(obj: any, multiValueTypes: string[]): any {
156
156
  export function convertedScim20(obj: any, multiValueTypes: string[]): any {
157
157
  if (!obj.Operations || !Array.isArray(obj.Operations)) return {}
158
158
  let scimdata: { [key: string]: any } = { meta: { attributes: [] } } // meta is used for deleted attributes
159
- const o: any = utils.copyObj(obj)
159
+ const o: any = structuredClone(obj)
160
160
  const arrPrimaryDone: any = []
161
161
  const primaryOrgType: any = {}
162
162
 
@@ -199,7 +199,7 @@ export function convertedScim20(obj: any, multiValueTypes: string[]): any {
199
199
  typeElement = arrMatches[3] // streetAddress
200
200
 
201
201
  if (type === 'primary' && !arrPrimaryDone.includes(arrMatches[1])) { // make sure primary is included
202
- const pObj: any = utils.copyObj(element)
202
+ const pObj: any = structuredClone(element)
203
203
  pObj.path = pObj.path.substring(0, pObj.path.lastIndexOf('.')) + '.primary'
204
204
  pObj.value = primaryValue
205
205
  o.Operations.push(pObj)
@@ -329,7 +329,7 @@ export function convertedScim20(obj: any, multiValueTypes: string[]): any {
329
329
  scimdata.meta.attributes.push(`${key}:${_k}`)
330
330
  delete value[_k]
331
331
  } else if (typeof value[_k] === 'object') { // manager.value
332
- if (Object.prototype.hasOwnProperty.call(value[_k], 'value') && (value[_k].value === undefined || value[_k].value === null)) {
332
+ if (Object.hasOwn(value[_k], 'value') && (value[_k].value === undefined || value[_k].value === null)) {
333
333
  scimdata.meta.attributes.push(`${key}:${_k}.value`)
334
334
  delete value[_k].value
335
335
  }
package/lib/utils.ts CHANGED
@@ -170,7 +170,7 @@ export const JSONStringify = function (object: any) {
170
170
  }
171
171
 
172
172
  const objProp = function (obj: Record<string, any>, prop: string, val: any) { // return obj value based on json dot notation formatted prop
173
- if (Object.prototype.hasOwnProperty.call(obj, prop)) return obj[prop]
173
+ if (Object.hasOwn(obj, prop)) return obj[prop]
174
174
  const props = prop.split('.') // scimgateway.auth.basic[0].password
175
175
  const final = props.pop() as string
176
176
  for (let i = 0; i < props.length; i++) {
@@ -192,7 +192,8 @@ export const copyObj = (o: any): any => { // deep copy/clone faster than JSON.pa
192
192
  if (typeof v === 'object' && v !== null) {
193
193
  const objProp = Object.getPrototypeOf(v) // e.g. HttpsProxyAgent {}
194
194
  if (objProp !== null && objProp !== Object.getPrototypeOf({}) && objProp !== Object.getPrototypeOf([])) {
195
- output[key] = Object.assign(Object.create(v), v) // e.g. { HttpsProxyAgent {...} }
195
+ const proto = Object.getPrototypeOf(v)
196
+ output[key] = Object.assign(Object.create(proto), v) // e.g. { HttpsProxyAgent {...} }
196
197
  } else output[key] = copyObj(v)
197
198
  } else output[key] = v
198
199
  }
@@ -210,7 +211,7 @@ const _extendObj = (obj: Record<any, any>, src: Record<any, any>) => {
210
211
  for (let i = 0; i < src[key].length; i++) {
211
212
  const val = src[key][i]
212
213
  if (typeof val === 'object') {
213
- if (Object.prototype.hasOwnProperty.call(val, 'type')) {
214
+ if (Object.hasOwn(val, 'type')) {
214
215
  if (!obj[key]) obj[key] = [val]
215
216
  else {
216
217
  for (let j = 0; j < obj[key].length; j++) {
@@ -223,7 +224,7 @@ const _extendObj = (obj: Record<any, any>, src: Record<any, any>) => {
223
224
  }
224
225
  obj[key].push(val)
225
226
  }
226
- } else if (Object.prototype.hasOwnProperty.call(val, 'value')) {
227
+ } else if (Object.hasOwn(val, 'value')) {
227
228
  if (!obj[key]) obj[key] = [val]
228
229
  else {
229
230
  for (let j = 0; j < obj[key].length; j++) {
@@ -258,7 +259,7 @@ export const extendObjClear = (obj: Record<string, any>, src: Record<string, any
258
259
  Object.keys(src).forEach((key) => {
259
260
  if (src[key] === null) return
260
261
  if (typeof src[key] !== 'object') { // last key
261
- if (Object.prototype.hasOwnProperty.call(obj, key)) return
262
+ if (Object.hasOwn(obj, key)) return
262
263
  if (isSoftSync) obj[key] = src[key]
263
264
  else {
264
265
  switch (typeof src[key]) {
@@ -288,7 +289,7 @@ export const extendObjClear = (obj: Record<string, any>, src: Record<string, any
288
289
  if (typeof val !== 'object') {
289
290
  if (!obj[key].includes(val)) obj[key].push(val) // e.g. ["value1", "value2"]
290
291
  } else {
291
- if (Object.prototype.hasOwnProperty.call(val, 'type') && key !== 'members' && key !== 'groups') {
292
+ if (Object.hasOwn(val, 'type') && key !== 'members' && key !== 'groups') {
292
293
  if (obj[key].length < 1) {
293
294
  const v: any = copyObj(val)
294
295
  if (!isSoftSync) v.operation = 'delete'
@@ -301,7 +302,7 @@ export const extendObjClear = (obj: Record<string, any>, src: Record<string, any
301
302
  found = true
302
303
  for (const kv in val) {
303
304
  if (kv === 'type' || kv === 'value' || isSoftSync) continue // don't clear type/value
304
- if (Object.prototype.hasOwnProperty.call(el, kv)) continue
305
+ if (Object.hasOwn(el, kv)) continue
305
306
  switch (typeof val[kv]) {
306
307
  case 'string':
307
308
  el[kv] = ''
@@ -324,7 +325,7 @@ export const extendObjClear = (obj: Record<string, any>, src: Record<string, any
324
325
  obj[key].push(v)
325
326
  }
326
327
  }
327
- } else if (Object.prototype.hasOwnProperty.call(val, 'value')) { // no type
328
+ } else if (Object.hasOwn(val, 'value')) { // no type
328
329
  if (obj[key].length < 1) {
329
330
  const v: any = copyObj(val)
330
331
  if (!isSoftSync) v.operation = 'delete'
@@ -370,7 +371,7 @@ export const deltaObj = (obj: Record<string, any>, src: Record<string, any>) =>
370
371
  const el = arr[i]
371
372
  if (el.operation) continue // keep operation
372
373
  if (el.type) {
373
- if (Object.prototype.hasOwnProperty.call(el, 'value')) {
374
+ if (Object.hasOwn(el, 'value')) {
374
375
  const a = src[key].filter(o => o.type === el.type && o.value === el.value)
375
376
  if (a.length === 1) {
376
377
  arr.splice(i, 1)
@@ -388,7 +389,7 @@ export const deltaObj = (obj: Record<string, any>, src: Record<string, any>) =>
388
389
  i -= 1
389
390
  }
390
391
  }
391
- } else if (Object.prototype.hasOwnProperty.call(el, 'value')) {
392
+ } else if (Object.hasOwn(el, 'value')) {
392
393
  const a = src[key].filter(o => o.value === el.value)
393
394
  if (a.length === 1) {
394
395
  arr.splice(i, 1)
@@ -441,9 +442,9 @@ export const stripObj = (obj: Record<string, any>, attributes?: string, excluded
441
442
  const ret: Record<string, any> = {}
442
443
  for (let i = 0; i < arrAttr.length; i++) {
443
444
  const attr = arrAttr[i].split('.') // title / name.familyName / emails.value
444
- if (Object.prototype.hasOwnProperty.call(obj, attr[0])) {
445
+ if (Object.hasOwn(obj, attr[0])) {
445
446
  if (attr.length === 1) ret[attr[0]] = obj[attr[0]]
446
- else if (Object.prototype.hasOwnProperty.call(obj[attr[0]], attr[1])) { // name.familyName
447
+ else if (Object.hasOwn(obj[attr[0]], attr[1])) { // name.familyName
447
448
  if (!ret[attr[0]]) ret[attr[0]] = {}
448
449
  ret[attr[0]][attr[1]] = obj[attr[0]][attr[1]]
449
450
  } else if (Array.isArray(obj[attr[0]])) { // emails.value / phoneNumbers.type
@@ -452,7 +453,7 @@ export const stripObj = (obj: Record<string, any>, attributes?: string, excluded
452
453
  for (let j = 0; j < arr.length; j++) {
453
454
  if (typeof arr[j] !== 'object') {
454
455
  ret[attr[0]].push(arr[j])
455
- } else if (Object.prototype.hasOwnProperty.call(arr[j], attr[1])) {
456
+ } else if (Object.hasOwn(arr[j], attr[1])) {
456
457
  if (ret[attr[0]].length !== arr.length) { // initiate
457
458
  for (let i = 0; i < arr.length; i++) ret[attr[0]].push({}) // need arrCheckEmpty
458
459
  }
@@ -481,14 +482,14 @@ export const stripObj = (obj: Record<string, any>, attributes?: string, excluded
481
482
  const ret: any = copyObj(obj)
482
483
  for (let i = 0; i < arrAttr.length; i++) {
483
484
  const attr = arrAttr[i].split('.') // title / name.familyName / emails.value
484
- if (Object.prototype.hasOwnProperty.call(ret, attr[0])) {
485
+ if (Object.hasOwn(ret, attr[0])) {
485
486
  if (attr.length === 1) delete ret[attr[0]]
486
- else if (Object.prototype.hasOwnProperty.call(ret[attr[0]], attr[1])) delete ret[attr[0]][attr[1]] // name.familyName
487
+ else if (Object.hasOwn(ret[attr[0]], attr[1])) delete ret[attr[0]][attr[1]] // name.familyName
487
488
  else if (Array.isArray(ret[attr[0]])) { // emails.value / phoneNumbers.type
488
489
  const arr = ret[attr[0]]
489
490
  for (let j = 0; j < arr.length; j++) {
490
- if (Object.prototype.hasOwnProperty.call(arr[j], attr[1])) {
491
- const index = arr.findIndex((el: Record<string, any>) => ((Object.prototype.hasOwnProperty.call(el, attr[1]))))
491
+ if (Object.hasOwn(arr[j], attr[1])) {
492
+ const index = arr.findIndex((el: Record<string, any>) => ((Object.hasOwn(el, attr[1]))))
492
493
  if (index > -1) {
493
494
  delete arr[index][attr[1]]
494
495
  try {
@@ -516,7 +517,7 @@ export const sortByKey = (key: string, order: string = 'ascending') => {
516
517
  const val: any = [undefined, undefined]
517
518
  const arrIter = [a, b]
518
519
  const levels = key.split('.')
519
- if (!Object.prototype.hasOwnProperty.call(a, levels[0]) || !Object.prototype.hasOwnProperty.call(b, levels[0])) return 0
520
+ if (!Object.hasOwn(a, levels[0]) || !Object.hasOwn(b, levels[0])) return 0
520
521
  arrIter.forEach((el, index) => {
521
522
  let parent = el
522
523
  for (let i = 0; i < levels.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "6.1.8",
3
+ "version": "6.1.10",
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)",