scimgateway 6.1.2 → 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,10 +18,10 @@
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
- - [Azure Relay](https://learn.microsoft.com/en-us/azure/azure-relay/relay-what-is-it) is now supported for secure and hassle-free outbound communication — with just one minute of configuration
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
25
25
  - [ETag](https://datatracker.ietf.org/doc/html/rfc7644#section-3.14) is now supported
26
26
  - [Bulk Operations](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7) is now supported
27
27
  - Remote real-time log subscription for centralized logging and monitoring. Using browser `https://<host>/logger`, curl or custom client API - see configuration notes
@@ -1303,9 +1303,30 @@ 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
+
1314
+ ### v6.1.3
1315
+
1316
+ [Fixed]
1317
+
1318
+ - azure relay, recover on failure
1319
+ - plugin-ldap, some improvements for Active Directory and the use of objectGUID/mS-DS-ConsistencyGuid
1320
+ - plugin-mongodb, group meta.version not standarized
1321
+ - when modifying group members, if an error occurs, the gateway now checks whether it was caused by adding an existing member or removing a non-existing member. In such cases, it returns 200 OK instead of an error.
1322
+
1323
+ [Improved]
1324
+ - Dependencies bump
1325
+
1306
1326
  ### v6.1.2
1307
1327
 
1308
1328
  [Fixed]
1329
+
1309
1330
  - SMTP mail functionality failed because of an updated dependency
1310
1331
  - endpointMapper failed when `mapTo` included multiple comma-separated attributes and one of them was a multivalued attribute, e.g. `{ "mail": { "mapTo": "userName,emails.work.value" } }`
1311
1332
 
package/bun.lock CHANGED
@@ -16,12 +16,12 @@
16
16
  "https-proxy-agent": "^7.0.6",
17
17
  "hyco-https": "^1.4.5",
18
18
  "is-in-subnet": "^4.0.1",
19
- "jose": "^6.1.0",
19
+ "jose": "^6.1.1",
20
20
  "ldapjs": "^3.0.7",
21
21
  "lokijs": "^1.5.12",
22
- "mongodb": "^6.20.0",
22
+ "mongodb": "^7.0.0",
23
23
  "node-machine-id": "1.1.12",
24
- "nodemailer": "^7.0.9",
24
+ "nodemailer": "^7.0.10",
25
25
  "saml": "^3.0.1",
26
26
  "tsx": "^4.20.6",
27
27
  },
@@ -233,7 +233,7 @@
233
233
 
234
234
  "@ldapjs/protocol": ["@ldapjs/protocol@1.2.1", "", {}, "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ=="],
235
235
 
236
- "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.1", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg=="],
236
+ "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.2", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg=="],
237
237
 
238
238
  "@nats-io/jetstream": ["@nats-io/jetstream@3.2.0", "", { "dependencies": { "@nats-io/nats-core": "3.2.0" } }, "sha512-6H/vMjTMPsFEXKGK7dqScwHEtP1ZedZrwbCdRQuYDIVq4WLqZOD6ryeEZ/gMAP7YKLy82G6IixGUm2DVsDPCMw=="],
239
239
 
@@ -357,7 +357,7 @@
357
357
 
358
358
  "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
359
359
 
360
- "@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="],
360
+ "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
361
361
 
362
362
  "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/type-utils": "8.46.0", "@typescript-eslint/utils": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA=="],
363
363
 
@@ -421,7 +421,7 @@
421
421
 
422
422
  "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
423
423
 
424
- "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="],
424
+ "bson": ["bson@7.0.0", "", {}, "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw=="],
425
425
 
426
426
  "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
427
427
 
@@ -617,7 +617,7 @@
617
617
 
618
618
  "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
619
619
 
620
- "jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="],
620
+ "jose": ["jose@6.1.1", "", {}, "sha512-GWSqjfOPf4cWOkBzw5THBjtGPhXKqYnfRBzh4Ni+ArTrQQ9unvmsA3oFLqaYKoKe5sjWmGu5wVKg9Ft1i+LQfg=="],
621
621
 
622
622
  "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="],
623
623
 
@@ -673,9 +673,9 @@
673
673
 
674
674
  "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
675
675
 
676
- "mongodb": ["mongodb@6.20.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.2" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.3.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ=="],
676
+ "mongodb": ["mongodb@7.0.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg=="],
677
677
 
678
- "mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="],
678
+ "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.0", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og=="],
679
679
 
680
680
  "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
681
681
 
@@ -685,7 +685,7 @@
685
685
 
686
686
  "node-machine-id": ["node-machine-id@1.1.12", "", {}, "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ=="],
687
687
 
688
- "nodemailer": ["nodemailer@7.0.9", "", {}, "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ=="],
688
+ "nodemailer": ["nodemailer@7.0.10", "", {}, "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w=="],
689
689
 
690
690
  "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
691
691
 
@@ -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
  // =================================================