scimgateway 6.2.5 → 6.2.7

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.
@@ -27,16 +27,24 @@
27
27
  "Microsoft_365_F1":{"displayName":"Microsoft 365 F1","category":"Microsoft 365","licenseCategory":"Paid","isBillable":true,"priceUSD":2.25,"includes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"],"derivedIncludes":["EXCHANGESTANDARD","SHAREPOINTSTANDARD","MCOSTANDARD","ONEDRIVESTANDARD"]},
28
28
  "ENTRA_IDENTITY_PROTECTION":{"displayName":"Microsoft Entra ID Protection","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":["AAD_PREMIUM_P2"],"derivedIncludes":["AAD_PREMIUM_P2","AAD_PREMIUM"]},
29
29
  "ENTRA_IDENTITY_GOVERNANCE_STEPUP":{"displayName":"Microsoft Entra ID Governance Step-Up","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":4,"includes":["AAD_PREMIUM_P1"],"derivedIncludes":["AAD_PREMIUM_P1"]},
30
+ "M365_E5_SECURITY":{"displayName":"Microsoft 365 E5 Security","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":12,"includes":["MDE_ADVANCED","DEFENDER_OFFICE_365","IDENTITY_THREAT_PROTECTION","DEFENDER_CLOUD_APPS"],"derivedIncludes":["MDE_ADVANCED","DEFENDER_OFFICE_365","IDENTITY_THREAT_PROTECTION","DEFENDER_CLOUD_APPS"]},
31
+ "M365_E5_COMPLIANCE":{"displayName":"Microsoft 365 E5 Compliance","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":10,"includes":[]},
30
32
  "ENTRA_PERMISSIONS_MANAGEMENT":{"displayName":"Microsoft Entra Permissions Management","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":10,"includes":[]},
33
+ "ENTRA_WORKLOAD_ID":{"displayName":"Microsoft Entra Workload ID","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":3,"includes":[]},
34
+ "ENTRA_VERIFIED_ID":{"displayName":"Microsoft Entra Verified ID","category":"Entra","licenseCategory":"Consumption","isBillable":true,"priceUSD":0,"includes":[]},
31
35
  "AAD_PREMIUM_P1":{"displayName":"Microsoft Entra ID P1","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":[]},
32
36
  "AAD_B2C":{"displayName":"Azure AD B2C","category":"Entra","licenseCategory":"Consumption","isBillable":true,"priceUSD":0,"includes":[]},
33
37
  "AAD_BASIC_EDU":{"displayName":"Azure Active Directory Basic (Education)","category":"Entra","licenseCategory":"Free","isBillable":false,"priceUSD":0,"includes":[]},
34
38
  "M365_DEFENDER_IDENTITY":{"displayName":"Microsoft 365 Defender for Identity","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":[]},
39
+ "TEAMS_PREMIUM":{"displayName":"Microsoft Teams Premium","category":"Collaboration","licenseCategory":"Paid","isBillable":true,"priceUSD":10,"includes":[]},
35
40
  "Microsoft_Teams_EEA_New": { "displayName": "Microsoft Teams EEA New", "category": "Collaboration", "licenseCategory": "Paid", "isBillable": true, "priceUSD": 4.00, "includes": [] },
41
+ "MCOEV_VIRTUALUSER":{"displayName":"Teams Phone Resource Account License","category":"Collaboration","licenseCategory":"Paid","isBillable":true,"priceUSD":0,"includes":[]},
42
+ "TEAMS_AUDIO_CONF":{"displayName":"Microsoft Teams Audio Conferencing","category":"Collaboration","licenseCategory":"Paid","isBillable":true,"priceUSD":4,"includes":[]},
36
43
  "POWERAPPS_DEV": { "displayName": "Power Apps Developer Plan", "category": "Power Platform", "licenseCategory": "Free", "isBillable": false, "priceUSD": 0.00, "includes": [] },
37
44
  "EXCHANGESTANDARD":{"displayName":"Exchange Online Plan 1","category":"Exchange","licenseCategory":"Paid","isBillable":true,"priceUSD":4,"includes":[]},
38
45
  "EXCHANGEENTERPRISE":{"displayName":"Exchange Online Plan 2","category":"Exchange","licenseCategory":"Paid","isBillable":true,"priceUSD":8,"includes":[]},
39
46
  "EXCHANGEARCHIVE_ADDON":{"displayName":"Exchange Online Archiving","category":"Exchange","licenseCategory":"Paid","isBillable":true,"priceUSD":3,"includes":[]},
47
+ "EXCHANGE_KIOSK":{"displayName":"Exchange Online Kiosk","category":"Exchange","licenseCategory":"Paid","isBillable":true,"priceUSD":2,"includes":[]},
40
48
  "SHAREPOINTSTANDARD":{"displayName":"SharePoint Online Plan 1","category":"SharePoint","licenseCategory":"Paid","isBillable":true,"priceUSD":5,"includes":[]},
41
49
  "SHAREPOINTENTERPRISE":{"displayName":"SharePoint Online Plan 2","category":"SharePoint","licenseCategory":"Paid","isBillable":true,"priceUSD":10,"includes":[]},
42
50
  "MCOSTANDARD":{"displayName":"Microsoft Teams","category":"Collaboration","licenseCategory":"Paid","isBillable":true,"priceUSD":5,"includes":[]},
@@ -44,6 +52,8 @@
44
52
  "PHONESYSTEM_VIRTUALUSER":{"displayName":"Microsoft Teams Phone Resource Account","category":"Collaboration","licenseCategory":"Paid","isBillable":true,"priceUSD":0,"includes":[]},
45
53
  "MCOPSTN1":{"displayName":"Microsoft Teams Domestic Calling Plan","category":"Collaboration","licenseCategory":"Paid","isBillable":true,"priceUSD":8,"includes":[]},
46
54
  "MCOPSTN2":{"displayName":"Microsoft Teams International Calling Plan","category":"Collaboration","licenseCategory":"Paid","isBillable":true,"priceUSD":12,"includes":[]},
55
+ "POWER_AUTOMATE_PER_FLOW":{"displayName":"Power Automate Per Flow","category":"Power Platform","licenseCategory":"Paid","isBillable":true,"priceUSD":100,"includes":[]},
56
+ "POWER_PAGES":{"displayName":"Power Pages","category":"Power Platform","licenseCategory":"Consumption","isBillable":true,"priceUSD":0,"includes":[]},
47
57
  "POWER_BI_STANDARD":{"displayName":"Microsoft Fabric (Free)","category":"Power Platform","licenseCategory":"Free","isBillable":false,"priceUSD":0,"includes":[]},
48
58
  "POWER_BI_PRO":{"displayName":"Power BI Pro","category":"Power Platform","licenseCategory":"Paid","isBillable":true,"priceUSD":13,"includes":[]},
49
59
  "POWERAPPS_PER_USER":{"displayName":"Power Apps Per User","category":"Power Platform","licenseCategory":"Paid","isBillable":true,"priceUSD":10,"includes":[]},
@@ -58,10 +68,15 @@
58
68
  "VISIO_P2":{"displayName":"Visio Plan 2","category":"Visio","licenseCategory":"Paid","isBillable":true,"priceUSD":12,"includes":[]},
59
69
  "MDE_ADVANCED":{"displayName":"Microsoft Defender for Endpoint","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":8,"includes":[]},
60
70
  "DEFENDER_OFFICE_365":{"displayName":"Microsoft Defender for Office 365","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":5,"includes":[]},
71
+ "DEFENDER_ENDPOINT_P1":{"displayName":"Microsoft Defender for Endpoint Plan 1","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":3,"includes":[]},
72
+ "DEFENDER_ENDPOINT_P2":{"displayName":"Microsoft Defender for Endpoint Plan 2","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":5,"includes":[]},
73
+ "DEFENDER_IDENTITY_STANDALONE":{"displayName":"Microsoft Defender for Identity (Standalone)","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":[]},
61
74
  "IDENTITY_THREAT_PROTECTION":{"displayName":"Microsoft Defender for Identity","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":[]},
62
75
  "DEFENDER_CLOUD_APPS":{"displayName":"Microsoft Defender for Cloud Apps","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":7,"includes":[]},
63
76
  "M365_COPILOT":{"displayName":"Microsoft 365 Copilot","category":"AI","licenseCategory":"Paid","isBillable":true,"priceUSD":30,"includes":[]},
64
77
  "COPILOT_STUDIO":{"displayName":"Copilot Studio","category":"AI","licenseCategory":"Paid","isBillable":true,"priceUSD":20,"includes":[]},
78
+ "GITHUB_COPILOT":{"displayName":"GitHub Copilot Business","category":"AI","licenseCategory":"Paid","isBillable":true,"priceUSD":19,"includes":[]},
79
+ "DYNAMICS_COPILOT":{"displayName":"Dynamics 365 Copilot","category":"AI","licenseCategory":"Paid","isBillable":true,"priceUSD":0,"includes":[]},
65
80
  "VIVA_CONNECTIONS":{"displayName":"Microsoft Viva Connections","category":"Viva","licenseCategory":"Paid","isBillable":true,"priceUSD":3,"includes":[]},
66
81
  "VIVA_INSIGHTS":{"displayName":"Microsoft Viva Insights","category":"Viva","licenseCategory":"Paid","isBillable":true,"priceUSD":3,"includes":[]},
67
82
  "VIVA_LEARNING":{"displayName":"Microsoft Viva Learning","category":"Viva","licenseCategory":"Paid","isBillable":true,"priceUSD":4,"includes":[]},
@@ -76,6 +91,8 @@
76
91
  "ENTRA_PRIVATE_ACCESS":{"displayName":"Microsoft Entra Private Access","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":8,"includes":[]},
77
92
  "ENTRA_INTERNET_ACCESS":{"displayName":"Microsoft Entra Internet Access","category":"Entra","licenseCategory":"Paid","isBillable":true,"priceUSD":5,"includes":[]},
78
93
  "WINDOWS_STORE":{"displayName":"Microsoft Store for Business","category":"Windows","licenseCategory":"Free","isBillable":false,"priceUSD":0,"includes":[]},
94
+ "WINDOWS_E3":{"displayName":"Windows Enterprise E3","category":"Windows","licenseCategory":"Paid","isBillable":true,"priceUSD":7,"includes":[]},
95
+ "WINDOWS_E5":{"displayName":"Windows Enterprise E5","category":"Windows","licenseCategory":"Paid","isBillable":true,"priceUSD":11,"includes":[]},
79
96
  "WIN_DEF_ATP":{"displayName":"Windows Defender ATP","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":5,"includes":[]},
80
97
  "RMS_S_ENTERPRISE":{"displayName":"Azure Information Protection Premium P1","category":"Security","licenseCategory":"Paid","isBillable":true,"priceUSD":6,"includes":[]},
81
98
  "INTUNE_A":{"displayName":"Microsoft Intune","category":"Device Management","licenseCategory":"Paid","isBillable":true,"priceUSD":8,"includes":[]},
@@ -59,6 +59,35 @@ export class HelperRest {
59
59
  }
60
60
  }
61
61
  }
62
+
63
+ ;(async () => {
64
+ // housekeeping - odata pagination
65
+ while (true) {
66
+ await new Promise(resolve => setTimeout(resolve, 5 * 60 * 1000)) // 5 minutes
67
+ for (const baseEntity in this.config_entity) {
68
+ if (!this._serviceClient[baseEntity]?.index) continue
69
+ for (const index in this._serviceClient[baseEntity].index) {
70
+ const parsed = parseInt(index, 10)
71
+ if (Number.isNaN(parsed)) continue
72
+ if (Object.keys(this._serviceClient[baseEntity].index[parsed]).length === 0) {
73
+ delete this._serviceClient[baseEntity].index[parsed]
74
+ continue
75
+ }
76
+ for (const optionsUrl in this._serviceClient[baseEntity].index[parsed]) {
77
+ const validTo = this._serviceClient[baseEntity].index[parsed][optionsUrl].validTo
78
+ if (Number.isNaN(validTo)) {
79
+ delete this._serviceClient[baseEntity].index[parsed][optionsUrl]
80
+ continue
81
+ }
82
+ const now = Math.floor(Date.now() / 1000)
83
+ if (now > validTo) {
84
+ delete this._serviceClient[baseEntity].index[parsed][optionsUrl]
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ })()
62
91
  }
63
92
 
64
93
  /**
@@ -659,28 +688,25 @@ export class HelperRest {
659
688
  options.body = dataString
660
689
  } else if (options.headers) delete options.headers['Content-Type']
661
690
 
662
- if (this._serviceClient[baseEntity]?.nextLink[options.url]) {
663
- if (ctx?.paging?.startIndex && ctx.paging.startIndex > 1) {
664
- if (ctx.paging.startIndex === this._serviceClient[baseEntity]?.nextLink[options.url].startIndex) {
665
- options.url = this._serviceClient[baseEntity]?.nextLink[options.url]['@odata.nextLink']
666
- } else {
667
- if (!ctx) ctx = {}
668
- if (!ctx.paging) ctx.paging = {}
669
- if (this._serviceClient[baseEntity]?.nextLink[options.url].totalResults
670
- && ctx.paging.startIndex > this._serviceClient[baseEntity]?.nextLink[options.url].totalResults) {
671
- ctx.paging.totalResults = this._serviceClient[baseEntity]?.nextLink[options.url].totalResults
672
- return { body: { value: [] } }
673
- } else {
674
- // reset the paging cursor - none expected startIndex sequence, using default none paged url
675
- ctx.paging.startIndex = 1 // caller should check and return this new startIndex in final response
676
- delete this._serviceClient[baseEntity].nextLink[options.url]
677
- }
691
+ const optionsUrl = options.url
692
+ const startIndex: number = ctx?.paging?.startIndex || 0
693
+ if (startIndex > 1) {
694
+ if (this._serviceClient[baseEntity].index && this._serviceClient[baseEntity].index[startIndex]
695
+ && this._serviceClient[baseEntity].index[startIndex][optionsUrl] && this._serviceClient[baseEntity].index[startIndex][optionsUrl].nextLink) {
696
+ options.url = this._serviceClient[baseEntity].index[startIndex][optionsUrl].nextLink
697
+ } else {
698
+ let max = 0
699
+ for (const key in this._serviceClient[baseEntity].index) {
700
+ const parsed = parseInt(key, 10)
701
+ if (Number.isNaN(parsed)) continue
702
+ if (!this._serviceClient[baseEntity].index[parsed][optionsUrl]?.totalResults) continue
703
+ if (parsed > max) max = parsed
678
704
  }
679
- }
680
- } else {
681
- if (ctx?.paging?.startIndex > 1 && !this._serviceClient[baseEntity]?.nextLink[options.url]) { // no previous paging and invalid startIndex
682
- ctx.paging.totalResults = ctx.paging.startIndex - 1
683
- return { body: { value: [] } }
705
+ if (max > 0) {
706
+ ctx.paging.totalResults = this._serviceClient[baseEntity].index[max][optionsUrl].totalResults
707
+ if (startIndex > ctx.paging.totalResults) return { body: { value: [] } }
708
+ }
709
+ throw new Error(`Strict sequencing is required; startIndex=${ctx.paging.startIndex} is not the expected value.`)
684
710
  }
685
711
  }
686
712
 
@@ -721,38 +747,56 @@ export class HelperRest {
721
747
  // if (!ctx) ctx = { paging }
722
748
  // else ctx.paging = paging
723
749
  if (result.body && typeof result.body === 'object') {
724
- if (result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/v1.0/users?$top=100&$skiptoken=xxx"}
750
+ if (result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/beta/users?$top=100&$skiptoken=xxx"}
725
751
  if (!ctx) ctx = {}
726
752
  if (!ctx.paging) ctx.paging = {}
727
- const nextLinkBase = decodeURIComponent(result.body['@odata.nextLink'].substring(0, result.body['@odata.nextLink'].indexOf('$skiptoken') - 1))
728
753
  const count = result.body['@odata.count']
729
754
  if (count !== undefined) {
730
755
  ctx.paging.totalResults = count
731
756
  }
732
- let totalResults = ctx.paging.totalResults
733
- if (!totalResults) totalResults = (this._serviceClient[baseEntity].nextLink[nextLinkBase]?.totalResults)
734
- let isCount = this._serviceClient[baseEntity].nextLink[nextLinkBase]?.isCount || count !== undefined
735
757
  const itemsPerPage = result.body.value.length
736
- this._serviceClient[baseEntity].nextLink[nextLinkBase] = {}
737
- this._serviceClient[baseEntity].nextLink[nextLinkBase]['startIndex'] = ctx.paging.startIndex ? ctx.paging.startIndex + itemsPerPage : itemsPerPage + 1
738
- this._serviceClient[baseEntity].nextLink[nextLinkBase]['@odata.nextLink'] = result.body['@odata.nextLink']
739
- this._serviceClient[baseEntity].nextLink[nextLinkBase]['isCount'] = isCount
740
- if (isCount) {
741
- this._serviceClient[baseEntity].nextLink[nextLinkBase]['totalResults'] = totalResults // count=true ignored when using nextLink
758
+ const nextStartIndex = ctx.paging.startIndex ? ctx.paging.startIndex + itemsPerPage : itemsPerPage + 1
759
+ let totalResults = ctx.paging.totalResults
760
+ if (ctx.paging.startIndex && this._serviceClient[baseEntity].index && this._serviceClient[baseEntity].index[ctx.paging.startIndex]) {
761
+ if (this._serviceClient[baseEntity].index[ctx.paging.startIndex][optionsUrl]) {
762
+ if (!totalResults) totalResults = this._serviceClient[baseEntity].index[ctx.paging.startIndex][optionsUrl].totalResults
763
+ }
764
+ delete this._serviceClient[baseEntity].index[ctx.paging.startIndex] // could consider not to delte prior and let houskeeping do the cleanup
765
+ }
766
+
767
+ if (!this._serviceClient[baseEntity].index) this._serviceClient[baseEntity].index = {}
768
+ if (!this._serviceClient[baseEntity].index[nextStartIndex]) this._serviceClient[baseEntity].index[nextStartIndex] = {}
769
+ if (!this._serviceClient[baseEntity].index[nextStartIndex][optionsUrl]) this._serviceClient[baseEntity].index[nextStartIndex][optionsUrl] = {}
770
+ this._serviceClient[baseEntity].index[nextStartIndex][optionsUrl].nextLink = result.body['@odata.nextLink']
771
+ ctx.paging.nextLink = result.body['@odata.nextLink'] // gives client option to auto-paginate without using startIndex
772
+ if (ctx.paging.nextLink.startsWith(this._serviceClient[baseEntity].baseUrl)) {
773
+ ctx.paging.nextLink = ctx.paging.nextLink.slice(this._serviceClient[baseEntity].baseUrl.length)
774
+ if (!ctx.paging.nextLink.startsWith('/')) ctx.paging.nextLink = '/' + ctx.paging.nextLink
775
+ }
776
+
777
+ if (count) {
778
+ this._serviceClient[baseEntity].index[nextStartIndex][optionsUrl].totalResults = totalResults // count=true ignored when using nextLink
742
779
  ctx.paging.totalResults = totalResults
743
780
  } else {
744
781
  const totalResults = ctx.paging.startIndex - 1 + (itemsPerPage * 2) // ensure new client paging
745
- this._serviceClient[baseEntity].nextLink[nextLinkBase]['totalResults'] = totalResults
782
+ this._serviceClient[baseEntity].index[nextStartIndex][optionsUrl].totalResults = totalResults
746
783
  ctx.paging.totalResults = totalResults
747
784
  }
785
+ const d = Math.floor((Date.now() + 5 * 60 * 1000) / 1000)// now + 5 minutes
786
+ this._serviceClient[baseEntity].index[nextStartIndex][optionsUrl].validTo = d
748
787
  } else { // no more paging
749
- const linkBase = decodeURIComponent(options.url?.substring(0, options.url?.indexOf('$skiptoken') - 1))
750
- if (ctx?.paging?.startIndex && ctx.paging.startIndex > 1 && this._serviceClient[baseEntity]?.nextLink[linkBase]) {
751
- if (!this._serviceClient[baseEntity]?.nextLink[linkBase].isCount) { // final no count page
788
+ if (ctx?.paging) {
789
+ if (ctx.paging.nextLink) delete ctx.paging.nextLink
790
+ if (ctx.paging.startIndex) {
752
791
  const itemsPerPage = result.body.value.length
753
792
  const totalResults = ctx.paging.startIndex - 1 + itemsPerPage
754
- this._serviceClient[baseEntity].nextLink[linkBase]['totalResults'] = totalResults
755
- ctx.paging.totalResults = totalResults
793
+ if (this._serviceClient[baseEntity].index && this._serviceClient[baseEntity].index[ctx.paging.startIndex] && this._serviceClient[baseEntity].index[ctx.paging.startIndex][optionsUrl]) {
794
+ // keeping the last one with updated totalResults to catch startIndex > totalResults, houskeeping will clean up after 5 minutes
795
+ this._serviceClient[baseEntity].index[ctx.paging.startIndex][optionsUrl].totalResults = totalResults // update the last one with correct totalResults
796
+ ctx.paging.totalResults = totalResults
797
+ const d = Math.floor((Date.now() + 5 * 60 * 1000) / 1000)
798
+ this._serviceClient[baseEntity].index[ctx.paging.startIndex][optionsUrl].validTo = d
799
+ }
756
800
  }
757
801
  }
758
802
  }
@@ -769,12 +813,6 @@ export class HelperRest {
769
813
  if (!retryAfter) retryAfter = 60
770
814
  }
771
815
 
772
- if (retryAfter) {
773
- this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
774
- await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
775
- }
776
-
777
- if (!retryCount) retryCount = 0
778
816
  let urlObj
779
817
  try { urlObj = new URL(path) } catch (err) { void 0 }
780
818
  let isServiceClient = !urlObj && this._serviceClient[baseEntity] && !this.lock.isLocked() // !isLocked to avoid retry ongoing doRequest with failing getAccessToken()
@@ -782,12 +820,25 @@ export class HelperRest {
782
820
 
783
821
  if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || statusCode === 504 || oAuthTokeErr || retryAfter)) {
784
822
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
785
- if (retryCount < connectionObj.baseUrls.length) {
786
- retryCount++
787
- if (isServiceClient) {
788
- this.updateServiceClient(baseEntity, { baseUrl: connectionObj.baseUrls[retryCount - 1] })
789
- this.scimgateway.logDebug(baseEntity, `${(connectionObj.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
823
+
824
+ let maxRetry = connectionObj.baseUrls.length
825
+ if (!retryCount) retryCount = 0
826
+ if (retryAfter) {
827
+ const delta = retryCount - maxRetry
828
+ if (delta >= 0 && delta < 2) maxRetry += delta + 1 // allow 3 retry for the same initial throttle
829
+ }
830
+
831
+ if (retryCount < maxRetry) {
832
+ if (retryAfter) {
833
+ this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
834
+ await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
790
835
  }
836
+ const index = retryCount < connectionObj.baseUrls.length ? retryCount : connectionObj.baseUrls.length - 1
837
+ retryCount++
838
+
839
+ this.updateServiceClient(baseEntity, { baseUrl: connectionObj.baseUrls[index] })
840
+ this.scimgateway.logDebug(baseEntity, `${(connectionObj.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
841
+
791
842
  if (oAuthTokeErr) {
792
843
  delete this._serviceClient[baseEntity] // ensure new getAccessToken request - token used should not have been expired, but rejected for other reason e.g. token server restart and no persistent token store?
793
844
  }
@@ -868,7 +919,7 @@ export class HelperRest {
868
919
  * {
869
920
  * "type": "oauth",
870
921
  * "options": {
871
- * "azureTenantId": "<Entra ID azureTenantId", // Entra ID authentication - if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/v1.0]
922
+ * "azureTenantId": "<Entra ID azureTenantId", // Entra ID authentication - if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
872
923
  * "tokenUrl": "<tokenUrl>", // must be set if not using azureTenantId
873
924
  * "clientId": "<clientId>",
874
925
  * "clientSecret": "<clientSecret>"
@@ -927,7 +978,7 @@ export class HelperRest {
927
978
  * {
928
979
  * "type": "oauthJwtBearer",
929
980
  * "options": {
930
- * "azureTenantId": "<Entra ID azureTenantId", // Entra ID authentication, if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/v1.0]
981
+ * "azureTenantId": "<Entra ID azureTenantId", // Entra ID authentication, if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
931
982
  * "clientId": "<clientId>",
932
983
  * "tls": { // files located in ./config/certs
933
984
  * "key": "key.pem",
@@ -76,7 +76,7 @@
76
76
  import path from 'node:path'
77
77
 
78
78
  // start - mandatory plugin initialization
79
- import { ScimGateway, HelperRest } from 'scimgateway'
79
+ import { ScimGateway, HelperRest, LicenseData } from 'scimgateway'
80
80
  const scimgateway = new ScimGateway()
81
81
  const helper = new HelperRest(scimgateway)
82
82
  const config = scimgateway.getConfig()
@@ -84,7 +84,6 @@ scimgateway.authPassThroughAllowed = false
84
84
  scimgateway.pluginAndOrFilterEnabled = true
85
85
  // end - mandatory plugin initialization
86
86
 
87
- const newHelper = new HelperRest(scimgateway)
88
87
  const entitlementsByValues: Record<string, any> = {}
89
88
  const rolesByValues: Record<string, any> = {}
90
89
  const rolesAssignments: Record<string, any> = {}
@@ -94,18 +93,14 @@ const permission: Record<string, any> = {}
94
93
 
95
94
  // load Azure license mapping JSON-file having skuPartNumber and corresponding user-friendly name
96
95
  let fs: typeof import('fs')
97
- let licenseMapping: Record<string, any> = {}
96
+ let licenseMapping: Record<string, any> = LicenseData
98
97
  async function loadLicenseMapping() {
99
98
  try {
100
99
  if (!fs) fs = (await import('fs'))
100
+ // runtime override: if file exists in pluginDir, use it instead of the embedded default
101
101
  let mappingPath = path.join(scimgateway.pluginDir, 'azure-license-mapping.json')
102
102
  if (fs.existsSync(mappingPath)) {
103
103
  licenseMapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8'))
104
- } else {
105
- mappingPath = path.join(scimgateway.gwDir, 'azure-license-mapping.json')
106
- if (fs.existsSync(mappingPath)) {
107
- licenseMapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8'))
108
- }
109
104
  }
110
105
  } catch (err) {
111
106
  scimgateway.logDebug('plugin-entra-id', `Error loading license mapping: ${err}`)
@@ -252,7 +247,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
252
247
  }
253
248
  } else selectAttributes = userSelectAttributes
254
249
 
255
- if (!permission[baseEntity]?.signInActivity) { // remove signInActivity
250
+ if (permission[baseEntity]?.signInActivity === false) { // remove signInActivity
256
251
  const index = selectAttributes.indexOf('signInActivity')
257
252
  if (index > -1) {
258
253
  selectAttributes.splice(index, 1)
@@ -941,15 +936,14 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
941
936
  if (getObj.operator === 'eq' && ['id', 'displayName', 'externalId'].includes(getObj.attribute)) {
942
937
  // mandatory - unique filtering - single unique user to be returned - correspond to getUser() in versions < 4.x.x
943
938
  if (getObj.attribute === 'id') {
944
- if (includeMembers) path = `/groups/${getObj.value}?$select=${attrs.join()}&$expand=members($select=id,displayName)`
945
- else path = `/groups/${getObj.value}?$select=${attrs.join()}`
939
+ path = `/groups/${getObj.value}?$select=${attrs.join()}`
946
940
  } else {
947
- if (includeMembers) path = `/groups?$filter=${getObj.attribute} eq '${getObj.value}'&$select=${attrs.join()}&$expand=members($select=id,displayName)`
948
- else path = `/groups?$filter=${getObj.attribute} eq '${getObj.value}'&$select=${attrs.join()}`
941
+ path = `/groups?$filter=${getObj.attribute} eq '${getObj.value}'&$select=${attrs.join()}`
949
942
  }
950
943
  } else if (isUserMemberOf) {
951
944
  // mandatory - return all groups the user 'id' (getObj.value) is member of - correspond to getGroupMembers() in versions < 4.x.x
952
945
  // Resources = [{ id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }]
946
+ includeMembers = false
953
947
  path = `/users/${getObj.value}/transitiveMemberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&$select=id,displayName`
954
948
  } else {
955
949
  // optional - simpel filtering
@@ -961,8 +955,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
961
955
  throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
962
956
  } else {
963
957
  // mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all groups to be returned - correspond to exploreGroups() in versions < 4.x.x
964
- if (includeMembers) path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}&$expand=members($select=id,displayName)`
965
- else path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}`
958
+ path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}`
966
959
  }
967
960
  if (getObj.and || getObj.or) {
968
961
  // plugin have enabled 'scimgateway.pluginAndOrFilterEnabled' and the query includes an additonal and/or getObj that must to be handled and combined with the initial getObj
@@ -984,7 +977,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
984
977
  if (!ctx) ctx = { paging }
985
978
  else ctx.paging = paging
986
979
 
987
- const newCtx = { ...ctx }
980
+ const newCtx: Record<string, any> = ctx?.headers ? { headers: ctx?.headers } : {}
988
981
  newCtx.paging = { startIndex: 1 }
989
982
 
990
983
  try {
@@ -993,11 +986,11 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
993
986
  if (!isUserMemberOf) response = await helper.doRequest(baseEntity, method, path, body, ctx, options)
994
987
  else {
995
988
  // request both the default transitiveMemberOf (includes nested groups) and memberOf because we want to distinguish SCIM type=direct/indirect
996
- const pathMemberOf = `/users/${getObj.value}/memberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&$select=id,displayName`
989
+ let pathMemberOf = `/users/${getObj.value}/memberOf/microsoft.graph.group?$count=true&$select=id,displayName`
997
990
  const allErrors: string[] = []
998
991
  const results = await Promise.allSettled([
999
992
  helper.doRequest(baseEntity, method, path, body, ctx, options),
1000
- newHelper.doRequest(baseEntity, method, pathMemberOf, body, newCtx, options), // using newHelper to avoid shared internal helperRest paging
993
+ helper.doRequest(baseEntity, method, pathMemberOf, body, newCtx, options),
1001
994
  ])
1002
995
  const errors = results
1003
996
  .filter(r => r.status === 'rejected')
@@ -1010,30 +1003,23 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
1010
1003
  }
1011
1004
 
1012
1005
  response = (results[0] as PromiseFulfilledResult<any>).value // includes all groups (also nested)
1013
- responseMemberOf = (results[1] as PromiseFulfilledResult<any>).value // do not include nested groups
1014
-
1015
- let nextStartIndex = scimgateway.getNextStartIndex(responseMemberOf.body.value.length * 2, newCtx.paging.startIndex, responseMemberOf.body.value.length)
1016
- if (nextStartIndex > newCtx.paging.startIndex && responseMemberOf && responseMemberOf.body.value && Array.isArray(responseMemberOf.body.value)) {
1017
- // use paging to ensure responseMemberOf is complete
1018
- let totalResults = responseMemberOf.body.value.length
1019
- let startIndex = 1
1006
+ responseMemberOf = (results[1] as PromiseFulfilledResult<any>).value // does not include nested groups
1007
+ while (newCtx.paging.nextLink) {
1020
1008
  let res: any
1021
- do {
1022
- try {
1023
- startIndex = nextStartIndex
1024
- newCtx.paging.startIndex = startIndex
1025
- res = await newHelper.doRequest(baseEntity, method, pathMemberOf, body, newCtx, options)
1026
- } catch (err) { void 0 }
1027
- if (res?.body && res.body.value && Array.isArray(res.body.value) && res.body.value.length > 0) {
1028
- const count = res.body.value.length
1029
- totalResults += count
1030
- nextStartIndex = scimgateway.getNextStartIndex(totalResults + count, startIndex, count)
1031
- for (let i = 0; i < res.body.value.length; i++) {
1032
- if (!res.body.value[i].id) continue
1033
- responseMemberOf.body.value.push(res.body.value[i])
1034
- }
1009
+ pathMemberOf = newCtx.paging.nextLink
1010
+ delete newCtx.paging.nextLink
1011
+ try {
1012
+ res = await helper.doRequest(baseEntity, method, pathMemberOf, body, newCtx, options)
1013
+ } catch (err: any) {
1014
+ delete newCtx.paging.nextLink
1015
+ break
1016
+ }
1017
+ if (res?.body && res.body.value && Array.isArray(res.body.value) && res.body.value.length > 0) {
1018
+ for (let i = 0; i < res.body.value.length; i++) {
1019
+ if (!res.body.value[i].id) continue
1020
+ responseMemberOf.body.value.push(res.body.value[i])
1035
1021
  }
1036
- } while (nextStartIndex > startIndex)
1022
+ }
1037
1023
  }
1038
1024
 
1039
1025
  if (response.body && response.body.value && Array.isArray(response.body.value)) {
@@ -1047,6 +1033,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
1047
1033
  })
1048
1034
  }
1049
1035
  }
1036
+
1050
1037
  if (!response.body) {
1051
1038
  throw new Error(`invalid response: ${JSON.stringify(response)}`)
1052
1039
  }
@@ -1056,6 +1043,22 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
1056
1043
  }
1057
1044
 
1058
1045
  for (let i = 0; i < response.body.value.length; ++i) {
1046
+ if (includeMembers) {
1047
+ const id = response.body.value[i].id
1048
+ if (!id) break
1049
+ let path = `/groups/${id}/members?$select=id,displayName`
1050
+ const newCtx: Record<string, any> = ctx?.headers ? { headers: ctx?.headers } : {}
1051
+ newCtx.paging = { startIndex: 1 }
1052
+ const r = await helper.doRequest(baseEntity, 'GET', path, null, newCtx, options)
1053
+ response.body.value[i].members = r.body?.value || []
1054
+ while (newCtx.paging.nextLink) {
1055
+ const path = newCtx.paging.nextLink
1056
+ delete newCtx.paging.nextLink
1057
+ const r = await helper.doRequest(baseEntity, 'GET', path, null, newCtx, options)
1058
+ response.body.value[i].members.push(...r.body?.value || [])
1059
+ }
1060
+ }
1061
+
1059
1062
  let members: any
1060
1063
  if (response.body.value[i].members) {
1061
1064
  members = response.body.value[i].members.reduce((acc: any[], el: Record<string, any>) => {
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Auth0, Inc. <support@auth0.com> (http://auth0.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,66 @@
1
+ # Local `saml` Package
2
+
3
+ This folder contains a local copy of the [node-saml](https://github.com/auth0/node-saml) package (v4.0.0), originally authored by Auth0.
4
+ Licensed under the MIT License — see [LICENSE](./LICENSE).
5
+
6
+ ## Why This Exists
7
+
8
+ When compiling scimgateway plugins to standalone binaries using `bun build --compile`, the original `saml` npm package fails at runtime on any machine other than the build machine.
9
+
10
+ **Root cause:** The original `saml` package uses `fs.readFileSync` with `__dirname`-resolved paths to load XML template files (`saml11.template` and `saml20.template`) at module initialization:
11
+
12
+ ```js
13
+ // Original saml/lib/saml11.js
14
+ var newSaml11Document = utils.factoryForNode(path.join(__dirname, 'saml11.template'));
15
+ ```
16
+
17
+ ```js
18
+ // Original saml/lib/utils.js
19
+ exports.factoryForNode = function factoryForNode(pathToTemplate) {
20
+ const template = fs.readFileSync(pathToTemplate); // fails in compiled binary
21
+ ...
22
+ };
23
+ ```
24
+
25
+ When Bun compiles the binary, `__dirname` gets baked in as the **absolute path from the build machine** (e.g. `/Users/xxx/.../node_modules/saml/lib/`). The `.template` files are not embedded in the binary, so the `readFileSync` call fails with `ENOENT` when the binary runs elsewhere.
26
+
27
+ ## What Was Changed
28
+
29
+ Only one functional change was made — **templates are inlined as string constants** instead of being read from disk at runtime.
30
+
31
+ ### `utils.js`
32
+ - Removed `const fs = require('fs')` — no longer needed
33
+ - Changed `factoryForNode(pathToTemplate)` to `factoryForNode(templateString)` — accepts an XML string directly instead of a file path
34
+
35
+ ### `saml11.js` and `saml20.js`
36
+ - Removed `path.join(__dirname, '*.template')` file references
37
+ - Template XML is inlined as a string constant passed to `factoryForNode()`
38
+ - Removed `var path = require('path')` (no longer needed)
39
+
40
+ ### `xml/encrypt.js` and `xml/sign.js`
41
+ - Copied unchanged from the original package
42
+
43
+ ### `index.js`
44
+ - Copied unchanged from the original package
45
+
46
+ ## Import Change
47
+
48
+ In `lib/samlAssertion.ts`:
49
+ ```diff
50
+ -import { Saml20 as saml } from 'saml'
51
+ +import { Saml20 as saml } from './saml/index.js'
52
+ ```
53
+
54
+ ## Dependencies
55
+
56
+ The sub-dependencies used by this code remain as npm packages (not copied):
57
+ - `@xmldom/xmldom`
58
+ - `moment`
59
+ - `xml-crypto`
60
+ - `xml-encryption`
61
+ - `valid-url`
62
+ - `xml-name-validator`
63
+ - `async`
64
+ - `xpath`
65
+
66
+ The `saml` entry in `package.json` must be kept — it provides the sub-dependencies (@xmldom/xmldom, moment, xml-crypto, etc.) that this local copy requires.
@@ -0,0 +1,2 @@
1
+ exports.Saml11 = require('./saml11');
2
+ exports.Saml20 = require('./saml20');