scimgateway 6.2.2 → 6.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,130 @@
1
+ # Change Log
2
+
3
+ ### v6.2.4
4
+ - **[Improved]** `plugin-entra-id` now support reset users MFA capabilites and user will be forced to re-register MFA.
5
+
6
+ ### v6.2.3
7
+ - **[Improved]** `plugin-entra-id` now includes information on whether a user has registered for MFA (has an MFA-capable method registered).
8
+
9
+ ### v6.2.2
10
+ - **[Improved]** `plugin-entra-id` now supports Entra ID IGA Access Packages. For required API permissions, see Entra ID App Registration
11
+
12
+ ### v6.2.1
13
+ - `HelperRest`: fixed minor log cosmetics introduced in v6.2.0
14
+
15
+ ### v6.2.0
16
+ - **[Fixed]** `HelperRest`: failed on Bun v1.3.14 due to stricter Fetch standards compliance
17
+ - **[Improved]** New `plugin-generic` replaces `plugin-scim`. Uses `endpointMapper` with the new `valueMap` option for group allowlisting and name mapping. Default config uses one-to-one SCIM mapping with plugin-loki as the target endpoint.
18
+ - **[Improved]** `endpointMapper` now supports `valueMap`:
19
+
20
+ ```json
21
+ "map": {
22
+ "group": {
23
+ "displayName": {
24
+ "mapTo": "displayName",
25
+ "type": "string",
26
+ "valueMap": {
27
+ "outboundEndpointGrp1": "inboundScimGrp1",
28
+ "Employees": "Admins"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Clients only see and manage the SCIM-named groups (`inboundScimGrp1`, `Admins`), mapped to their endpoint counterparts (`outboundEndpointGrp1`, `Employees`). Useful for allowlisting specific groups or supporting different inbound/outbound names.
36
+
37
+ ### v6.1.20
38
+ - `plugin-entra-id`: roles introduced in v6.1.19 were missing when retrieving a single user
39
+
40
+ ### v6.1.19
41
+ - **[Fixed]** SCIM v2.0 ResourceType endpoint schemas using incorrect id
42
+ - **[Improved]** `GET /Roles` and `GET /Entitlements` endpoint support, with user management via SCIM `roles` and `entitlements` attributes
43
+ - **[Improved]** `plugin-entra-id`: `entitlements` for Entra ID licenses (read-only); `roles` for Permanent and Eligible PIM roles (full management)
44
+ - PIM Eligible roles: requires `RoleEligibilitySchedule.ReadWrite.All`
45
+ - PIM Permanent roles: requires `RoleManagement.ReadWrite.Directory`
46
+ - Remove `map.user.roles` if above conditions are not met
47
+ - `skipSignInActivity` option (v6.1.17) no longer used; `signInActivity` and PIM role permissions are validated at startup
48
+
49
+ ### v6.1.18
50
+ - `createUser` and `modifyUser` now return the full user object, ensuring returned data reflects what was modified even when the endpoint hasn't internally synced yet
51
+
52
+ ### v6.1.17
53
+ - `plugin-entra-id`: fixed broken `filter=userName eq "user_upn"` introduced in v6.1.11 when using updated config with `map.user.signInActivity`
54
+ - `plugin-entra-id`: new option `endpoint.entity.[baseEntity].skipSignInActivity = true` to exclude `signInActivity` (requires Entra ID Premium + `AuditLog.Read.All`)
55
+
56
+ ### v6.1.16
57
+ - `plugin-entra-id`: `GET /Entitlements` now uses `derivedIncludes` with full recursive expansion
58
+
59
+ ### v6.1.15
60
+ - `plugin-entra-id`: fixed `filter=entitlements pr`
61
+
62
+ ### v6.1.14
63
+ - Support for filter `attribute not pr`
64
+ - Dependencies bump
65
+
66
+ ### v6.1.13
67
+ - `plugin-entra-id`: `signInActivity` attributes are now filterable
68
+
69
+ ### v6.1.12
70
+ - Filter operator `pr` (presence) now forwarded to plugins (previously rejected)
71
+ - `plugin-entra-id`: handles `pr` filter on entitlements
72
+
73
+ ### v6.1.11
74
+ - **[Fixed]** Incorrect schema generation when using `endpointMapper` (regression from v6.1.6)
75
+ - **[Improved]** New `GET /Entitlements` endpoint and `scimgateway.getEntitlements()` method
76
+ - `plugin-entra-id`: user license information via `entitlements`; remove `map.user.signInActivity` if Entra ID Premium is unavailable
77
+
78
+ ### v6.1.10
79
+ - `plugin-entra-id`: group membership now includes nested (transitive) groups (`direct` and `indirect`)
80
+ - Fixed missing Docker files: `config/docker/.dockerignore` and `docker-compose-mssql.yml`
81
+
82
+ ### v6.1.9
83
+ - `createUser`/`createGroup` responses now correctly include the generated ID
84
+
85
+ ### v6.1.8 / v6.1.7
86
+ - Fixed incorrect masking of secrets in request info log messages
87
+ - `plugin-entra-id`: fixed edge case where `createUser` with a manager could fail
88
+
89
+ ### v6.1.6
90
+ - Fixed `plugin-loki` and `plugin-mongodb` returning empty results when using extension schema attributes in search
91
+ - Auth failure due to `readOnly` now returns HTTP 405 instead of 401
92
+ - `postinstall` ensures `"type": "module"` is set in `package.json`
93
+ - `endpointMapper` now generates a custom schema; supports `"x-agent-schema"` for AI MCP tool instructions
94
+
95
+ ### v6.1.5
96
+ - Complex filtering (`and`/`or`) handled by the gateway using the plugin's simple filter logic
97
+ - `modifyGroup` now returns HTTP 204 instead of 200
98
+ - New `/auth` endpoint for validating external authentication
99
+ - `plugin-entra-id`: supports `sw` (startsWith) filter
100
+
101
+ ### v6.1.4
102
+ - Fixed OData paging in `plugin-entra-id` and `helper-rest` — missing users/groups/members in large directories
103
+ - Fixed incomplete group membership when paging not fully iterated
104
+
105
+ ### v6.1.3
106
+ - Azure Relay: improved recovery on failure
107
+ - `plugin-ldap`: improvements for Active Directory and `objectGUID`/`mS-DS-ConsistencyGuid`
108
+ - `modifyGroup`: adding an existing member or removing a non-existent member now returns 200 OK instead of an error
109
+
110
+ ### v6.1.2
111
+ - Fixed SMTP mail failure caused by an updated dependency
112
+ - Fixed `endpointMapper` when `mapTo` contained multiple comma-separated attributes including a multivalued one
113
+
114
+ ### v6.1.1
115
+ - `plugin-ldap`: fixed race condition where `createUser` immediately followed by `readUser` could fail on some systems (e.g. Samba AD)
116
+ - Final info log message now includes full JSON serialization (durationMs, status, requestBody, responseBody, …)
117
+
118
+ ### v6.1.0
119
+ - `tsx` included — SCIM Gateway now runs as ES module (TypeScript) in Node.js: `node --import=tsx ./index.ts`
120
+ - Simplified mandatory plugin initialization using static `import`
121
+ - `index.ts` updated to use static imports
122
+ - Bun binary builds now supported (see Single Binary Deployment)
123
+
124
+ ### v6.0.0 — Major
125
+ - API method response bodies returned as-is (previously wrapped in `{ result: <content> }`) — **clients parsing responses must be updated**
126
+ - New `scimgateway.publicApi()` for unauthenticated `/pub/api` routes
127
+ - `bearerJwtAzure.tenantIdGUID` replaced by `bearerJwt.azureTenantId` — **existing configurations must be updated**
128
+
129
+ ### v5.x — Previous Major Series
130
+ For v5.x change history (Bun/TypeScript migration, Azure Relay, Bulk Operations, SCIM Stream, HelperRest, Docker, email OAuth, and more), see the GitHub commit history.
package/README.md CHANGED
@@ -76,37 +76,12 @@ SCIM Gateway is a user provisioning bridge built with [Bun](https://bun.sh/) and
76
76
  - [Custom Schemas](#custom-schemas)
77
77
  - [License](#license)
78
78
  - [Change Log](#change-log)
79
- - [v6.2.2](#v622)
80
- - [v6.2.1](#v621)
81
- - [v6.2.0](#v620)
82
- - [v6.1.20](#v6120)
83
- - [v6.1.19](#v6119)
84
- - [v6.1.18](#v6118)
85
- - [v6.1.17](#v6117)
86
- - [v6.1.16](#v6116)
87
- - [v6.1.15](#v6115)
88
- - [v6.1.14](#v6114)
89
- - [v6.1.13](#v6113)
90
- - [v6.1.12](#v6112)
91
- - [v6.1.11](#v6111)
92
- - [v6.1.10](#v6110)
93
- - [v6.1.9](#v619)
94
- - [v6.1.8 / v6.1.7](#v618--v617)
95
- - [v6.1.6](#v616)
96
- - [v6.1.5](#v615)
97
- - [v6.1.4](#v614)
98
- - [v6.1.3](#v613)
99
- - [v6.1.2](#v612)
100
- - [v6.1.1](#v611)
101
- - [v6.1.0](#v610)
102
- - [v6.0.0 — Major](#v600--major)
103
- - [v5.x — Previous Major Series](#v5x--previous-major-series)
104
79
 
105
80
  ---
106
81
 
107
82
  ## What's New
108
83
 
109
- - **`plugin-entra-id`** now supports Entra ID roles and access packages, in addition to reading licenses.
84
+ - **`plugin-entra-id`** now supports Entra ID roles, access packages and MFA, in addition to reading licenses.
110
85
  - **`plugin-generic`** replaces `plugin-scim` — a flexible template using `endpointMapper` with the new `valueMap` option for allowlisting and name mapping e.g., groups
111
86
  - **`GET /Roles` and `GET /Entitlements`** endpoint support, with user management via SCIM `roles` and `entitlements` attributes; `plugin-entra-id` uses `entitlements` for Entra ID licenses (read-only) and `roles` for Permanent and Eligible PIM roles (full management)
112
87
  - **AI Agent ready** — `x-agent-schema` configuration in `endpointMapper` enables custom schema generation with MCP tool instructions for autonomous provisioning agents
@@ -1077,8 +1052,9 @@ The `baseEntity` parameter enables multi-tenant setups — create multiple endpo
1077
1052
  4. **API permissions → Add → Microsoft Graph → Application permissions:**
1078
1053
  - `Directory.ReadWriteAll`
1079
1054
  - `Organization.ReadWrite.All`
1080
- - Additional for signInActivity, roles, licenses and access packages:
1081
- - `AuditLog.Read.All` *(only if using `map.user.signInActivity`; requires Entra ID Premium)*
1055
+ - Additional for signInActivity, MFA, roles, and access packages:
1056
+ - `AuditLog.Read.All` *(sign-in activity; only if using `map.user.signInActivity`; requires Entra ID Premium)*
1057
+ - `UserAuthenticationMethod.ReadWrite.All` *(MFA information and reset; only if using `map.user.mfa`)*
1082
1058
  - `RoleEligibilitySchedule.ReadWrite.Directory` *(PIM Eligible roles; only if using `map.user.roles`)*
1083
1059
  - `RoleManagement.ReadWrite.Directory` *(PIM Permanent roles; only if using `map.user.roles`)*
1084
1060
  - `EntitlementManagement.ReadWrite.All` *(IGA Access Packages; only if using `map.user.entitlements`)*
@@ -1087,7 +1063,7 @@ The `baseEntity` parameter enables multi-tenant setups — create multiple endpo
1087
1063
 
1088
1064
  > For full access to admin users, assign the `Global Administrator` role. The `User Administrator` role has limitations on users with admin roles.
1089
1065
 
1090
- > `signInActivity, roles, licenses and access packages` requires permissions above. Note, `ReadWrite` can be replaced with `Read` if management is not required. **Remove any mapping configuration whose conditions are not met** — Minimum read permissions are validated at startup.
1066
+ > Note, if MFA, PIM, and Access Package `management` is not required, `ReadWrite` can be replaced with `Read`. **Remove any mapping configuration whose conditions are not met** — The minimum `Read` permissions are validated at startup.
1091
1067
 
1092
1068
  ### Plugin Configuration
1093
1069
 
@@ -1279,126 +1255,4 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1279
1255
  ---
1280
1256
 
1281
1257
  ## Change Log
1282
-
1283
- ### v6.2.2
1284
- - **[Improved]** `plugin-entra-id` now supports Entra ID IGA Access Packages. For required API permissions, see [Entra ID App Registration](#entra-id-app-registration)
1285
-
1286
- ### v6.2.1
1287
- - `HelperRest`: fixed minor log cosmetics introduced in v6.2.0
1288
-
1289
- ### v6.2.0
1290
- - **[Fixed]** `HelperRest`: failed on Bun v1.3.14 due to stricter Fetch standards compliance
1291
- - **[Improved]** New `plugin-generic` replaces `plugin-scim`. Uses `endpointMapper` with the new `valueMap` option for group allowlisting and name mapping. Default config uses one-to-one SCIM mapping with plugin-loki as the target endpoint.
1292
- - **[Improved]** `endpointMapper` now supports `valueMap`:
1293
-
1294
- ```json
1295
- "map": {
1296
- "group": {
1297
- "displayName": {
1298
- "mapTo": "displayName",
1299
- "type": "string",
1300
- "valueMap": {
1301
- "outboundEndpointGrp1": "inboundScimGrp1",
1302
- "Employees": "Admins"
1303
- }
1304
- }
1305
- }
1306
- }
1307
- ```
1308
-
1309
- Clients only see and manage the SCIM-named groups (`inboundScimGrp1`, `Admins`), mapped to their endpoint counterparts (`outboundEndpointGrp1`, `Employees`). Useful for allowlisting specific groups or supporting different inbound/outbound names.
1310
-
1311
- ### v6.1.20
1312
- - `plugin-entra-id`: roles introduced in v6.1.19 were missing when retrieving a single user
1313
-
1314
- ### v6.1.19
1315
- - **[Fixed]** SCIM v2.0 ResourceType endpoint schemas using incorrect id
1316
- - **[Improved]** `GET /Roles` and `GET /Entitlements` endpoint support, with user management via SCIM `roles` and `entitlements` attributes
1317
- - **[Improved]** `plugin-entra-id`: `entitlements` for Entra ID licenses (read-only); `roles` for Permanent and Eligible PIM roles (full management)
1318
- - PIM Eligible roles: requires `RoleEligibilitySchedule.ReadWrite.All`
1319
- - PIM Permanent roles: requires `RoleManagement.ReadWrite.Directory`
1320
- - Remove `map.user.roles` if above conditions are not met
1321
- - `skipSignInActivity` option (v6.1.17) no longer used; `signInActivity` and PIM role permissions are validated at startup
1322
-
1323
- ### v6.1.18
1324
- - `createUser` and `modifyUser` now return the full user object, ensuring returned data reflects what was modified even when the endpoint hasn't internally synced yet
1325
-
1326
- ### v6.1.17
1327
- - `plugin-entra-id`: fixed broken `filter=userName eq "user_upn"` introduced in v6.1.11 when using updated config with `map.user.signInActivity`
1328
- - `plugin-entra-id`: new option `endpoint.entity.[baseEntity].skipSignInActivity = true` to exclude `signInActivity` (requires Entra ID Premium + `AuditLog.Read.All`)
1329
-
1330
- ### v6.1.16
1331
- - `plugin-entra-id`: `GET /Entitlements` now uses `derivedIncludes` with full recursive expansion
1332
-
1333
- ### v6.1.15
1334
- - `plugin-entra-id`: fixed `filter=entitlements pr`
1335
-
1336
- ### v6.1.14
1337
- - Support for filter `attribute not pr`
1338
- - Dependencies bump
1339
-
1340
- ### v6.1.13
1341
- - `plugin-entra-id`: `signInActivity` attributes are now filterable
1342
-
1343
- ### v6.1.12
1344
- - Filter operator `pr` (presence) now forwarded to plugins (previously rejected)
1345
- - `plugin-entra-id`: handles `pr` filter on entitlements
1346
-
1347
- ### v6.1.11
1348
- - **[Fixed]** Incorrect schema generation when using `endpointMapper` (regression from v6.1.6)
1349
- - **[Improved]** New `GET /Entitlements` endpoint and `scimgateway.getEntitlements()` method
1350
- - `plugin-entra-id`: user license information via `entitlements`; remove `map.user.signInActivity` if Entra ID Premium is unavailable
1351
-
1352
- ### v6.1.10
1353
- - `plugin-entra-id`: group membership now includes nested (transitive) groups (`direct` and `indirect`)
1354
- - Fixed missing Docker files: `config/docker/.dockerignore` and `docker-compose-mssql.yml`
1355
-
1356
- ### v6.1.9
1357
- - `createUser`/`createGroup` responses now correctly include the generated ID
1358
-
1359
- ### v6.1.8 / v6.1.7
1360
- - Fixed incorrect masking of secrets in request info log messages
1361
- - `plugin-entra-id`: fixed edge case where `createUser` with a manager could fail
1362
-
1363
- ### v6.1.6
1364
- - Fixed `plugin-loki` and `plugin-mongodb` returning empty results when using extension schema attributes in search
1365
- - Auth failure due to `readOnly` now returns HTTP 405 instead of 401
1366
- - `postinstall` ensures `"type": "module"` is set in `package.json`
1367
- - `endpointMapper` now generates a custom schema; supports `"x-agent-schema"` for AI MCP tool instructions
1368
-
1369
- ### v6.1.5
1370
- - Complex filtering (`and`/`or`) handled by the gateway using the plugin's simple filter logic
1371
- - `modifyGroup` now returns HTTP 204 instead of 200
1372
- - New `/auth` endpoint for validating external authentication
1373
- - `plugin-entra-id`: supports `sw` (startsWith) filter
1374
-
1375
- ### v6.1.4
1376
- - Fixed OData paging in `plugin-entra-id` and `helper-rest` — missing users/groups/members in large directories
1377
- - Fixed incomplete group membership when paging not fully iterated
1378
-
1379
- ### v6.1.3
1380
- - Azure Relay: improved recovery on failure
1381
- - `plugin-ldap`: improvements for Active Directory and `objectGUID`/`mS-DS-ConsistencyGuid`
1382
- - `modifyGroup`: adding an existing member or removing a non-existent member now returns 200 OK instead of an error
1383
-
1384
- ### v6.1.2
1385
- - Fixed SMTP mail failure caused by an updated dependency
1386
- - Fixed `endpointMapper` when `mapTo` contained multiple comma-separated attributes including a multivalued one
1387
-
1388
- ### v6.1.1
1389
- - `plugin-ldap`: fixed race condition where `createUser` immediately followed by `readUser` could fail on some systems (e.g. Samba AD)
1390
- - Final info log message now includes full JSON serialization (durationMs, status, requestBody, responseBody, …)
1391
-
1392
- ### v6.1.0
1393
- - `tsx` included — SCIM Gateway now runs as ES module (TypeScript) in Node.js: `node --import=tsx ./index.ts`
1394
- - Simplified mandatory plugin initialization using static `import`
1395
- - `index.ts` updated to use static imports
1396
- - Bun binary builds now supported (see [Single Binary Deployment](#single-binary-deployment))
1397
-
1398
- ### v6.0.0 — Major
1399
- - API method response bodies returned as-is (previously wrapped in `{ result: <content> }`) — **clients parsing responses must be updated**
1400
- - New `scimgateway.publicApi()` for unauthenticated `/pub/api` routes
1401
- - `bearerJwtAzure.tenantIdGUID` replaced by `bearerJwt.azureTenantId` — **existing configurations must be updated**
1402
-
1403
- ### v5.x — Previous Major Series
1404
- For v5.x change history (Bun/TypeScript migration, Azure Relay, Bulk Operations, SCIM Stream, HelperRest, Docker, email OAuth, and more), see the [GitHub commit history](https://github.com/jelhub/scimgateway/commits/master/).
1258
+ For a detailed history of changes, please see the [Change Log](https://github.com/jelhub/scimgateway/blob/master/CHANGELOG.md).
@@ -171,6 +171,7 @@
171
171
  "comment": "This mapping requires Entra ID Premium license and API permissions: 'AuditLog.Read.All'. Remove this mapping if conditions not met",
172
172
  "mapTo": "signInActivity",
173
173
  "type": "complexObject",
174
+ "mutability": "readOnly",
174
175
  "subAttributes": [
175
176
  {
176
177
  "name": "lastSignInDateTime",
@@ -189,7 +190,36 @@
189
190
  }
190
191
  ],
191
192
  "x-agent-schema": {
192
- "description": "Read-only attribute-object representing Entra ID signInActivity like: lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime. Note: Only availabe for users having P1/P2 licenses. If signInActivity is missing, agent should not consider this to be no sign-in activity for the user.",
193
+ "description": "Read-only attribute object representing Entra ID signInActivity like: lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime. Note: Only availabe for users having P1/P2 licenses. If signInActivity is missing, agent should not consider this to be no sign-in activity for the user.",
194
+ "readOnly": true
195
+ }
196
+ },
197
+ "mfa": {
198
+ "mapTo": "mfa",
199
+ "type": "complexObject",
200
+ "mutability": "readOnly",
201
+ "subAttributes": [
202
+ {
203
+ "name": "isMfaCapable",
204
+ "mutability": "readOnly",
205
+ "description": "true/false - user has at least one MFA-capable method registered (members only; for guests, methods are in home tenant and enforcement is via Conditional Access).",
206
+ "type": "boolean"
207
+ },
208
+ {
209
+ "name": "isLegacyEnabled",
210
+ "mutability": "readOnly",
211
+ "description": "true/false - legacy per-user MFA is enabled ('perUserMfaState' is 'enabled' or 'enforced'); such users bypass/aren´t fully controlled by standard Conditional Access only design and therefore require special attention.",
212
+ "type": "boolean"
213
+ },
214
+ {
215
+ "name": "reset",
216
+ "mutability": "writeOnly",
217
+ "description": "Set to 'true' will reset users MFA capabilites and user will be forced to re-register MFA.",
218
+ "type": "boolean"
219
+ }
220
+ ],
221
+ "x-agent-schema": {
222
+ "description": "Read-only attribute object (except for 'mfa.reset') presenting the user's MFA capabilities. Note: 'mfa.isMfaCapable' does not mean MFA is enabled for the user. Conditional Access policies or using Security Defaults must be verified separately.",
193
223
  "readOnly": true
194
224
  }
195
225
  },
@@ -705,8 +705,11 @@ export class HelperRest {
705
705
  if (f.status > 399) {
706
706
  if (f.status === 429) { // throttle
707
707
  const v = f.headers.get('retry-after')
708
- if (v) retryAfter = parseInt(v, 10) + 1
709
- else retryAfter = 10
708
+ if (v) {
709
+ retryAfter = parseInt(v, 10)
710
+ if (isNaN(retryAfter)) retryAfter = 10
711
+ retryAfter += 1
712
+ } else retryAfter = 10
710
713
  }
711
714
  throw new Error(JSON.stringify(result))
712
715
  }
@@ -765,6 +768,12 @@ export class HelperRest {
765
768
  if (err.message.includes('ratelimit')) { // have seen throttling not follow standard 429/retry-after, but instead using 500 and error message only
766
769
  if (!retryAfter) retryAfter = 60
767
770
  }
771
+
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
+
768
777
  if (!retryCount) retryCount = 0
769
778
  let urlObj
770
779
  try { urlObj = new URL(path) } catch (err) { void 0 }
@@ -773,12 +782,6 @@ export class HelperRest {
773
782
 
774
783
  if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || statusCode === 504 || oAuthTokeErr || retryAfter)) {
775
784
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
776
- if (retryAfter) {
777
- this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
778
- await new Promise(resolve => setTimeout(function () {
779
- resolve(null)
780
- }, retryAfter * 1000))
781
- }
782
785
  if (retryCount < connectionObj.baseUrls.length) {
783
786
  retryCount++
784
787
  if (isServiceClient) {
@@ -791,13 +794,13 @@ export class HelperRest {
791
794
  const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
792
795
  return ret // problem fixed
793
796
  } else {
794
- if (statusCode === 404) { // not logged as error e.g. getUser-manager
797
+ if (statusCode === 404 || ctx?.skipLogAsError) {
795
798
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
796
799
  } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
797
800
  throw err
798
801
  }
799
802
  } else {
800
- if (statusCode === 404) { // not logged as error e.g. getUser-manager
803
+ if (statusCode === 404 || ctx?.skipLogAsError) { // 404 not logged as error e.g. getUser-manager
801
804
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
802
805
  } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
803
806
  if (statusCode === 401) delete this._serviceClient[baseEntity]
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Author: Jarle Elshaug
5
5
  //
6
- // Purpose: Entra ID provisioning including licenses e.g. O365
6
+ // Purpose: Entra ID user and group provisioning including roles and access packages in addition to retrieving license, MFA and sign-in information
7
7
  //
8
8
  // Prereq: Entra ID configuration:
9
9
  // Entra Application key defined (clientsecret). Other options are upload a certificate or configure "Federated Identity Credentials"
@@ -21,14 +21,15 @@
21
21
  // Schema generated according mapping configuration.
22
22
  // Note:
23
23
  // - 'map.user.signInActivity' requires Entra ID Premium license and API permissions 'AuditLog.Read.All'.
24
+ // - 'map.user.mfa' provides MFA status information and requires API permissions 'UserAuthenticationMethod.Read.All'
24
25
  // - 'map.user.entitlements' relates to Licenses and Access Packages. Access Packages requires API permissions 'EntitlementManagement.ReadWrite.All'
25
26
  // - 'map.user.roles relates to standard Permanent roles and PIM Permanent and Eligible roles.
26
27
  // PIM is included on tenant having P2 or Governance License and requires following API permissions:
27
28
  // - PIM Eligible roles requires API permissions 'RoleEligiblitySchedule.ReadWrite.All'
28
29
  // - PIM Permanent roles requires API permissions 'RoleManagement.ReadWrite.Directory'
29
- // - Remove mapping if conditions not met
30
+ // - Remove mapping if conditions not met or consider using only 'Read' if no management needed
30
31
  //
31
- // /User SCIM (custom) Endpoint (AAD)
32
+ // /User SCIM (custom) Endpoint (Entra ID)
32
33
  // --------------------------------------------------------------------------------------------
33
34
  // User Principal Name userName userPrincipalName
34
35
  // Id id id
@@ -61,9 +62,10 @@
61
62
  // Groups groups - virtual readOnly N/A
62
63
  // Roles roles roles (roleAssignments/roleEligibilitySchedules) - type=Permanent/Eligiable, value=id, display=role display name
63
64
  // Entitlements entitlements entitlements (assignedLicenses) - type=License, value=skuId and display=user-friendly-license-name / type=AccessPackage, value=AP-id and display=AP-displayName
64
- // SignInActivity signInActivity signInActivity (lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime), Note: Requires Entra ID Premium license and API permissions: 'AuditLog.Read.All'. Remove this mapping if conditions not met".
65
+ // SignInActivity signInActivity signInActivity (lastSignInDateTime, lastSuccessfulSignInDateTime and lastNonInteractiveSignInDateTime)
66
+ // MFA mfa mfa (mfa.isMfaCapable and mfa.isLegacyEnabled)
65
67
  //
66
- // /Group SCIM (custom) Endpoint (AAD)
68
+ // /Group SCIM (custom) Endpoint (Entra ID)
67
69
  // --------------------------------------------------------------------------------------------
68
70
  // Name displayName displayName
69
71
  // Id id id
@@ -122,6 +124,7 @@ for (const key in config.map.user) { // mapAttributesTo = ['id', 'country', 'pre
122
124
  let attr = key.split('.')[0]
123
125
  // complexArray/complexObject are special
124
126
  if (config.map.user[key].mapTo === 'entitlements') attr = 'assignedLicenses'
127
+ if (config.map.user[key].mapTo === 'mfa') attr = 'perUserMfaState'
125
128
  if (config.map.user[key].mapTo === 'roles') continue
126
129
 
127
130
  if (!userSelectAttributes.includes(attr)) userSelectAttributes.push(attr)
@@ -147,22 +150,32 @@ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.va
147
150
  for (const baseEntity in config.entity) {
148
151
  try {
149
152
  permission[baseEntity] = {}
150
- const [signInResult, eligibleResult, permanentScheduleResult, accessPackageResult] = await Promise.allSettled([
153
+ let probeUserId: string | undefined
154
+ try {
155
+ const res = await helper.doRequest(baseEntity, 'GET', '/users?$top=1&$select=id', null, null)
156
+ probeUserId = res?.body?.value?.[0]?.id
157
+ } catch (err) { }
158
+
159
+ const [signInResult, eligibleResult, permanentScheduleResult, accessPackageResult, mfaResult] = await Promise.allSettled([
151
160
  (async () => {
152
161
  if (!mapAttributesTo.includes('signInActivity')) throw new Error('skipping signInActivity check')
153
- await helper.doRequest(baseEntity, 'GET', '/users?$top=1&$select=id,signInActivity', null, null)
162
+ await helper.doRequest(baseEntity, 'GET', '/users?$top=1&$select=id,signInActivity', null, { skipLogAsError: true })
154
163
  })(),
155
164
  (async () => {
156
165
  if (!mapAttributesTo.includes('roles')) throw new Error('skipping eligible check')
157
- await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleEligibilityScheduleInstances?$top=1', null, null)
166
+ await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleEligibilityScheduleInstances?$top=1', null, { skipLogAsError: true })
158
167
  })(),
159
168
  (async () => {
160
169
  if (!mapAttributesTo.includes('roles')) throw new Error('skipping permanent schedule check')
161
- await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleAssignmentScheduleInstances?$top=1', null, null)
170
+ await helper.doRequest(baseEntity, 'GET', '/roleManagement/directory/roleAssignmentScheduleInstances?$top=1', null, { skipLogAsError: true })
162
171
  })(),
163
172
  (async () => {
164
173
  if (!mapAttributesTo.includes('entitlements')) throw new Error('skipping access package check')
165
- await helper.doRequest(baseEntity, 'GET', '/identityGovernance/entitlementManagement/accessPackages?$top=1&$select=id', null, null)
174
+ await helper.doRequest(baseEntity, 'GET', '/identityGovernance/entitlementManagement/accessPackages?$top=1&$select=id', null, { skipLogAsError: true })
175
+ })(),
176
+ (async () => {
177
+ if (!mapAttributesTo.includes('mfa') || !probeUserId) throw new Error('skipping mfaMethods check')
178
+ await helper.doRequest(baseEntity, 'GET', `/users/${probeUserId}/authentication/methods`, null, { skipLogAsError: true })
166
179
  })(),
167
180
  ])
168
181
  if (signInResult.status === 'fulfilled') {
@@ -175,19 +188,25 @@ if (!groupAttributes.includes('members.value')) groupAttributes.push('members.va
175
188
  permission[baseEntity].eligible = true
176
189
  } else {
177
190
  permission[baseEntity].eligible = false
178
- if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM eligible role functionality has been deactivated because it requires either a P2 or Governance license, as well as the API permission 'RoleEligibilitySchedule.ReadWrite.All'`)
191
+ if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM eligible role functionality has been deactivated because it requires either a P2 or Governance license, as well as the minimum API permission 'RoleEligibilitySchedule.Read.All'`)
179
192
  }
180
193
  if (permanentScheduleResult.status === 'fulfilled') {
181
194
  permission[baseEntity].permanentSchedule = true
182
195
  } else {
183
196
  permission[baseEntity].permanentSchedule = false
184
- if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM permanent role functionality has been deactivated because it requires either a P2 or Governance license, as well as the API permission 'RoleManagement.ReadWrite.Directory'`)
197
+ if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `PIM permanent role functionality has been deactivated because it requires either a P2 or Governance license, as well as the minimum API permission 'RoleManagement.Read.Directory'`)
185
198
  }
186
199
  if (accessPackageResult.status === 'fulfilled') {
187
200
  permission[baseEntity].accessPackage = true
188
201
  } else {
189
202
  permission[baseEntity].accessPackage = false
190
- if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `IGA Access Packages functionality has been deactivated because it requires API permission 'EntitlementManagement.ReadWrite.All'`)
203
+ if (mapAttributesTo.includes('roles')) scimgateway.logError(baseEntity, `IGA Access Packages functionality has been deactivated because it requires minimum API permission 'EntitlementManagement.Read.All'`)
204
+ }
205
+ if (mfaResult.status === 'fulfilled') {
206
+ permission[baseEntity].mfa = true
207
+ } else {
208
+ permission[baseEntity].mfa = false
209
+ if (mapAttributesTo.includes('mfa')) scimgateway.logError(baseEntity, `MFA Methods functionality has been deactivated because it requires API permissions 'UserAuthenticationMethod.Read.All'`)
191
210
  }
192
211
  } catch (err) {}
193
212
  }
@@ -217,7 +236,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
217
236
  Resources: [],
218
237
  totalResults: null,
219
238
  }
220
- let response: any
239
+ let response: Record<string, any> = {}
221
240
  let selectAttributes: string[] = []
222
241
 
223
242
  if (attributes.length > 0) {
@@ -227,6 +246,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
227
246
  if (!attr) continue
228
247
  // complexArray/complexObject are special
229
248
  if (attribute.startsWith('entitlements')) attr = 'assignedLicenses'
249
+ if (attribute.startsWith('mfa')) attr = 'perUserMfaState'
230
250
  if (attribute.startsWith('roles')) continue
231
251
  if (!selectAttributes.includes(attr)) selectAttributes.push(attr)
232
252
  }
@@ -289,17 +309,18 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
289
309
  if (config.map.user[arr[0]] && ['complexArray', 'complexObject'].includes(config.map.user[arr[0]]?.type)) {
290
310
  if (arr[0] === 'roles') {
291
311
  if (type && type !== 'Permanent' && type !== 'Eligible') throw new Error(`${action} filter error: when using roles.type, the type must be either 'Permanent' or 'Eligible`)
312
+ if (!type && obj.operator === 'pr') obj = { attribute: 'roles.type' } // 'filter=roles pr' - precense => filter all objects having roles
292
313
  const o = await getUsersByRole(baseEntity, obj, (type) ? decodeURIComponent(type) as 'Permanent' | 'Eligible' : undefined, ctx)
293
-
294
314
  if (!Array.isArray(o) || o.length === 0) return ret
295
- const fnArr: { fn: () => Promise<any> }[] = []
315
+ const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
316
+ response.body = { value: [] }
317
+ if (!response.body?.value) response.body = { value: [] }
296
318
  for (const id of o) {
297
319
  const userPath = `/users/${id}?$select=${selectAttributes.join(',')}`
298
320
  const fn = () => helper.doRequest(baseEntity, 'GET', userPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
299
- fnArr.push({ fn })
321
+ fnArr.push({ fn, objArr: response.body.value })
300
322
  }
301
- response = { body: { value: [] } }
302
- await fnCunckExecute(fnArr, response.body.value) // fnCunckExecute results in response.body.value and evaluated later
323
+ await fnCunckExecute(fnArr) // fnCunckExecute results in response.body.value and evaluated later
303
324
  if (response.body.value.length === 0) return ret
304
325
  } else if (arr[0] === 'entitlements') { // using entitlements for licenses and access packages
305
326
  if (getObj.attribute !== 'entitlements.type' && getObj.and?.attribute !== 'entitlements.type') throw new Error(`${action} filter error: mandatory entitlements.type is missing, examples: entitlements[type eq "xxx"], entitlements[type eq "xxx" and value eq "xxx"], entitlements[type eq "xxx" and display <eq/co/sw> "xxx"]`)
@@ -325,19 +346,20 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
325
346
  }
326
347
  if (typeof o !== 'object' || o === null || Object.keys(o).length === 0) return ret
327
348
  const isAttrsOk = attributes.length > 0 && attributes.length < 3 && (attributes.includes('id') || attributes.includes('displayName'))
328
- const fnArr: { fn: () => Promise<any> }[] = []
349
+ const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
350
+ response.body = { value: [] }
351
+ if (!response.body?.value) response.body = { value: [] }
329
352
  for (const key in o) {
330
353
  if (isAttrsOk) ret.Resources.push(o[key])
331
354
  else {
332
355
  const userPath = `/users/${key}?$select=${selectAttributes.join(',')}`
333
356
  const fn = () => helper.doRequest(baseEntity, 'GET', userPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
334
- fnArr.push({ fn })
357
+ fnArr.push({ fn, objArr: response.body.value })
335
358
  }
336
359
  }
337
360
  if (isAttrsOk) return ret
338
361
  else {
339
- response = { body: { value: [] } }
340
- await fnCunckExecute(fnArr, response.body.value) // fnCunckExecute results in response.body.value and evaluated later
362
+ await fnCunckExecute(fnArr)
341
363
  if (response.body.value.length === 0) return ret
342
364
  }
343
365
  } else throw new Error(`${action} error: entitlements.type must be either "License" or "AccessPackage"`)
@@ -412,31 +434,30 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
412
434
  if (!response.body.value) {
413
435
  throw new Error(`invalid response: ${JSON.stringify(response)}`)
414
436
  }
415
- const fnArr: { index: number, fn: () => Promise<any> }[] = []
437
+ const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
416
438
  const byValues = await getEntitlementsByValues(baseEntity, ctx)
417
439
 
418
- // include manager
419
- if (!isExpandManager && selectAttributes.includes('manager')) {
420
- for (let i = 0; i < response.body.value.length; ++i) {
421
- if (!response.body.value[i].id) break
440
+ for (let i = 0; i < response.body.value.length; ++i) {
441
+ if (!response.body.value[i].id) break
442
+
443
+ // include manager
444
+ if (!isExpandManager && selectAttributes.includes('manager')) {
422
445
  const singleUserPath = `/users/${response.body.value[i].id}/manager?$select=userPrincipalName`
423
446
  const fn = () => helper.doRequest(baseEntity, 'GET', singleUserPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined, options)
424
- fnArr.push({ index: i, fn })
447
+ // fnArr.push({ index: i, fn })
448
+ fnArr.push({ index: i, fn, objArr: response.body.value, key: 'manager' })
425
449
  }
426
- await fnCunckExecute(fnArr, response.body.value, 'manager')
427
- }
428
450
 
429
- // include groups (before roles)
430
- if (attributes.length === 0 || attributes.includes('groups')) {
431
- for (let i = 0; i < response.body.value.length; ++i) {
432
- if (!response.body.value[i].id) break
451
+ // include groups (before roles)
452
+ if (attributes.length === 0 || attributes.includes('groups')) {
433
453
  const fn = () => scimgateway.getUserGroups(baseEntity, response.body.value[i].id, ctx?.headers ? { headers: ctx?.headers } : undefined)
434
- fnArr.push({ index: i, fn })
454
+ fnArr.push({ index: i, fn, objArr: response.body.value, key: 'groups' })
435
455
  }
436
- await fnCunckExecute(fnArr, response.body.value, 'groups')
437
456
  }
438
457
 
439
- // attribute cleanup and mapping
458
+ if (fnArr.length > 0) await fnCunckExecute(fnArr)
459
+
460
+ // attribute mapping and cleanup
440
461
  for (let i = 0; i < response.body.value.length; ++i) {
441
462
  const obj = response.body.value[i]
442
463
  if (obj.manager?.userPrincipalName) {
@@ -445,13 +466,19 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
445
466
  else delete obj.manager
446
467
  }
447
468
 
469
+ if (obj.perUserMfaState && permission[baseEntity]?.mfa) {
470
+ const isLegacyEnabled = obj.perUserMfaState === 'enabled' || obj.perUserMfaState === 'enforced'
471
+ if (!obj.mfa) obj.mfa = {}
472
+ obj.mfa.isLegacyEnabled = isLegacyEnabled
473
+ }
474
+
448
475
  if (obj.signInActivity) {
449
476
  delete obj.signInActivity.lastSignInRequestId
450
477
  delete obj.signInActivity.lastNonInteractiveSignInRequestId
451
478
  delete obj.signInActivity.lastSuccessfulSignInRequestId
452
479
  }
453
480
 
454
- // include roles and entitlements
481
+ // include roles, entitlements and MFA - MFA here and not in above fnArr/fnCunckExecute to reduce throttle noise
455
482
  if (obj.id) {
456
483
  const roles = async (obj: Record<string, any>): Promise<Record<string, any>[]> => {
457
484
  // roles type=Permanent/Eligible
@@ -476,12 +503,26 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
476
503
  }
477
504
  return result
478
505
  }
506
+ const mfa = async (obj: Record<string, any>): Promise<boolean | undefined> => {
507
+ // include MFA 'isMfaCapable', which indicates whether the user has registered authentication methods eligible for MFA.
508
+ if (attributes.length === 0 || attributes.includes('mfa') || attributes.includes('mfa.isMfaCapable')) {
509
+ if (permission[baseEntity]?.mfa) {
510
+ return await getUserisMfaCapable(baseEntity, obj.id, ctx?.headers ? { headers: ctx?.headers } : undefined)
511
+ } else return undefined
512
+ }
513
+ }
514
+
479
515
  const arrResolve = await Promise.all([
480
516
  roles(obj),
481
517
  entitlements(obj),
518
+ mfa(obj),
482
519
  ])
483
520
  obj.roles = arrResolve[0]
484
521
  obj.entitlements = arrResolve[1]
522
+ if (arrResolve[2] !== undefined) {
523
+ if (!obj.mfa) obj.mfa = {}
524
+ obj.mfa.isMfaCapable = arrResolve[2]
525
+ }
485
526
  }
486
527
 
487
528
  // map to inbound
@@ -562,7 +603,6 @@ scimgateway.deleteUser = async (baseEntity, id, ctx) => {
562
603
  const method = 'DELETE'
563
604
  const path = `/Users/${id}`
564
605
  const body = null
565
-
566
606
  try {
567
607
  await helper.doRequest(baseEntity, method, path, body, ctx)
568
608
  return (null)
@@ -570,6 +610,7 @@ scimgateway.deleteUser = async (baseEntity, id, ctx) => {
570
610
  throw new Error(`${action} error: ${err.message}`)
571
611
  }
572
612
  }
613
+
573
614
  // =================================================
574
615
  // modifyUser
575
616
  // =================================================
@@ -577,10 +618,6 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
577
618
  const action = 'modifyUser'
578
619
  scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)} passThrough=${ctx ? 'true' : 'false'}`)
579
620
 
580
- // roles and entitlements only supported for getUsers - readOnly
581
- // if (attrObj.roles) delete attrObj.roles
582
- // if (attrObj.entitlements) delete attrObj.entitlements
583
-
584
621
  const [parsedAttrObj]: Record<string, any>[] = scimgateway.endpointMapper('outbound', attrObj, config.map.user) // SCIM/CustomSCIM => endpoint attribute standard
585
622
  if (parsedAttrObj instanceof Error) throw (parsedAttrObj) // error object
586
623
 
@@ -600,6 +637,18 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
600
637
  return undefined
601
638
  }
602
639
 
640
+ // MFA Reset
641
+ if (Object.hasOwn(parsedAttrObj, 'mfa')) {
642
+ if (parsedAttrObj.mfa.reset === true) {
643
+ if (!permission[baseEntity]?.mfa || !permission[baseEntity]?.eligible) throw new Error(`${action} error: MFA reset is not supported by the endpoint - missing permissions.`)
644
+ const currentRoles = await getUserRoles(baseEntity, id, [], true, ctx)
645
+ const isPrivileged = currentRoles.some(r => isPrivilegedRole(r.value))
646
+ if (isPrivileged) throw new Error(`${action} error: MFA reset is not allowed for users with high-privilege roles for security reasons.`)
647
+ await resetUserMfa(baseEntity, id, ctx)
648
+ }
649
+ delete parsedAttrObj.mfa
650
+ }
651
+
603
652
  // Roles
604
653
  if (Object.hasOwn(parsedAttrObj, 'roles') && Array.isArray(parsedAttrObj.roles)) {
605
654
  const r: Record<string, any>[] = []
@@ -618,7 +667,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
618
667
  const res: Record<string, any> = { value: el.value, type: el.type }
619
668
  if (el.display) res.display = el.display
620
669
  if (el.operation === 'delete') {
621
- if (el.value === '62e90394-69f5-4237-9190-012177145e10') throw new Error(`${action} error: Removal of the 'Global Administrator' role is not allowed for security reasons.`)
670
+ if (isPrivilegedRole(el.value)) throw new Error(`${action} error: Removal of high-privilege roles is not allowed for security reasons.`)
622
671
  res.operation = el.operation
623
672
  }
624
673
  r.push(res)
@@ -774,7 +823,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
774
823
  const profile = () => { // patch
775
824
  return new Promise((resolve, reject) => {
776
825
  (async () => {
777
- if (JSON.stringify(parsedAttrObj) === '{}') return resolve(null)
826
+ if (Object.keys(parsedAttrObj).length === 0) return resolve(null)
778
827
  let res: any
779
828
  for (const key in parsedAttrObj) { // if object, the modified Entra ID object must contain all elements, if not they will be cleared e.g. employeeOrgData
780
829
  if (typeof parsedAttrObj[key] === 'object') { // get original object and merge
@@ -1682,6 +1731,85 @@ const getUserAccessPackages = async (baseEntity: string, userId: string, include
1682
1731
  return result
1683
1732
  }
1684
1733
 
1734
+ const isPrivilegedRole = (roleId: string): boolean => {
1735
+ return [
1736
+ '62e90394-69f5-4237-9190-012177145e10', // Global Administrator
1737
+ 'e8611ab8-c189-46e8-94e1-60213ab1f814', // Privileged Role Administrator
1738
+ '7be44c8a-adaf-4e2a-84d6-ab2649e08a13', // Privileged Authentication Administrator
1739
+ 'c4e39bd9-1100-46d3-8c65-fb160da0071f', // Authentication Administrator
1740
+ ].includes(roleId)
1741
+ }
1742
+
1743
+ const isMethodMfaCapable = (odataType: string): boolean => {
1744
+ return [
1745
+ '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod',
1746
+ '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod',
1747
+ '#microsoft.graph.phoneAuthenticationMethod',
1748
+ '#microsoft.graph.softwareOathAuthenticationMethod',
1749
+ '#microsoft.graph.fido2AuthenticationMethod',
1750
+ ].includes(odataType)
1751
+ }
1752
+
1753
+ const getAuthMethodCollectionFromODataType = (odataType: string): string => {
1754
+ const map: Record<string, string> = {
1755
+ '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod': 'microsoftAuthenticatorMethods',
1756
+ '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod': 'microsoftAuthenticatorMethods',
1757
+ '#microsoft.graph.phoneAuthenticationMethod': 'phoneMethods',
1758
+ '#microsoft.graph.softwareOathAuthenticationMethod': 'softwareOathMethods',
1759
+ '#microsoft.graph.fido2AuthenticationMethod': 'fido2Methods',
1760
+ '#microsoft.graph.emailAuthenticationMethod': 'emailMethods',
1761
+ '#microsoft.graph.temporaryAccessPassAuthenticationMethod': 'temporaryAccessPassMethods',
1762
+ '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod': 'windowsHelloForBusinessMethods',
1763
+ '#microsoft.graph.passwordAuthenticationMethod': 'passwordMethods',
1764
+ }
1765
+ const collection = map[odataType]
1766
+ return collection
1767
+ }
1768
+
1769
+ const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
1770
+ const action = 'getUserisMfaCapable'
1771
+ const method = 'GET'
1772
+ const body = null
1773
+ const path = `/users/${userId}/authentication/methods`
1774
+ const r = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
1775
+ if (!r.body?.value) {
1776
+ if (r.body?.id) r.body.value = [r.body]
1777
+ else throw new Error(`${action} error: invalid response: ${JSON.stringify(r)}`)
1778
+ }
1779
+ const methodTypes = r.body.value.map((m: any) => m['@odata.type'])
1780
+ if (methodTypes.length < 1) return false
1781
+ return methodTypes.some(isMethodMfaCapable)
1782
+ }
1783
+
1784
+ const resetUserMfa = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
1785
+ const action = 'resetUserMfa'
1786
+ const method = 'GET'
1787
+ const body = null
1788
+ const path = `/users/${userId}/authentication/methods`
1789
+ const r = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
1790
+ if (!r.body?.value) {
1791
+ if (r.body?.id) r.body.value = [r.body]
1792
+ else throw new Error(`${action} error: invalid response: ${JSON.stringify(r)}`)
1793
+ }
1794
+ const methods = r.body.value.map((m: any) => { return { 'id': m.id, '@odata.type': m['@odata.type'] } })
1795
+ if (methods.length < 1) return false
1796
+ const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
1797
+ for (const m of methods) {
1798
+ if (!isMethodMfaCapable(m['@odata.type'])) continue
1799
+ const methodName = getAuthMethodCollectionFromODataType(m['@odata.type'])
1800
+ if (!methodName) continue
1801
+ const path = `/users/${userId}/authentication/${methodName}/${m.id}`
1802
+ const fn = () => helper.doRequest(baseEntity, 'DELETE', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
1803
+ fnArr.push({ fn })
1804
+ }
1805
+ if (fnArr.length === 0) return false
1806
+ // include revoke sessions
1807
+ const fn = () => helper.doRequest(baseEntity, 'POST', `/users/${userId}/invalidateAllRefreshTokens`, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
1808
+ fnArr.push({ fn })
1809
+ await fnCunckExecute(fnArr)
1810
+ return true
1811
+ }
1812
+
1685
1813
  /**
1686
1814
  * getUsersByRole returns an array of user IDs having a specific role assigned
1687
1815
  * @param baseEntity
@@ -1724,23 +1852,24 @@ const getUsersByRole = async (baseEntity: string, getObj: Record<string, any>, t
1724
1852
 
1725
1853
  if (activePrincipals.size === 0) return []
1726
1854
 
1727
- // 3. Resolve Principals (determine if User or Group)
1855
+ // 3. Resolve principals (determine if user or group)
1728
1856
  const userIds = new Set<string>()
1729
- const principalsToResolve: { fn: () => Promise<any> }[] = []
1857
+ const fnArrPrincipalObjects: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
1858
+ const principalObjects: any[] = []
1730
1859
  for (const pId of activePrincipals) {
1731
1860
  const path = `/directoryObjects/${pId}`
1732
- principalsToResolve.push({ fn: () => helper.doRequest(baseEntity, 'GET', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined) })
1861
+ fnArrPrincipalObjects.push({ fn: () => helper.doRequest(baseEntity, 'GET', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined), objArr: principalObjects })
1733
1862
  }
1734
- const principalObjects: any[] = []
1735
- await fnCunckExecute(principalsToResolve, principalObjects)
1863
+ if (fnArrPrincipalObjects.length > 0) await fnCunckExecute(fnArrPrincipalObjects)
1736
1864
 
1737
- // 4. Handle Users directly and fetch transitive members for Groups
1738
- const groupMembersToFetch: { fn: () => Promise<any> }[] = []
1865
+ // 4. Handle users directly and fetch transitive members for groups
1866
+ const fnArrGroupResults: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
1867
+ const groupResults: any[] = []
1739
1868
  for (const obj of principalObjects) {
1740
1869
  if (!obj || !obj.id) continue
1741
1870
  if (obj['@odata.type'] === '#microsoft.graph.user') userIds.add(obj.id)
1742
1871
  else if (obj['@odata.type'] === '#microsoft.graph.group') {
1743
- // Use a custom function to fetch all transitive members including paging
1872
+ // fetch all transitive members including paging
1744
1873
  const fetchAllMembers = async (groupId: string) => {
1745
1874
  let members: any[] = []
1746
1875
  let nextPath: string | null = `/groups/${groupId}/transitiveMembers/microsoft.graph.user?$select=id`
@@ -1748,63 +1877,80 @@ const getUsersByRole = async (baseEntity: string, getObj: Record<string, any>, t
1748
1877
  const res = await helper.doRequest(baseEntity, 'GET', nextPath, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
1749
1878
  if (!res.body?.value) break
1750
1879
  members.push(...res.body.value)
1751
- // extract nextLink and convert to relative path
1752
1880
  nextPath = res.body['@odata.nextLink'] ? res.body['@odata.nextLink'].split('/beta')[1] : null
1753
1881
  }
1754
- return { body: { value: members } } // Wrap results for fnCunckExecute compatibility
1882
+ return { body: { value: members } }
1755
1883
  }
1756
- groupMembersToFetch.push({ fn: () => fetchAllMembers(obj.id) })
1884
+ fnArrGroupResults.push({ fn: () => fetchAllMembers(obj.id), objArr: groupResults })
1757
1885
  }
1758
1886
  }
1759
- const groupResults: any[] = []
1760
- if (groupMembersToFetch.length > 0) await fnCunckExecute(groupMembersToFetch, groupResults)
1887
+
1888
+ if (fnArrGroupResults.length > 0) await fnCunckExecute(fnArrGroupResults)
1761
1889
  groupResults.forEach((m: any) => m.id && userIds.add(m.id.toLowerCase()))
1762
1890
 
1763
1891
  return Array.from(userIds)
1764
1892
  }
1765
1893
 
1766
1894
  /**
1767
- * fnCunckExecute runs functions asynchronous in chunks
1768
- * @param fnArr array of objects that must include function and optionally index [{fn, index}]. If `index` is included, it represent the index of `objArr` that should be updated with `key` set to the value of the function result.
1769
- * @param objArr optionally array of objects. `objArr[index].key` will be set to function result. If objArr included e.g. empty, but no index and no key, function result will be inserted to objArr.
1770
- * @param key optionally key
1771
- * @returns undefined, but updated objArr if objArr argument is included
1895
+ * fnCunckExecute runs array of functions asynchronous in chunks
1896
+ * @param fnArr array of objects that must include function and optionally index, objArr and key [{fn, index, objArr, key}]. Function will always be executed. Any optional objArr will be updated according to provided index/key
1897
+ * @param fnArr.index optional and represent the index of `objArr` that should be updated with `key` set to the value of the function result
1898
+ * @param fnArr.objArr optionally array of objects or a single object that will be updated based on fn result. If index, `objArr[index].key` will be set to function result. If key, but no index, function result will be inserted to objArr[key].
1899
+ * @param fnArr.key optionally key
1900
+ * @param chunkSize optinally size of chunks used, default 5
1901
+ * @returns undefined, but updated objArr if objArr is included
1772
1902
  **/
1773
- const fnCunckExecute = async (fnArr: { index?: number, fn: () => Promise<any> }[], objArr?: Record<string, any>[], key?: string) => {
1774
- if (!Array.isArray(fnArr)) throw new Error(`fnCunckExecute get ${key} error: fnArr and/or objArr is not array`)
1775
- if (fnArr.length > 0) {
1776
- if (typeof fnArr[0] !== 'object' || !fnArr[0].fn) throw new Error(`fnCunckExecute error: fnArr missing fn object(s)`)
1777
- else if (fnArr[0].index !== undefined && !(objArr || key)) throw new Error(`fnCunckExecute error: missing reponseValue/key`)
1778
- const chunk = 5
1779
- do {
1780
- const arrChunk = fnArr.splice(0, chunk)
1781
- const results = await Promise.allSettled(arrChunk.map(o => o.fn())) as { status: 'fulfilled' | 'rejected', reason: any, value: any }[] // processing max chunk async
1782
- const errors = results.filter(result => result.status === 'rejected').map(result => result.reason.message)
1783
- if (errors.length > 0) {
1784
- let errMsg
1785
- let statusCode
1786
- try {
1787
- const res = JSON.parse(errors[0])
1788
- statusCode = res?.statusCode
1789
- errMsg = res?.body?.error?.message
1790
- } catch (err) { errMsg = errors.join(', ') }
1791
- if (statusCode !== 404) throw new Error(errMsg)
1792
- }
1793
- results.forEach((result, idx) => {
1794
- if (result.status === 'fulfilled') {
1795
- if (!result.value?.body) return
1796
- const res = result.value.body
1797
- if (typeof arrChunk[idx].index === 'number' && objArr && key) {
1798
- if (res.value) objArr[arrChunk[idx].index][key] = res.value
1799
- else objArr[arrChunk[idx].index][key] = res // Assign the result to the specific index and key
1800
- } else if (arrChunk[idx].index === undefined && objArr && key === undefined) { // When index and key are undefined, append to objArr if objArr provided
1801
- if (Array.isArray(res.value)) objArr.push(...res.value) // If res.value is an array, spread its elements into objArr
1802
- else objArr.push(res) // Otherwise, push the entire res object
1903
+ const fnCunckExecute = async (fnArr: { fn: () => Promise<any>, index?: number, objArr?: Record<string, any>[] | Record<string, any>, key?: string }[], chunkSize?: number) => {
1904
+ if (!Array.isArray(fnArr)) throw new Error(`fnCunckExecute error: fnArr is not array`)
1905
+ if (fnArr.length === 0) return
1906
+ if (typeof fnArr[0] !== 'object' || !fnArr[0].fn) throw new Error(`fnCunckExecute error: fnArr missing fn object(s)`)
1907
+ else if (fnArr[0].index !== undefined && !(fnArr[0].objArr || fnArr[0].key)) throw new Error(`fnCunckExecute error: missing reponseValue/key`)
1908
+ if (!chunkSize || chunkSize < 1) chunkSize = 5
1909
+ do {
1910
+ const arrChunk = fnArr.splice(0, chunkSize)
1911
+ const results = await Promise.allSettled(arrChunk.map(o => o.fn())) as { status: 'fulfilled' | 'rejected', reason: any, value: any }[] // processing max chunk async
1912
+ const errors = results.filter(result => result.status === 'rejected').map(result => result.reason.message)
1913
+ if (errors.length > 0) {
1914
+ let errMsg
1915
+ let statusCode
1916
+ try {
1917
+ const res = JSON.parse(errors[0])
1918
+ statusCode = res?.statusCode
1919
+ errMsg = res?.body?.error?.message
1920
+ } catch (err) { errMsg = errors.join(', ') }
1921
+ if (statusCode !== 404) throw new Error(errMsg)
1922
+ }
1923
+ results.forEach((result, idx) => {
1924
+ if (result.status === 'fulfilled') {
1925
+ const objArr: any = arrChunk[idx].objArr
1926
+ const key = arrChunk[idx].key
1927
+ const index = arrChunk[idx].index
1928
+ if (result.value === undefined) return
1929
+ const res: any = result.value.body || result.value
1930
+ const val = (res && res.value !== undefined) ? res.value : res
1931
+ if (typeof index === 'number' && objArr && key) {
1932
+ const keyArr = key.split('.')
1933
+ if (keyArr.length > 2) throw new Error(`fnCunckExecute error: key ${key} can not be more than 2 levels deep`)
1934
+ if (keyArr.length === 2 && !objArr[index][keyArr[0]]) objArr[index][keyArr[0]] = {}
1935
+ if (keyArr.length === 1) objArr[index][keyArr[0]] = val
1936
+ else objArr[index][keyArr[0]][keyArr[1]] = val
1937
+ } else if (index === undefined && objArr) {
1938
+ if (key === undefined) { // When index and key are undefined, append to objArr if objArr provided
1939
+ if (Array.isArray(objArr)) {
1940
+ if (Array.isArray(res.value)) objArr.push(...res.value) // If res.value is an array, spread its elements into objArr
1941
+ else objArr.push(res) // Otherwise, push the entire res object
1942
+ }
1943
+ } else {
1944
+ const keyArr = key.split('.')
1945
+ if (keyArr.length > 2) throw new Error(`fnCunckExecute error: key ${key} can not be more than 2 levels deep`)
1946
+ if (keyArr.length === 2 && !objArr[keyArr[0]]) objArr[keyArr[0]] = {}
1947
+ if (keyArr.length === 1) objArr[keyArr[0]] = val
1948
+ else objArr[keyArr[0]][keyArr[1]] = val
1803
1949
  }
1804
1950
  }
1805
- })
1806
- } while (fnArr.length > 0)
1807
- }
1951
+ }
1952
+ })
1953
+ } while (fnArr.length > 0)
1808
1954
  }
1809
1955
 
1810
1956
  //
@@ -945,7 +945,7 @@ export class ScimGateway {
945
945
  description: item.description ?? '',
946
946
  required: (item.mapTo === 'userName') ? true : false,
947
947
  caseExact: false,
948
- mutability: 'readWrite',
948
+ mutability: item.mutability ?? 'readWrite',
949
949
  returned: 'default',
950
950
  uniqueness: (item.mapTo === 'userName') ? 'server' : 'none',
951
951
  }
@@ -2261,6 +2261,7 @@ export class ScimGateway {
2261
2261
  if (res?.Resources && Array.isArray(res.Resources) && res.Resources.length === 1) {
2262
2262
  // we might have endpoint like Entra ID that hasn’t caught up yet due to internal sync
2263
2263
  // ensure returned object reflects changes by doing a merge with patch payload (convertedScim)
2264
+ if (finalScimdata?.mfa) delete finalScimdata.mfa // writeOnly mfa.reset
2264
2265
  res.Resources[0] = this.patchObj(res.Resources[0], finalScimdata) // merge
2265
2266
  if (res.Resources[0].password) delete res.Resources[0].password
2266
2267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "6.2.2",
3
+ "version": "6.2.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)",