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 +6 -0
- package/README.md +4 -4
- package/config/plugin-entra-id.json +7 -1
- package/lib/helper-rest.ts +2 -2
- package/lib/plugin-entra-id.ts +73 -2
- package/lib/scimgateway.ts +2 -1
- package/package.json +1 -1
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
},
|
package/lib/helper-rest.ts
CHANGED
|
@@ -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
|
|
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
|
|
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]
|
package/lib/plugin-entra-id.ts
CHANGED
|
@@ -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
|
|
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 = '
|
|
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
|
package/lib/scimgateway.ts
CHANGED
|
@@ -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