scimgateway 6.2.3 → 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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # Change Log
2
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
+
3
6
  ### v6.2.3
4
7
  - **[Improved]** `plugin-entra-id` now includes information on whether a user has registered for MFA (has an MFA-capable method registered).
5
8
 
package/README.md CHANGED
@@ -81,7 +81,7 @@ SCIM Gateway is a user provisioning bridge built with [Bun](https://bun.sh/) and
81
81
 
82
82
  ## What's New
83
83
 
84
- - **`plugin-entra-id`** now supports Entra ID roles and access packages, in addition to reading MFA capabilities and licenses.
84
+ - **`plugin-entra-id`** now supports Entra ID roles, access packages and MFA, in addition to reading licenses.
85
85
  - **`plugin-generic`** replaces `plugin-scim` — a flexible template using `endpointMapper` with the new `valueMap` option for allowlisting and name mapping e.g., groups
86
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)
87
87
  - **AI Agent ready** — `x-agent-schema` configuration in `endpointMapper` enables custom schema generation with MCP tool instructions for autonomous provisioning agents
@@ -1054,7 +1054,7 @@ The `baseEntity` parameter enables multi-tenant setups — create multiple endpo
1054
1054
  - `Organization.ReadWrite.All`
1055
1055
  - Additional for signInActivity, MFA, roles, and access packages:
1056
1056
  - `AuditLog.Read.All` *(sign-in activity; only if using `map.user.signInActivity`; requires Entra ID Premium)*
1057
- - `UserAuthenticationMethod.Read.All` *(MFA information; only if using `map.user.mfa`)*
1057
+ - `UserAuthenticationMethod.ReadWrite.All` *(MFA information and reset; only if using `map.user.mfa`)*
1058
1058
  - `RoleEligibilitySchedule.ReadWrite.Directory` *(PIM Eligible roles; only if using `map.user.roles`)*
1059
1059
  - `RoleManagement.ReadWrite.Directory` *(PIM Permanent roles; only if using `map.user.roles`)*
1060
1060
  - `EntitlementManagement.ReadWrite.All` *(IGA Access Packages; only if using `map.user.entitlements`)*
@@ -1063,7 +1063,7 @@ The `baseEntity` parameter enables multi-tenant setups — create multiple endpo
1063
1063
 
1064
1064
  > For full access to admin users, assign the `Global Administrator` role. The `User Administrator` role has limitations on users with admin roles.
1065
1065
 
1066
- > Note, if 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.
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.
1067
1067
 
1068
1068
  ### Plugin Configuration
1069
1069
 
@@ -210,10 +210,16 @@
210
210
  "mutability": "readOnly",
211
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
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"
213
219
  }
214
220
  ],
215
221
  "x-agent-schema": {
216
- "description": "Read-only attribute object representing 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.",
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.",
217
223
  "readOnly": true
218
224
  }
219
225
  },
@@ -794,13 +794,13 @@ export class HelperRest {
794
794
  const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
795
795
  return ret // problem fixed
796
796
  } else {
797
- if (statusCode === 404 || ctx.skipLogAsError) {
797
+ if (statusCode === 404 || ctx?.skipLogAsError) {
798
798
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
799
799
  } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
800
800
  throw err
801
801
  }
802
802
  } else {
803
- if (statusCode === 404 || ctx.skipLogAsError) { // 404 not logged as error e.g. getUser-manager
803
+ if (statusCode === 404 || ctx?.skipLogAsError) { // 404 not logged as error e.g. getUser-manager
804
804
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
805
805
  } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
806
806
  if (statusCode === 401) delete this._serviceClient[baseEntity]
@@ -637,6 +637,18 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
637
637
  return undefined
638
638
  }
639
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
+
640
652
  // Roles
641
653
  if (Object.hasOwn(parsedAttrObj, 'roles') && Array.isArray(parsedAttrObj.roles)) {
642
654
  const r: Record<string, any>[] = []
@@ -655,7 +667,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
655
667
  const res: Record<string, any> = { value: el.value, type: el.type }
656
668
  if (el.display) res.display = el.display
657
669
  if (el.operation === 'delete') {
658
- 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.`)
659
671
  res.operation = el.operation
660
672
  }
661
673
  r.push(res)
@@ -1719,6 +1731,15 @@ const getUserAccessPackages = async (baseEntity: string, userId: string, include
1719
1731
  return result
1720
1732
  }
1721
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
+
1722
1743
  const isMethodMfaCapable = (odataType: string): boolean => {
1723
1744
  return [
1724
1745
  '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod',
@@ -1729,8 +1750,24 @@ const isMethodMfaCapable = (odataType: string): boolean => {
1729
1750
  ].includes(odataType)
1730
1751
  }
1731
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
+
1732
1769
  const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
1733
- const action = 'getUserAccessPackages'
1770
+ const action = 'getUserisMfaCapable'
1734
1771
  const method = 'GET'
1735
1772
  const body = null
1736
1773
  const path = `/users/${userId}/authentication/methods`
@@ -1744,6 +1781,35 @@ const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: und
1744
1781
  return methodTypes.some(isMethodMfaCapable)
1745
1782
  }
1746
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
+
1747
1813
  /**
1748
1814
  * getUsersByRole returns an array of user IDs having a specific role assigned
1749
1815
  * @param baseEntity
@@ -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.3",
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)",