scimgateway 6.1.3 → 6.1.4

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
@@ -18,7 +18,7 @@
18
18
  Latest news:
19
19
 
20
20
  - Bun binary build is now supported, allowing SCIM Gateway to be compiled into a single executable binary for simplified deployment and execution. SCIM Gateway can now run as an ES module (TypeScript) in Node.js.
21
- - Major release **v6.0.0** introduces changes to API method response bodies (not SCIM-related) and a new method `publicApi()` for handling public path `/pub/api` requests with no authentication required. In addition, the configuration option `bearerJwtAzure.tenantIdGUID` has been replaced by `bearerJwt.azureTenantId`. See the version history for details.
21
+ - Major release **v6.0.0** introduces changes to API method responses (not SCIM-related) and a new method `publicApi()` for handling public path `/pub/api` requests with no authentication required. In addition, the configuration option `bearerJwtAzure.tenantIdGUID` has been replaced by `bearerJwt.azureTenantId`. See the version history for details.
22
22
  - Support for Entra ID [Federated Identity Credentials](https://learn.microsoft.com/en-us/graph/api/resources/federatedidentitycredentials-overview?view=graph-rest-1.0) has been added through internal JWKS (JSON Web Key Set), allowing SCIM Gateway to access Microsoft Entra–protected resources without the need to manage secrets
23
23
  - External JWKS (JSON Web Key Set) is now supported by JWT authentication, allowing external applications to access SCIM Gateway without the need to manage secrets
24
24
  - [Azure Relay](https://learn.microsoft.com/en-us/azure/azure-relay/relay-what-is-it) is now supported for secure and hassle-free outbound-only communication — with just one minute of configuration
@@ -1303,6 +1303,14 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1303
1303
 
1304
1304
  ## Change log
1305
1305
 
1306
+ ### v6.1.4
1307
+
1308
+ [Fixed]
1309
+
1310
+ - plugin-entra-id, OData paging was not working, so some users/groups/members might be missing
1311
+ - helper-rest, OData paging
1312
+ - user’s group membership did not iterate through paging and may be incomplete
1313
+
1306
1314
  ### v6.1.3
1307
1315
 
1308
1316
  [Fixed]
@@ -195,6 +195,10 @@
195
195
  "mapTo": "companyName",
196
196
  "type": "string"
197
197
  },
198
+ "employeeHireDate": {
199
+ "mapTo": "employeeHireDate",
200
+ "type": "string"
201
+ },
198
202
  "employeeOrgData.costCenter": {
199
203
  "mapTo": "employeeOrgData.costCenter",
200
204
  "type": "string"
@@ -239,6 +243,10 @@
239
243
  "type": "array",
240
244
  "typeInbound": "string"
241
245
  },
246
+ "faxNumber": {
247
+ "mapTo": "faxNumber",
248
+ "type": "string"
249
+ },
242
250
  "country": {
243
251
  "mapTo": "country",
244
252
  "type": "string"
@@ -298,7 +306,7 @@
298
306
  "type": "string"
299
307
  },
300
308
  "displayName": {
301
- "mapTo": "displayName,externalId",
309
+ "mapTo": "displayName",
302
310
  "type": "string"
303
311
  },
304
312
  "securityEnabled": {
@@ -65,10 +65,9 @@ export class HelperRest {
65
65
  * getAccessToken returns oauth accesstoken object
66
66
  * @param baseEntity
67
67
  * @param connectionObj endpoint.entity.baseEntity.connection
68
- * @param ctx
69
68
  * @returns { access_token: 'xxx', token_type: 'Bearer/Basic', validTo: 'xxx' }
70
69
  */
71
- public async getAccessToken(baseEntity: string, connectionObj: Record<string, any>, ctx?: Record<string, any> | undefined) { // public in case token is needed for other logic e.g. sending mail
70
+ public async getAccessToken(baseEntity: string, connectionObj: Record<string, any>) { // public in case token is needed for other logic e.g. sending mail
72
71
  await this.lock.acquire()
73
72
  const d = Math.floor(Date.now() / 1000) // seconds (unix time)
74
73
  if (this._serviceClient[baseEntity]?.accessToken?.validTo >= d + 30) { // avoid simultaneously token requests
@@ -229,7 +228,7 @@ export class HelperRest {
229
228
  if (!this.scimgateway.jwk[kid]) {
230
229
  this.scimgateway.jwk[kid] = { publicKey, privateKey }
231
230
  const ttl = 5 * 60
232
- ;(async () => {
231
+ ; (async () => {
233
232
  setTimeout(async () => {
234
233
  delete this.scimgateway.jwk[kid]
235
234
  }, ttl * 1000)
@@ -393,7 +392,7 @@ export class HelperRest {
393
392
  if (!connOpt.headers) connOpt.headers = {}
394
393
  connOpt.headers['Content-Type'] = 'application/x-www-form-urlencoded' // body must be query string formatted (no JSON)
395
394
 
396
- const response = await this.doRequest(baseEntity, method, tokenUrl, form, ctx, connOpt)
395
+ const response = await this.doRequest(baseEntity, method, tokenUrl, form, undefined, connOpt)
397
396
  if (!response.body) {
398
397
  const err = new Error(`[${action}] No data retrieved from: ${method} ${tokenUrl}`)
399
398
  throw (err)
@@ -452,7 +451,7 @@ export class HelperRest {
452
451
  if (this._serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
453
452
  this.scimgateway.logDebug(baseEntity, `${action}: Accesstoken about to expire in ${this._serviceClient[baseEntity].accessToken.validTo - d} seconds`)
454
453
  try {
455
- const accessToken = await this.getAccessToken(baseEntity, connectionObj, ctx)
454
+ const accessToken = await this.getAccessToken(baseEntity, connectionObj)
456
455
  this._serviceClient[baseEntity].accessToken = accessToken
457
456
  this._serviceClient[baseEntity].options.headers['Authorization'] = `${accessToken.token_type} ${accessToken.access_token}`
458
457
  } catch (err) {
@@ -512,7 +511,7 @@ export class HelperRest {
512
511
  }
513
512
  }
514
513
 
515
- param.accessToken = await this.getAccessToken(baseEntity, connectionObj, ctx)
514
+ param.accessToken = await this.getAccessToken(baseEntity, connectionObj)
516
515
  if (param.accessToken?.access_token && param.accessToken?.token_type) {
517
516
  param.options.headers['Authorization'] = `${param.accessToken.token_type} ${param.accessToken.access_token}`
518
517
  } else { // no auth or PassTrough
@@ -561,8 +560,6 @@ export class HelperRest {
561
560
 
562
561
  // OData support
563
562
  this._serviceClient[baseEntity].nextLink = {} // OData pagination (Entra ID)
564
- this._serviceClient[baseEntity].nextLink.users = null
565
- this._serviceClient[baseEntity].nextLink.groups = null
566
563
  }
567
564
 
568
565
  if (ctx?.headers?.get) { // Auth PassThrough using ctx header
@@ -679,7 +676,35 @@ export class HelperRest {
679
676
  options.body = dataString
680
677
  } else if (options.headers) delete options.headers['Content-Type']
681
678
 
682
- const url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
679
+ let url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
680
+ if (url.includes('$count=true') && url.includes('graph.microsoft.com')) {
681
+ options.headers['ConsistencyLevel'] = 'eventual'
682
+ }
683
+
684
+ if (this._serviceClient[baseEntity]?.nextLink[url]) {
685
+ if (ctx?.paging?.startIndex && ctx.paging.startIndex > 1) {
686
+ if (ctx.paging.startIndex === this._serviceClient[baseEntity]?.nextLink[url].startIndex) {
687
+ url = this._serviceClient[baseEntity]?.nextLink[url]['@odata.nextLink']
688
+ } else {
689
+ if (!ctx) ctx = {}
690
+ if (!ctx.paging) ctx.paging = {}
691
+ if (this._serviceClient[baseEntity]?.nextLink[url].totalResults
692
+ && ctx.paging.startIndex > this._serviceClient[baseEntity]?.nextLink[url].totalResults) {
693
+ ctx.paging.totalResults = this._serviceClient[baseEntity]?.nextLink[url].totalResults
694
+ return { body: { value: [] } }
695
+ } else {
696
+ // reset the paging cursor - none expected startIndex sequence, using default none paged url
697
+ ctx.paging.startIndex = 1 // caller should check and return this new startIndex in final response
698
+ delete this._serviceClient[baseEntity].nextLink[url]
699
+ }
700
+ }
701
+ }
702
+ } else {
703
+ if (ctx?.paging?.startIndex > 1 && !this._serviceClient[baseEntity]?.nextLink[url]) { // no previous paging and invalid startIndex
704
+ ctx.paging.totalResults = ctx.paging.startIndex - 1
705
+ return { body: { value: [] } }
706
+ }
707
+ }
683
708
 
684
709
  // execute request
685
710
  const f = await fetch(url, options)
@@ -708,30 +733,50 @@ export class HelperRest {
708
733
  throw new Error(JSON.stringify(result))
709
734
  }
710
735
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.protocol}//${options.host}${(options.port ? `:${options.port}` : '')}${options.path} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
711
- if (result.body && typeof result.body === 'object' && result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/beta/users?$top=100&$skiptoken=xxx"}
712
- // OData paging
713
- const nextUrl = result.body['@odata.nextLink'].split('?')[1] // keep search query
714
- const arr = result['@odata.nextLink'].split('?')[0].split('/')
715
- const objType = (arr[arr.length - 1]) // users
716
- let startIndexNext = ''
717
- if (this._serviceClient[baseEntity].nextLink[objType]) {
718
- for (const k in this._serviceClient[baseEntity].nextLink[objType]) {
719
- if (this._serviceClient[baseEntity].nextLink[objType][k] === nextUrl) return result // repetive startIndex=1
720
- startIndexNext = k
721
- break
736
+
737
+ // OData paging logic
738
+ // client prerequisite for enabling doRequest() OData paging support (see plugin-entra-id):
739
+ // let paging = { startIndex: getObj.startIndex }
740
+ // if (!ctx) ctx = { paging }
741
+ // else ctx.paging = paging
742
+ if (result.body && typeof result.body === 'object') {
743
+ if (result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/beta/users?$top=100&$skiptoken=xxx"}
744
+ if (!ctx) ctx = {}
745
+ if (!ctx.paging) ctx.paging = {}
746
+ const nextLinkBase = decodeURIComponent(result.body['@odata.nextLink'].substring(0, result.body['@odata.nextLink'].indexOf('$skiptoken') - 1))
747
+ const count = result.body['@odata.count']
748
+ if (count !== undefined) {
749
+ ctx.paging.totalResults = count
750
+ }
751
+ let totalResults = ctx.paging.totalResults
752
+ if (!totalResults) totalResults = (this._serviceClient[baseEntity].nextLink[nextLinkBase]?.totalResults)
753
+ let isCount = this._serviceClient[baseEntity].nextLink[nextLinkBase]?.isCount || count !== undefined
754
+ const itemsPerPage = result.body.value.length
755
+ this._serviceClient[baseEntity].nextLink[nextLinkBase] = {}
756
+ this._serviceClient[baseEntity].nextLink[nextLinkBase]['startIndex'] = ctx.paging.startIndex ? ctx.paging.startIndex + itemsPerPage : itemsPerPage + 1
757
+ this._serviceClient[baseEntity].nextLink[nextLinkBase]['@odata.nextLink'] = result.body['@odata.nextLink']
758
+ this._serviceClient[baseEntity].nextLink[nextLinkBase]['isCount'] = isCount
759
+ if (isCount) {
760
+ this._serviceClient[baseEntity].nextLink[nextLinkBase]['totalResults'] = totalResults // count=true ignored when using nextLink
761
+ ctx.paging.totalResults = totalResults
762
+ } else {
763
+ const totalResults = ctx.paging.startIndex - 1 + (itemsPerPage * 2) // ensure new client paging
764
+ this._serviceClient[baseEntity].nextLink[nextLinkBase]['totalResults'] = totalResults
765
+ ctx.paging.totalResults = totalResults
766
+ }
767
+ } else { // no more paging
768
+ const linkBase = decodeURIComponent(url.substring(0, url.indexOf('$skiptoken') - 1))
769
+ if (ctx?.paging?.startIndex && ctx.paging.startIndex > 1 && this._serviceClient[baseEntity]?.nextLink[linkBase]) {
770
+ if (!this._serviceClient[baseEntity]?.nextLink[linkBase].isCount) { // final no count page
771
+ const itemsPerPage = result.body.value.length
772
+ const totalResults = ctx.paging.startIndex - 1 + itemsPerPage
773
+ this._serviceClient[baseEntity].nextLink[linkBase]['totalResults'] = totalResults
774
+ ctx.paging.totalResults = totalResults
775
+ }
722
776
  }
723
777
  }
724
- const a = result.body['@odata.nextLink'].split('top=')
725
- let top = '0'
726
- if (a.length > 1) {
727
- top = a[1].split('&')[0]
728
- }
729
- if (!startIndexNext) startIndexNext = (Number(top) + 1).toString()
730
- else startIndexNext = (Number(startIndexNext) + Number(top) + 1).toString()
731
- // reset and set new nextLink
732
- this._serviceClient[baseEntity].nextLink[objType] = {}
733
- this._serviceClient[baseEntity].nextLink[objType][startIndexNext] = nextUrl
734
778
  }
779
+
735
780
  return result
736
781
  } finally {
737
782
  clearTimeout(timeout)
@@ -976,36 +1021,6 @@ export class HelperRest {
976
1021
  return await this.doRequestHandler(baseEntity, method, path, body, ctx, opt)
977
1022
  }
978
1023
 
979
- /**
980
- * nextLinkPaging returns paging url when using OData e.g., Entra ID
981
- * @param baseEntity baseEntity
982
- * @param objType e.g., 'users' or 'groups', a type that corresponds with what's being used by endpoint url request
983
- * @param startIndex SCIM startIndex paramenter
984
- * @returns paging url to be used
985
- **/
986
- public nextLinkPaging(baseEntity: string, objType: string, startIndex: number) {
987
- objType = objType.toLowerCase() // users or groups
988
- let nextPath = ''
989
- if (!startIndex || !this._serviceClient[baseEntity]) return ''
990
- if (startIndex < 2) {
991
- if (this._serviceClient[baseEntity].nextLink[objType]) {
992
- this._serviceClient[baseEntity].nextLink[objType] = null
993
- }
994
- return ''
995
- }
996
- if (this._serviceClient[baseEntity].nextLink[objType]) {
997
- if (this._serviceClient[baseEntity].nextLink[objType][startIndex]) {
998
- nextPath = `/users?${this._serviceClient[baseEntity].nextLink[objType][startIndex]}`
999
- } else {
1000
- this._serviceClient[baseEntity].nextLink[objType] = null
1001
- return ''
1002
- }
1003
- } else {
1004
- return ''
1005
- }
1006
- return nextPath
1007
- }
1008
-
1009
1024
  /**
1010
1025
  * getGraphUrl returns Microsoft Graph API url used for Entra ID
1011
1026
  * @returns Microsoft Graph API url
@@ -101,9 +101,17 @@ if (config.map) { // having licensDetails map here instead of config file
101
101
  }
102
102
 
103
103
  const userAttributes: string[] = []
104
- for (const key in config.map.user) { // userAttributes = ['country', 'preferredLanguage', 'mail', 'city', 'displayName', 'postalCode', 'jobTitle', 'businessPhones', 'onPremisesSyncEnabled', 'officeLocation', 'name.givenName', 'passwordPolicies', 'id', 'state', 'department', 'mailNickname', 'manager.managerId', 'active', 'userName', 'name.familyName', 'proxyAddresses.value', 'servicePlan.value', 'mobilePhone', 'streetAddress', 'onPremisesImmutableId', 'userType', 'usageLocation']
104
+ for (const key in config.map.user) { // userAttributes = ['id', 'country', 'preferredLanguage', 'mail', 'city', 'displayName', 'postalCode', 'jobTitle', 'businessPhones', 'onPremisesSyncEnabled', 'officeLocation', 'name.givenName', 'passwordPolicies', 'id', 'state', 'department', 'mailNickname', 'manager.managerId', 'active', 'userName', 'name.familyName', 'proxyAddresses.value', 'servicePlan.value', 'mobilePhone', 'streetAddress', 'onPremisesImmutableId', 'userType', 'usageLocation']
105
105
  if (config.map.user[key].mapTo) userAttributes.push(config.map.user[key].mapTo)
106
106
  }
107
+ if (!userAttributes.includes('id')) userAttributes.push('id')
108
+
109
+ const groupAttributes: string[] = []
110
+ for (const key in config.map.group) { // groupAttributes = ['id', 'displayName', 'securityEnabled', 'mailEnabled']
111
+ if (config.map.group[key].mapTo) groupAttributes.push(config.map.group[key].mapTo)
112
+ }
113
+ if (!groupAttributes.includes('id')) groupAttributes.push('id')
114
+ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.value')
107
115
 
108
116
  // =================================================
109
117
  // getUsers
@@ -150,18 +158,17 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
150
158
  throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
151
159
  } else {
152
160
  // mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all users to be returned - correspond to exploreUsers() in versions < 4.x.x
153
- if (getObj.startIndex && getObj.startIndex > 1) { // paging
154
- path = helper.nextLinkPaging(baseEntity, 'users', getObj.startIndex)
155
- if (!path) return ret
156
- } else {
157
- getObj.count = (!getObj.count || getObj.count > 999) ? 999 : getObj.count
158
- path = `/users?$top=${getObj.count}` // paging not supported using filter (Entra ID default page=100, max=999)
159
- }
161
+ path = `/users?$top=${getObj.count}&$count=true`
160
162
  }
161
163
  // mandatory if-else logic - end
162
164
 
163
165
  if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
164
166
 
167
+ // enable doRequest() OData paging support
168
+ let paging = { startIndex: getObj.startIndex }
169
+ if (!ctx) ctx = { paging }
170
+ else ctx.paging = paging
171
+
165
172
  try {
166
173
  let response: any
167
174
  if (path === 'getUser') { // special
@@ -176,10 +183,16 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
176
183
  const [scimObj] = scimgateway.endpointMapper('inbound', response.body.value[i], config.map.user) // endpoint => SCIM/CustomSCIM attribute standard
177
184
  if (scimObj && typeof scimObj === 'object' && Object.keys(scimObj).length > 0) ret.Resources.push(scimObj)
178
185
  }
179
- if (getObj.count === response.body.value.length) ret.totalResults = 99999999 // to ensure we get a new paging request - don't know the total numbers of users - metadata directoryObject collections are not countable
186
+
187
+ if (getObj.startIndex !== ctx.paging.startIndex) { // changed by doRequest()
188
+ ret.startIndex = ctx.paging.startIndex
189
+ }
190
+ if (ctx.paging.totalResults) ret.totalResults = ctx.paging.totalResults // set by doRequest()
180
191
  else ret.totalResults = getObj.startIndex ? getObj.startIndex - 1 + response.body.value.length : response.body.value.length
192
+
181
193
  return (ret)
182
194
  } catch (err: any) {
195
+ if (err.message.includes('Request_ResourceNotFound')) return { Resources: [] }
183
196
  throw new Error(`${action} error: ${err.message}`)
184
197
  }
185
198
  }
@@ -329,9 +342,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
329
342
  totalResults: null,
330
343
  }
331
344
 
332
- if (attributes.length < 1) attributes = ['id', 'displayName', 'members.value']
333
- if (!attributes.includes('id')) attributes.push('id')
334
-
345
+ if (attributes.length === 0) attributes = groupAttributes
335
346
  let includeMembers = false
336
347
  if (attributes.includes('members.value') || attributes.includes('members')) {
337
348
  includeMembers = true
@@ -356,9 +367,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
356
367
  } else if (getObj.operator === 'eq' && getObj.attribute === 'members.value') {
357
368
  // mandatory - return all groups the user 'id' (getObj.value) is member of - correspond to getGroupMembers() in versions < 4.x.x
358
369
  // Resources = [{ id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }]
359
- // not using below expand because Entra ID returns only a maximum of 20 items for the expanded relationship
360
- // path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$select=id,displayName&$expand=members($select=id,displayName)`
361
- path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$select=id,displayName`
370
+ path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&select=id,displayName`
362
371
  } else {
363
372
  // optional - simpel filtering
364
373
  throw new Error(`${action} error: not supporting simpel filtering: ${getObj.rawFilter}`)
@@ -368,16 +377,18 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
368
377
  throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
369
378
  } else {
370
379
  // mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all groups to be returned - correspond to exploreGroups() in versions < 4.x.x
371
- // Entra paging not supported because of select filter (Entra default page=100, max=999)
372
- // TODO: use a query that supports paging to fix current 999 limit of groups
373
- getObj.count = 999
374
- if (includeMembers) path = `/groups?$top=${getObj.count}&$select=${attrs.join()}&$expand=members($select=id,displayName)`
375
- else path = `/groups?$top=${getObj.count}&$select=${attrs.join()}`
380
+ if (includeMembers) path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}&$expand=members($select=id,displayName)`
381
+ else path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}`
376
382
  }
377
383
  // mandatory if-else logic - end
378
384
 
379
385
  if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
380
386
 
387
+ // enable doRequest() OData paging support
388
+ let paging = { startIndex: getObj.startIndex }
389
+ if (!ctx) ctx = { paging }
390
+ else ctx.paging = paging
391
+
381
392
  try {
382
393
  let response = await helper.doRequest(baseEntity, method, path, body, ctx)
383
394
  if (!response.body) {
@@ -411,12 +422,15 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
411
422
  }
412
423
  }
413
424
 
414
- // Entra paging not supported because of select filter
415
- getObj.startIndex = 1
416
- ret.totalResults = getObj.startIndex ? getObj.startIndex - 1 + response.body.value.length : response.body.value.length
425
+ if (getObj.startIndex !== ctx.paging.startIndex) { // changed by doRequest()
426
+ ret.startIndex = ctx.paging.startIndex
427
+ }
428
+ if (ctx.paging.totalResults) ret.totalResults = ctx.paging.totalResults // set by doRequest()
429
+ else ret.totalResults = getObj.startIndex ? getObj.startIndex - 1 + response.body.value.length : response.body.value.length
417
430
 
418
431
  return (ret)
419
432
  } catch (err: any) {
433
+ if (err.message.includes('Request_ResourceNotFound')) return { Resources: [] }
420
434
  throw new Error(`${action} error: ${err.message}`)
421
435
  }
422
436
  }
@@ -427,6 +441,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
427
441
  scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
428
442
  const action = 'createGroup'
429
443
  scimgateway.logDebug(baseEntity, `handling ${action} groupObj=${JSON.stringify(groupObj)} passThrough=${ctx ? 'true' : 'false'}`)
444
+
430
445
  const body: any = { displayName: groupObj.displayName }
431
446
  body.mailNickName = groupObj.displayName
432
447
  body.mailEnabled = false
@@ -454,7 +469,12 @@ scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
454
469
  scimgateway.deleteGroup = async (baseEntity, id, ctx) => {
455
470
  const action = 'deleteGroup'
456
471
  scimgateway.logDebug(baseEntity, `handling ${action} id=${id} passThrough=${ctx ? 'true' : 'false'}`)
457
- throw new Error(`${action} error: ${action} is not supported`)
472
+
473
+ const method = 'DELETE'
474
+ const path = `/groups/${id}`
475
+ const body = null
476
+
477
+ await helper.doRequest(baseEntity, method, path, body, ctx)
458
478
  }
459
479
 
460
480
  // =================================================
@@ -87,10 +87,10 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
87
87
  else if (typeof (response.body) === 'object' && Object.keys(response.body).length > 0) responseArr = [response.body]
88
88
  }
89
89
 
90
- if (!getObj.startIndex && !getObj.count) { // client request without paging
91
- getObj.startIndex = 1
92
- getObj.count = responseArr.length
93
- }
90
+ // no paging support
91
+ if (!getObj.startIndex) getObj.startIndex = 1
92
+ if (!getObj.count) getObj.count = responseArr.length
93
+ if (getObj.count > responseArr.length) getObj.count = responseArr.length
94
94
 
95
95
  for (let i = 0; i < responseArr.length && (i + 1 - getObj.startIndex) < getObj.count; ++i) {
96
96
  const userObj: any = responseArr[i]
@@ -340,10 +340,10 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
340
340
  else if (typeof (response.body) === 'object' && Object.keys(response.body).length > 0) responseArr = [response.body]
341
341
  }
342
342
 
343
- if (!getObj.startIndex && !getObj.count) { // client request without paging
344
- getObj.startIndex = 1
345
- getObj.count = responseArr.length
346
- }
343
+ // no paging support
344
+ if (!getObj.startIndex) getObj.startIndex = 1
345
+ if (!getObj.count) getObj.count = responseArr.length
346
+ if (getObj.count > responseArr.length) getObj.count = responseArr.length
347
347
 
348
348
  for (let i = 0; i < responseArr.length && (i + 1 - getObj.startIndex) < getObj.count; ++i) {
349
349
  const groupObj = responseArr[i]
@@ -854,7 +854,7 @@ export class ScimGateway {
854
854
  const getHandlerSchemas = async (ctx: Context) => {
855
855
  let tx = this.scimDef.Schemas
856
856
  tx = utilsScim.addResources(tx, undefined, undefined, undefined)
857
- tx = utilsScim.addSchemas(tx, isScimv2, undefined, undefined)
857
+ tx = utilsScim.addSchemasStripAttr(tx, isScimv2)
858
858
  ctx.response.body = JSON.stringify(tx)
859
859
  }
860
860
  funcHandler.getHandlerSchemas = getHandlerSchemas
@@ -1208,7 +1208,7 @@ export class ScimGateway {
1208
1208
  }
1209
1209
 
1210
1210
  scimdata = utils.stripObj(obj, ctx.query.attributes, ctx.query.excludedAttributes)
1211
- scimdata = utilsScim.addSchemas(scimdata, isScimv2, handle.description, undefined)
1211
+ scimdata = utilsScim.addSchemasStripAttr(scimdata, isScimv2, handle.description)
1212
1212
 
1213
1213
  if (!this.config.scimgateway.scim.skipMetaLocation) {
1214
1214
  const location = ctx.origin + ctx.path
@@ -1365,10 +1365,8 @@ export class ScimGateway {
1365
1365
  if (getObj.operator === 'eq' && ['id', 'userName', 'externalId', 'displayName', 'members.value'].includes(getObj.attribute)) info = ` ${getObj.attribute}=${getObj.value}`
1366
1366
  logger.debug(`${gwName} [Get ${handle.description}s]${info}`, { baseEntity: ctx?.routeObj?.baseEntity })
1367
1367
  try {
1368
- getObj.startIndex = ctx.query.startIndex ? parseInt(ctx.query.startIndex) : undefined
1369
- getObj.count = ctx.query.count ? parseInt(ctx.query.count) : undefined
1370
- if (getObj.startIndex && !getObj.count) getObj.count = 200 // defaults to 200 (plugin may override)
1371
- if (getObj.count && !getObj.startIndex) getObj.startIndex = 1
1368
+ getObj.startIndex = ctx.query.startIndex ? parseInt(ctx.query.startIndex, 10) : 1
1369
+ getObj.count = ctx.query.count ? parseInt(ctx.query.count, 10) : 200 // defaults to 200 (plugin may override)
1372
1370
 
1373
1371
  let res: any
1374
1372
  const obj: any = utils.copyObj(getObj)
@@ -1435,16 +1433,17 @@ export class ScimGateway {
1435
1433
  }
1436
1434
  }
1437
1435
  }
1438
- let scimdata: { [key: string]: any } = {
1439
- Resources: [],
1440
- totalResults: null,
1441
- }
1442
- if (res) {
1443
- if (res.Resources && Array.isArray(res.Resources)) {
1444
- scimdata.Resources = res.Resources
1445
- scimdata.totalResults = res.totalResults
1446
- } else if (Array.isArray(res)) scimdata.Resources = res
1447
- else if (typeof (res) === 'object' && Object.keys(res).length > 0) scimdata.Resources[0] = res
1436
+
1437
+ let location: string | undefined = ctx.origin + ctx.path
1438
+ if (this.config.scimgateway.scim.skipMetaLocation) location = undefined
1439
+ else if (ctx.query.excludedAttributes && ctx.query.excludedAttributes.includes('meta')) location = undefined
1440
+
1441
+ let scimdata = utilsScim.addResources(res, ctx.query.startIndex, ctx.query.sortBy, ctx.query.sortOrder)
1442
+ scimdata = utilsScim.addSchemasStripAttr(scimdata, isScimv2, handle.description, ctx.query.attributes, ctx.query.excludedAttributes, location)
1443
+ if (getObj.count === 0) {
1444
+ scimdata.Resources = []
1445
+ scimdata.itemsPerPage = 0
1446
+ // keep totalResults
1448
1447
  }
1449
1448
 
1450
1449
  if (scimdata.Resources.length === 1) {
@@ -1454,16 +1453,6 @@ export class ScimGateway {
1454
1453
  else if (obj.displayName) ctx.target = obj.displayName
1455
1454
  }
1456
1455
 
1457
- let location: string | undefined = ctx.origin + ctx.path
1458
- if (this.config.scimgateway.scim.skipMetaLocation) location = undefined
1459
- else if (ctx.query.excludedAttributes && ctx.query.excludedAttributes.includes('meta')) location = undefined
1460
- for (let i = 0; i < scimdata.Resources.length; i++) {
1461
- utils.getEtag(scimdata.Resources[i])
1462
- scimdata.Resources[i] = utils.stripObj(scimdata.Resources[i], ctx.query.attributes, ctx.query.excludedAttributes)
1463
- }
1464
- scimdata = utilsScim.addResources(scimdata, ctx.query.startIndex, ctx.query.sortBy, ctx.query.sortOrder)
1465
- scimdata = utilsScim.addSchemas(scimdata, isScimv2, handle.description, location)
1466
-
1467
1456
  ctx.response.body = JSON.stringify(scimdata)
1468
1457
  } catch (err: any) {
1469
1458
  if (isScimv2) ctx.response.status = 400
@@ -1593,7 +1582,7 @@ export class ScimGateway {
1593
1582
  if (!jsonBody.meta) jsonBody.meta = {}
1594
1583
  jsonBody.meta.location = location
1595
1584
  }
1596
- jsonBody = utilsScim.addSchemas(jsonBody, isScimv2, handle.description, undefined)
1585
+ jsonBody = utilsScim.addSchemasStripAttr(jsonBody, isScimv2, handle.description)
1597
1586
  if (eTag) ctx.response.headers.set('ETag', eTag)
1598
1587
  if (jsonBody?.meta?.location) ctx.response.headers.set('Location', jsonBody.meta.location)
1599
1588
  ctx.response.status = 201
@@ -1731,7 +1720,7 @@ export class ScimGateway {
1731
1720
  }
1732
1721
 
1733
1722
  scimres = utils.stripObj(userObj, ctx.query.attributes, ctx.query.excludedAttributes)
1734
- scimres = utilsScim.addSchemas(scimres, isScimv2, handle.description, undefined)
1723
+ scimres = utilsScim.addSchemasStripAttr(scimres, isScimv2, handle.description)
1735
1724
  if (eTag) ctx.response.headers.set('ETag', eTag)
1736
1725
  if (scimres?.meta?.location) ctx.response.headers.set('Location', scimres.meta.location)
1737
1726
  ctx.response.status = 200
@@ -2560,23 +2549,34 @@ export class ScimGateway {
2560
2549
  if (typeof (this as any)[handler.groups.getMethod] !== 'function') return groups // method not implemented
2561
2550
  if (this.config.scimgateway.scim.groupMemberOfUser) return groups // only support user member of group
2562
2551
  let res: any
2563
- try {
2564
- const ob = { attribute: 'members.value', operator: 'eq', value: decodeURIComponent(id) }
2565
- const attributes = ['id', 'displayName']
2566
- logger.debug(`${gwName} calling ${handler.groups.getMethod} - groups to be included`, { baseEntity })
2567
- res = await (this as any)[handler.groups.getMethod](baseEntity, ob, attributes, ctxPassThrough)
2568
- } catch (err) { void 0 }
2569
- if (res && res.Resources && Array.isArray(res.Resources) && res.Resources.length > 0) {
2570
- for (let i = 0; i < res.Resources.length; i++) {
2571
- if (!res.Resources[i].id) continue
2572
- const el: any = {}
2573
- el.value = res.Resources[i].id
2574
- if (res.Resources[i].displayName) el.display = res.Resources[i].displayName
2575
- if (isScimv2) el.type = 'direct'
2576
- else el.type = { value: 'direct' }
2577
- groups.push(el) // { "value": "Admins", "display": "Admins", "type": "direct"}
2552
+ const ob: Record<string, any> = { attribute: 'members.value', operator: 'eq', value: decodeURIComponent(id) }
2553
+ const attributes = ['id', 'displayName']
2554
+ const count = 200
2555
+ let startIndex = 1
2556
+ let nextStartIndex = 1
2557
+ do {
2558
+ try {
2559
+ logger.debug(`${gwName} calling ${handler.groups.getMethod} - groups to be included`, { baseEntity })
2560
+ startIndex = nextStartIndex
2561
+ ob.startIndex = startIndex
2562
+ ob.count = count
2563
+ res = await (this as any)[handler.groups.getMethod](baseEntity, ob, attributes, ctxPassThrough)
2564
+ } catch (err) { void 0 }
2565
+ if (res && res.Resources) {
2566
+ if (Array.isArray(res.Resources) && res.Resources.length > 0) {
2567
+ for (let i = 0; i < res.Resources.length; i++) {
2568
+ if (!res.Resources[i].id) continue
2569
+ const el: any = {}
2570
+ el.value = res.Resources[i].id
2571
+ if (res.Resources[i].displayName) el.display = res.Resources[i].displayName
2572
+ if (isScimv2) el.type = 'direct'
2573
+ else el.type = { value: 'direct' }
2574
+ groups.push(el) // { "value": "Admins", "display": "Admins", "type": "direct"}
2575
+ }
2576
+ nextStartIndex = utilsScim.getNextStartIndex(res.totalResults, startIndex, res.Resources.length)
2577
+ }
2578
2578
  }
2579
- }
2579
+ } while (nextStartIndex > startIndex)
2580
2580
  return groups
2581
2581
  }
2582
2582
  this.getMemberOf = getMemberOf
package/lib/utils-scim.ts CHANGED
@@ -515,8 +515,7 @@ export function endpointMapper(direction: string, parseObj: any, mapObj: any) {
515
515
  const keyRoot = key2.split('.').slice(0, -1).join('.') // xx.yy.mapTo => xx.yy
516
516
  if (dotMap[`${keyRoot}.type`] === 'array' && arrIndex >= 0) {
517
517
  dotNewObj[`${keyRoot}.${arrIndex}`] = dotParse[keyOrg] // servicePlan.0.value => servicePlan.0 and groups[0].value => memberOf.0
518
- }
519
- dotNewObj[keyRoot] = dotParse[key] // {"accountEnabled": {"mapTo": "active"} => str.replace("accountEnabled", "active")
518
+ } else dotNewObj[keyRoot] = dotParse[key] // {"accountEnabled": {"mapTo": "active"} => str.replace("accountEnabled", "active")
520
519
  break
521
520
  }
522
521
  }
@@ -768,14 +767,25 @@ export function addResources(data: any, startIndex?: string, sortBy?: string, so
768
767
  if (Array.isArray(data)) res.Resources = data
769
768
  else if (data.Resources) {
770
769
  res.Resources = data.Resources
771
- res.totalResults = data.totalResults
772
770
  } else res.Resources.push(data)
773
771
 
772
+ if (Object.hasOwn(data, 'totalResults')) res.totalResults = data.totalResults
773
+ if (data.startIndex) {
774
+ res.startIndex = data.startIndex
775
+ } else if (startIndex) {
776
+ res.startIndex = parseInt(startIndex, 10)
777
+ } else {
778
+ res.startIndex = 1
779
+ }
780
+
774
781
  // pagination
775
782
  if (!res.totalResults) res.totalResults = res.Resources.length // Specifies the total number of results matching the Consumer query
776
783
  res.itemsPerPage = res.Resources.length // Specifies the number of search results returned in a query response page
777
- if (startIndex) res.startIndex = parseInt(startIndex) // The 1-based index of the first result in the current set of search results
778
- else res.startIndex = 1
784
+
785
+ if (!res.startIndex || isNaN(res.startIndex)) {
786
+ if (startIndex) res.startIndex = parseInt(startIndex) // The 1-based index of the first result in the current set of search results
787
+ else res.startIndex = 1
788
+ }
779
789
  if (res.startIndex > res.totalResults) { // invalid paging request
780
790
  res.Resources = []
781
791
  res.itemsPerPage = 0
@@ -785,7 +795,7 @@ export function addResources(data: any, startIndex?: string, sortBy?: string, so
785
795
  return res
786
796
  }
787
797
 
788
- export function addSchemas(data: Record<string, any>, isScimv2: boolean, type?: string, location?: string) {
798
+ export function addSchemasStripAttr(data: Record<string, any>, isScimv2: boolean, type?: string, attributes?: string, excludedAttributes?: string, location?: string) {
789
799
  if (!type) {
790
800
  if (isScimv2) data.schemas = ['urn:ietf:params:scim:api:messages:2.0:ListResponse']
791
801
  else data.schemas = ['urn:scim:schemas:core:1.0']
@@ -796,6 +806,9 @@ export function addSchemas(data: Record<string, any>, isScimv2: boolean, type?:
796
806
  if (isScimv2) data.schemas = ['urn:ietf:params:scim:api:messages:2.0:ListResponse']
797
807
  else data.schemas = ['urn:scim:schemas:core:1.0']
798
808
  for (let i = 0; i < data.Resources.length; i++) {
809
+ utils.getEtag(data.Resources[i])
810
+ data.Resources[i] = utils.stripObj(data.Resources[i], attributes, excludedAttributes)
811
+
799
812
  if (isScimv2) { // scim v2 add schemas/resourceType on each element
800
813
  if (type === 'User') {
801
814
  const val = 'urn:ietf:params:scim:schemas:core:2.0:User'
@@ -1062,3 +1075,14 @@ export function loadScimDef(version: ScimVersion, customPath?: string): any {
1062
1075
  }
1063
1076
  return v === '1.1' ? scimdefV1Default : scimdefV2Default
1064
1077
  }
1078
+
1079
+ /**
1080
+ * returns next pagination startIndex
1081
+ * pagination should stop when nextStartIndex <= startIndex
1082
+ */
1083
+ export function getNextStartIndex(totalResults: number, startIndex: number, itemsPerPage: number): number {
1084
+ if (totalResults === undefined || startIndex == undefined || itemsPerPage === undefined) return startIndex
1085
+ const nextStartIndex = startIndex + itemsPerPage
1086
+ if (nextStartIndex >= totalResults) return startIndex
1087
+ return nextStartIndex
1088
+ }
package/lib/utils.ts CHANGED
@@ -744,7 +744,6 @@ export class TimerMapCache {
744
744
  createItem(item: string): string {
745
745
  if (!item) return ''
746
746
  this.itemCache.set(item, setTimeout(() => {
747
- console.log('deleting item')
748
747
  this.itemCache.delete(item)
749
748
  },
750
749
  this.itemTimeout))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "6.1.3",
3
+ "version": "6.1.4",
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)",