scimgateway 6.2.3 → 6.2.5

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,11 @@
1
1
  # Change Log
2
2
 
3
+ ### v6.2.5
4
+ - **[Fixed]** jwt config key `azureTenantId` not detected.
5
+
6
+ ### v6.2.4
7
+ - **[Improved]** `plugin-entra-id` now support reset users MFA capabilites and user will be forced to re-register MFA.
8
+
3
9
  ### v6.2.3
4
10
  - **[Improved]** `plugin-entra-id` now includes information on whether a user has registered for MFA (has an MFA-capable method registered).
5
11
 
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
@@ -90,7 +90,7 @@ SCIM Gateway is a user provisioning bridge built with [Bun](https://bun.sh/) and
90
90
  - **v6.0.0** — API method response bodies returned as-is; new `publicApi()` method for unauthenticated `/pub/api` routes; `bearerJwtAzure.tenantIdGUID` replaced by `bearerJwt.azureTenantId`
91
91
  - **Federated Identity Credentials** (Entra ID) — access Microsoft-protected resources without managing secrets, via internal JWKS
92
92
  - **External JWKS** support for JWT authentication
93
- - **Azure Relay** — secure outbound-only tunnel with one minute of setup (~$10/month per listener)
93
+ - **Azure Relay** — secure outbound-only tunnel with one minute of setup
94
94
  - **ETag** and **Bulk Operations** support (SCIM RFC 7644)
95
95
  - **Remote real-time log subscription** via browser, curl, or custom client at `https://<host>/logger`
96
96
  - **Gateway chaining** — chain `gateway1 → gateway2 → gateway3 → endpoint` with reverse-proxy-style auth validation
@@ -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,23 @@ 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 res = await scimgateway.getGroups(baseEntity, { attribute: 'members.value', operator: 'eq', value: id }, ['id', 'displayName'], ctx)
645
+ let userGroups: Record<string, any>[] = []
646
+ if (res?.Resources && Array.isArray(res.Resources)) {
647
+ userGroups = res.Resources.map((r: Record<string, any>) => { return { value: r.id } })
648
+ }
649
+ const currentRoles = await getUserRoles(baseEntity, id, userGroups, true, ctx)
650
+ const isPrivileged = currentRoles.some(r => isPrivilegedRole(r.value))
651
+ if (isPrivileged) throw new Error(`${action} error: MFA reset is not allowed for users with high-privilege roles for security reasons.`)
652
+ await resetUserMfa(baseEntity, id, ctx)
653
+ }
654
+ delete parsedAttrObj.mfa
655
+ }
656
+
640
657
  // Roles
641
658
  if (Object.hasOwn(parsedAttrObj, 'roles') && Array.isArray(parsedAttrObj.roles)) {
642
659
  const r: Record<string, any>[] = []
@@ -655,7 +672,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
655
672
  const res: Record<string, any> = { value: el.value, type: el.type }
656
673
  if (el.display) res.display = el.display
657
674
  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.`)
675
+ if (isPrivilegedRole(el.value)) throw new Error(`${action} error: Removal of high-privilege roles is not allowed for security reasons.`)
659
676
  res.operation = el.operation
660
677
  }
661
678
  r.push(res)
@@ -1719,6 +1736,15 @@ const getUserAccessPackages = async (baseEntity: string, userId: string, include
1719
1736
  return result
1720
1737
  }
1721
1738
 
1739
+ const isPrivilegedRole = (roleId: string): boolean => {
1740
+ return [
1741
+ '62e90394-69f5-4237-9190-012177145e10', // Global Administrator
1742
+ 'e8611ab8-c189-46e8-94e1-60213ab1f814', // Privileged Role Administrator
1743
+ '7be44c8a-adaf-4e2a-84d6-ab2649e08a13', // Privileged Authentication Administrator
1744
+ 'c4e39bd9-1100-46d3-8c65-fb160da0071f', // Authentication Administrator
1745
+ ].includes(roleId)
1746
+ }
1747
+
1722
1748
  const isMethodMfaCapable = (odataType: string): boolean => {
1723
1749
  return [
1724
1750
  '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod',
@@ -1729,8 +1755,24 @@ const isMethodMfaCapable = (odataType: string): boolean => {
1729
1755
  ].includes(odataType)
1730
1756
  }
1731
1757
 
1758
+ const getAuthMethodCollectionFromODataType = (odataType: string): string => {
1759
+ const map: Record<string, string> = {
1760
+ '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod': 'microsoftAuthenticatorMethods',
1761
+ '#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod': 'microsoftAuthenticatorMethods',
1762
+ '#microsoft.graph.phoneAuthenticationMethod': 'phoneMethods',
1763
+ '#microsoft.graph.softwareOathAuthenticationMethod': 'softwareOathMethods',
1764
+ '#microsoft.graph.fido2AuthenticationMethod': 'fido2Methods',
1765
+ '#microsoft.graph.emailAuthenticationMethod': 'emailMethods',
1766
+ '#microsoft.graph.temporaryAccessPassAuthenticationMethod': 'temporaryAccessPassMethods',
1767
+ '#microsoft.graph.windowsHelloForBusinessAuthenticationMethod': 'windowsHelloForBusinessMethods',
1768
+ '#microsoft.graph.passwordAuthenticationMethod': 'passwordMethods',
1769
+ }
1770
+ const collection = map[odataType]
1771
+ return collection
1772
+ }
1773
+
1732
1774
  const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
1733
- const action = 'getUserAccessPackages'
1775
+ const action = 'getUserisMfaCapable'
1734
1776
  const method = 'GET'
1735
1777
  const body = null
1736
1778
  const path = `/users/${userId}/authentication/methods`
@@ -1744,6 +1786,35 @@ const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: und
1744
1786
  return methodTypes.some(isMethodMfaCapable)
1745
1787
  }
1746
1788
 
1789
+ const resetUserMfa = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
1790
+ const action = 'resetUserMfa'
1791
+ const method = 'GET'
1792
+ const body = null
1793
+ const path = `/users/${userId}/authentication/methods`
1794
+ const r = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
1795
+ if (!r.body?.value) {
1796
+ if (r.body?.id) r.body.value = [r.body]
1797
+ else throw new Error(`${action} error: invalid response: ${JSON.stringify(r)}`)
1798
+ }
1799
+ const methods = r.body.value.map((m: any) => { return { 'id': m.id, '@odata.type': m['@odata.type'] } })
1800
+ if (methods.length < 1) return false
1801
+ const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
1802
+ for (const m of methods) {
1803
+ if (!isMethodMfaCapable(m['@odata.type'])) continue
1804
+ const methodName = getAuthMethodCollectionFromODataType(m['@odata.type'])
1805
+ if (!methodName) continue
1806
+ const path = `/users/${userId}/authentication/${methodName}/${m.id}`
1807
+ const fn = () => helper.doRequest(baseEntity, 'DELETE', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
1808
+ fnArr.push({ fn })
1809
+ }
1810
+ if (fnArr.length === 0) return false
1811
+ // include revoke sessions
1812
+ const fn = () => helper.doRequest(baseEntity, 'POST', `/users/${userId}/invalidateAllRefreshTokens`, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
1813
+ fnArr.push({ fn })
1814
+ await fnCunckExecute(fnArr)
1815
+ return true
1816
+ }
1817
+
1747
1818
  /**
1748
1819
  * getUsersByRole returns an array of user IDs having a specific role assigned
1749
1820
  * @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
  }
@@ -4404,7 +4405,7 @@ Content-Transfer-Encoding: quoted-printable
4404
4405
  // found logic
4405
4406
  if (lastKey === 'password' && key.startsWith('scimgateway.auth.basic')) foundBasic = true
4406
4407
  else if (lastKey === 'token' && key.startsWith('scimgateway.auth.bearerToken')) foundBearerToken = true
4407
- else if ((lastKey === 'publicKey' || lastKey === 'secret' || lastKey === 'wellKnownUri' || 'azureTenantId') && key.startsWith('scimgateway.auth.bearerJwt')) foundBearerJwt = true
4408
+ else if ((lastKey === 'publicKey' || lastKey === 'secret' || lastKey === 'wellKnownUri' || lastKey === 'azureTenantId') && key.startsWith('scimgateway.auth.bearerJwt')) foundBearerJwt = true
4408
4409
  else if (lastKey === 'clientSecret' && key.startsWith('scimgateway.auth.bearerOAuth')) foundBearerOAuth = true
4409
4410
 
4410
4411
  // certificate full path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "6.2.3",
3
+ "version": "6.2.5",
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)",