scimgateway
v6.2.2
Published
Using SCIM protocol as a gateway for user provisioning to other endpoints
Maintainers
Readme
SCIM Gateway
Author: Jarle Elshaug
SCIM Gateway is a user provisioning bridge built with Bun and Node.js using TypeScript. It translates incoming SCIM 1.1/2.0 requests into endpoint-specific protocols — turning any destination into a SCIM-compatible interface without vendor lock-in.
Table of Contents
- SCIM Gateway
- Table of Contents
- What's New
- Included Plugins
- Installation
- Configuration
- Running the Gateway
- Docker
- Identity Provider Integration
- Entra ID Provisioning Plugin
- API Gateway
- Building Custom Plugins
- License
- Change Log
What's New
plugin-entra-idnow supports Entra ID roles and access packages, in addition to reading licenses.plugin-genericreplacesplugin-scim— a flexible template usingendpointMapperwith the newvalueMapoption for allowlisting and name mapping e.g., groupsGET /RolesandGET /Entitlementsendpoint support, with user management via SCIMrolesandentitlementsattributes;plugin-entra-idusesentitlementsfor Entra ID licenses (read-only) androlesfor Permanent and Eligible PIM roles (full management)- AI Agent ready —
x-agent-schemaconfiguration inendpointMapperenables custom schema generation with MCP tool instructions for autonomous provisioning agents - Bun binary builds — compile a plugin into a single executable for simplified deployment
- ES module / TypeScript support in Node.js via
tsx - v6.0.0 — API method response bodies returned as-is; new
publicApi()method for unauthenticated/pub/apiroutes;bearerJwtAzure.tenantIdGUIDreplaced bybearerJwt.azureTenantId - Federated Identity Credentials (Entra ID) — access Microsoft-protected resources without managing secrets, via internal JWKS
- External JWKS support for JWT authentication
- Azure Relay — secure outbound-only tunnel with one minute of setup (~$10/month per listener)
- ETag and Bulk Operations support (SCIM RFC 7644)
- Remote real-time log subscription via browser, curl, or custom client at
https://<host>/logger - Gateway chaining — chain
gateway1 → gateway2 → gateway3 → endpointwith reverse-proxy-style auth validation - OAuth for email — Microsoft Exchange Online and Google Workspace Gmail alongside traditional SMTP Auth
- SCIM Stream — subscribe-based provisioning as an alternative to top-down IGA polling
Included Plugins
| Plugin | Endpoint Type | Description |
|---|---|---|
| loki | NoSQL | Standalone SCIM endpoint using LokiJS. Includes test users and groups. Ideal for development. |
| mongodb | NoSQL | Like Loki but backed by an external MongoDB. Demonstrates multi-tenant via baseEntity. |
| entra-id | REST | Users/Groups/Roles/AccessPackages/Licenses provisioning to Microsoft Entra ID via Microsoft Graph API. |
| generic | REST | Generic template using endpointMapper and the valueMap option for allowlisting and name mapping e.g., groups. Defaults to plugin-loki as the SCIM target. Can also act as a SCIM version gateway (e.g. 1.1 → 2.0). |
| api | REST | Non-SCIM plugin demonstrating API Gateway mode for custom REST specifications. |
| soap | SOAP | User provisioning to a SOAP-based endpoint with example WSDLs. |
| mssql | SQL | User provisioning to Microsoft SQL Server. |
| saphana | SQL | SAP HANA–specific user provisioning. |
| ldap | Directory | Full LDAP plugin pre-configured for Microsoft Active Directory. |
Installation
Prerequisites
Install Bun first. By default Bun installs to HOMEPATH\.bun. To install elsewhere, set BUN_INSTALL=<path> as a system environment variable before running the installer. Consider adding Bun to the system path for all users.
Install SCIM Gateway
mkdir c:\my-scimgateway
cd c:\my-scimgateway
bun init -y
bun install scimgateway
bun pm trust scimgateway # required to allow postinstall to copy example filesThis copies index.ts, lib/, and config/ (with example plugins) into your package directory.
Verify the Default Loki Plugin
bun c:\my-scimgatewayThen open a browser and try:
# Health check
GET http://localhost:8880/ping
# List users and groups (basic auth: gwadmin / password)
GET http://localhost:8880/Users
GET http://localhost:8880/Groups
# Real-time remote log monitoring
http://localhost:8880/logger
# Fetch a specific user or group
GET http://localhost:8880/Users/bjensen
GET http://localhost:8880/Groups/Admins
# Filter examples
GET http://localhost:8880/Users?filter=userName eq "bjensen"
GET http://localhost:8880/Users?filter=emails.value co "@example.com"&attributes=userName,name.familyName,emails&sortBy=name.familyName&sortOrder=descending
GET http://localhost:8880/Groups?filter=displayName eq "Admins"&excludedAttributes=members
GET http://localhost:8880/Groups?filter=members.value eq "bjensen"&attributes=id,displayName,members.valuePress Ctrl+C to stop.
Using Node.js, the startup command is:
node --import=tsx ./index.ts
Upgrading
The recommended approach is to rename the old package folder, do a fresh install, then copy your customized index.ts, config/, and lib/ from the previous install.
# Minor upgrade
bun install scimgateway
# Major upgrade (may break existing plugins — review change log first)
bun install scimgateway@latestExcluding example plugins in production: Bun skips postinstall unless you run bun pm trust scimgateway. For npm or Node.js environments, set scimgateway_postinstall_skip = true in .npmrc or the environment variable SCIMGATEWAY_POSTINSTALL_SKIP=true.
Configuration
Entry Point — index.ts
index.ts defines which plugins to start:
// Start one or more plugins:
import './lib/plugin-entra-id.ts'
export {}Plugin File Naming
Each plugin requires a TypeScript file and a JSON configuration file sharing the same name prefix:
lib/plugin-entra-id.ts
config/plugin-entra-id.jsonThe JSON file has two top-level objects:
{
"scimgateway": { ... },
"endpoint": { ... }
}scimgateway holds gateway core settings (port, auth, logging, TLS). endpoint holds plugin-specific connection details (host, credentials, mappings).
Core Options
| Option | Type | Default | Description |
|---|---|---|---|
| port | number | — | Port the gateway listens on |
| localhostonly | boolean | false | Accept requests only from 127.0.0.1 |
| chainingBaseUrl | string | — | Route requests to another gateway (http(s)://host:port) |
| idleTimeout | number | 120 | Seconds before an idle connection is dropped |
| scim.version | string | "2.0" | SCIM protocol version: "1.1" or "2.0" |
| scim.skipTypeConvert | boolean | false | Pass multivalue attributes as-is instead of type-converted objects |
| scim.skipMetaLocation | boolean | false | Omit meta.location from responses (useful behind a reverse proxy) |
| scim.groupMemberOfUser | boolean | false | Keep groups on the user object instead of managing group membership via modifyGroup |
| scim.usePutSoftSync | boolean | false | PUT replaces only the attributes in the body; existing attributes are preserved |
Logging options (log.*):
| Option | Values | Default | Description |
|---|---|---|---|
| log.loglevel.file | off, debug, info, warn, error | off | Log level for the plugin log file |
| log.loglevel.console | off, debug, info, warn, error | off | Log level for stdout/stderr |
| log.loglevel.push | debug, info, warn, error | info | Log level for the remote real-time subscriber |
| log.logDirectory | path | <package>/logs | Override the default log directory |
| log.customMasking | string[] | [] | Additional attribute names to mask in logs, e.g. ["SSN", "weight"] |
| log.colorize | boolean | true | Colorized console output; set false for plain JSON |
| log.maxSize | number | 20 | Max log file size in MB |
| log.maxFiles | number | 5 | Number of rotated log files to keep |
scim.skipTypeConvert example:
With skipTypeConvert: false (default), emails are converted to type-keyed objects:
"emails": {
"work": { "value": "[email protected]", "type": "work" },
"home": { "value": "", "type": "home", "operation": "delete" }
}With skipTypeConvert: true, the array is passed as-is:
"emails": [
{ "value": "[email protected]", "type": "work" },
{ "value": "john.smith.org", "type": "home", "operation": "delete" }
]Authentication
The auth object supports multiple concurrent methods. Set any admin user to null to disable that method. Each entry supports:
readOnly— iftrue, onlyGETrequests are allowedbaseEntities— restrict this credential to specific baseEntity values (empty array = all)
Basic Authentication
"auth": {
"basic": [
{
"username": "gwadmin",
"password": "password",
"readOnly": false,
"baseEntities": []
}
]
}Cleartext passwords are encrypted on first gateway start.
Bearer Token (Shared Secret)
"bearerToken": [
{
"token": "my-shared-secret",
"readOnly": false,
"baseEntities": []
}
]Supported by Entra ID provisioning. The token is encrypted on first start.
JWT (Standard)
"bearerJwt": [
{
"secret": null,
"publicKey": "jwt-public-key.pem",
"wellKnownUri": null,
"azureTenantId": null,
"options": {
"issuer": "https://my-idp.example.com"
},
"readOnly": false,
"baseEntities": []
}
]secret— HMAC shared secret (encrypted on start)publicKey— filename of a PEM file inconfig/certs/wellKnownUri— JWKS discovery URL, e.g.https://keycloak.example.com/realms/my-realm/.well-known/openid-configurationazureTenantId— Entra ID tenant ID; enables Entra-initiated provisioning using JWT validation
For Entra ID apps accessing the gateway:
"wellKnownUri": "https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration",
"options": { "audience": "{application-id}" }OAuth Client Credentials
"bearerOAuth": [
{
"clientId": "my-client-id",
"clientSecret": "my-client-secret",
"readOnly": false,
"baseEntities": []
}
]Clients request a token from POST /oauth/token (e.g. http://localhost:8880/oauth/token).
Authentication PassThrough
"passThrough": {
"enabled": true,
"readOnly": false,
"baseEntities": []
}The gateway forwards the raw Authorization header to the plugin. The plugin must set scimgateway.authPassThroughAllowed = true and implement its own auth handling against the endpoint.
IP Allow List
Restrict incoming traffic to specific subnets (CIDR notation). Useful for Entra ID provisioning where you want to accept traffic only from Azure IP ranges:
"ipAllowList": [
"13.64.151.161/32",
"13.66.141.64/27",
"2603:1056:2000::/48"
]Azure IP ranges can be downloaded from azureipranges.azurewebsites.net — search for
AzureActiveDirectoryand copy theaddressPrefixesarray.
When running behind a load balancer or reverse proxy, the proxy must include the client IP in the X-Forwarded-For header.
TLS & Certificates
Using PEM files
"certificate": {
"key": "key.pem",
"cert": "cert.pem",
"ca": "ca.pem"
}Files must be in config/certs/ or use absolute paths. For multiple CAs: "ca": ["ca1.pem", "ca2.pem"].
Generate a self-signed certificate:
openssl req -nodes -newkey rsa:2048 -x509 -sha256 -days 3650 \
-keyout key.pem -out cert.pem \
-subj "/O=My Company/OU=Application/CN=SCIM Gateway" \
-addext "subjectAltName=DNS:localhost,DNS:127.0.0.1,DNS:*.mycompany.com" \
-addext "extendedKeyUsage=serverAuth" \
-addext "keyUsage=digitalSignature"Using PFX / PKCS#12
"pfx": {
"bundle": "certbundle.pfx",
"password": "password"
}If communicating over localhost only (e.g. gateway installed directly on the provisioning server), you can skip TLS and use
http://localhost:<port>with"localhostonly": true.
No TLS
"certificate": {
"key": null,
"cert": null,
"ca": null
}Email Notifications
The email section supports alerting on errors and sending mail from plugin code via scimgateway.sendMail().
Microsoft Exchange Online (OAuth)
"email": {
"auth": {
"type": "oauth",
"options": {
"azureTenantId": "<tenant-id>",
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}
},
"emailOnError": {
"enabled": true,
"from": "[email protected]",
"to": "[email protected]",
"cc": null,
"subject": "SCIM Gateway error",
"sendInterval": 15
}
}Entra ID requirements:
- Grant the application permission
Mail.Send - Restrict which mailboxes the app can send from via an Exchange
ApplicationAccessPolicy:
Install-Module -Name ExchangeOnlineManagement
Connect-ExchangeOnline
New-ApplicationAccessPolicy `
-AppId <AppClientID> `
-PolicyScopeGroupId <MailEnabledSecurityGroupId> `
-AccessRight RestrictAccess `
-Description "Restrict app to specific mailboxes"Google Workspace Gmail (OAuth)
"email": {
"auth": {
"type": "oauth",
"options": {
"serviceAccountKeyFile": "google-service-account.json"
}
},
"emailOnError": {
"enabled": true,
"from": "[email protected]",
"to": "[email protected]"
}
}Google setup:
- Google Cloud Console: create a Service Account → download the JSON key
- Google Admin: Security → API controls → Domain Wide Delegation → add Client ID with scope
https://www.googleapis.com/auth/gmail.send - Ensure
fromaddress has a Google Workspace license
SMTP Auth
"email": {
"auth": {
"type": "smtp",
"options": {
"host": "smtp.gmail.com",
"port": 587,
"username": "[email protected]",
"password": "app-password"
}
},
"emailOnError": {
"enabled": true,
"to": "[email protected]"
}
}Azure Relay
Azure Relay lets the gateway listen for inbound SCIM requests over an outbound HTTPS/443 connection — no inbound firewall rules required.
Cost: ~$10/month per Hybrid Connection listener.
Azure setup:
- Create a Relay namespace in Azure → create a Hybrid Connection entity (one per plugin)
- Leave Requires Client Authorization unchecked unless your IdP includes a SAS token
- Copy the primary key from Shared Access Policies → RootManageSharedaccessKey
Plugin configuration:
"azureRelay": {
"enabled": true,
"connectionUrl": "https://<namespace>.servicebus.windows.net/<hybrid-connection>",
"apiKey": "<primary-key>",
"keyRule": "RootManageSharedaccessKey"
}The connectionUrl becomes the SCIM base URL. Examples:
GET https://<namespace>.servicebus.windows.net/<hybrid-connection>/Users
GET https://<namespace>.servicebus.windows.net/<hybrid-connection>/<baseEntity>/UsersMultiple gateway instances sharing the same connectionUrl will round-robin load-balance.
Azure Relay does not support remote log subscription.
Secrets from External Sources
All configuration values can be sourced from environment variables, external JSON files, or plain text files. This supports secret managers and Kubernetes secrets.
From environment variables:
"port": "process.env.PORT",
"log": { "loglevel": { "file": "process.env.LOG_LEVEL_FILE" } }From a shared JSON file (dot-notation keyed by plugin name):
"username": "process.file./var/run/vault/secrets.json"Where secrets.json contains:
{
"plugin-soap.scimgateway.auth.basic[0].username": "gwadmin",
"plugin-soap.scimgateway.auth.basic[0].password": "password",
"plugin-soap.endpoint.username": "superuser",
"plugin-soap.endpoint.password": "secret"
}From a single-value text file:
"secret": "process.text./var/run/vault/jwt.secret"Where the file contains the raw value: thisIsSecret
Set the environment variable
SEEDto a random string to override default password seeding. This also lets you copy an encrypted configuration file between machines.
Remote Log Subscription
Stream real-time logs from the gateway to a browser, curl, or custom client.
Browser: https://<host>/logger
curl:
curl -Ns http://localhost:8880/logger -u gwadmin:password | awk '
/^data: / {sub(/^data: /,""); printf "%s", $0; last=1; next}
/^$/ {if (last) print ""; last=0}
'Custom client (TypeScript/Bun):
const username = "gwadmin"
const password = "password"
const url = "http://localhost:8880/logger"
const headers = new Headers({
Authorization: "Basic " + btoa(`${username}:${password}`),
Accept: "text/event-stream"
})
// message handling and custom logic
const messageHandler = async (message: string) => {
console.log(message)
}
async function startup() {
while (true) {
try {
const resp = await fetch(url, { headers })
if (!resp.ok || !resp.body) {
console.error(`❌ Response error: ${resp.status} ${resp.statusText}`)
await Bun.sleep(10_000)
continue
}
console.log('✅ Connected — awaiting log events...\n')
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader()
while (true) {
const { value, done } = await reader.read()
if (done) break
if (!value.startsWith('data: ')) continue
const i = value.indexOf("\n\n")
if (i < 1) continue
messageHandler(value.slice(6, i))
}
console.error("⚠️ Connection closed")
await Bun.sleep(10_000)
} catch (err: any) {
console.error("❌ Connection error:", err?.message || err)
await Bun.sleep(10_000)
}
}
}
startup()Set a dedicated read-only credential for log collection:
"auth": {
"basic": [
{ "username": "gwadmin", "password": "password", "readOnly": false },
{ "username": "gwread", "password": "password", "readOnly": true }
],
"bearerToken": [
{ "token": "log-secret", "readOnly": true }
]
}Set push log level (default info):
"log": { "loglevel": { "push": "debug" } }You can also scope log output to a specific baseEntity: https://<host>/<baseEntity>/logger
Gateway Chaining
Chain multiple gateways: gateway1 → gateway2 → gateway3 → endpoint. Each gateway validates authorization and forwards the request unless PassThrough is enabled.
gateway1 configuration:
{
"scimgateway": {
"chainingBaseUrl": "https://gateway2:8880",
"auth": {
"passThrough": {
"enabled": false
}
}
}
}In chaining mode the plugin binary is only used for initialization. You can simplify the plugin to the mandatory section only:
// start - mandatory plugin initialization
import { ScimGateway } from 'scimgateway'
const scimgateway = new ScimGateway()
const config = scimgateway.getConfig()
scimgateway.authPassThroughAllowed = true // and configuration file having: scimgateway.auth.passThrough=true
scimgateway.pluginAndOrFilterEnabled = false
// end - mandatory plugin initializationHelperRest
HelperRest provides a unified REST client for plugins with built-in support for authentication, retries, failover, and proxies.
helper.doRequest(baseEntity, method, path, body?, ctx?, options?)baseEntity—'undefined'if not used; must match a key inendpoint.entitymethod—GET,POST,PATCH,PUT,DELETEpath— full URL or path appended tobaseUrlbody— optional request bodyctx— optional, passes theAuthorizationheader for PassThrough authoptions— optional overrides for connection settings
Endpoint connection structure:
"endpoint": {
"entity": {
"undefined": {
"connection": {
"baseUrls": ["https://api.example.com"],
"auth": {
"type": "basic|oauth|token|bearer|oauthSamlBearer|oauthJwtBearer",
"options": { ... }
},
"options": {
"headers": {},
"tls": {} // // files located in ./config/certs
},
"proxy": {}
}
}
}
}Basic Auth
"connection": {
"baseUrls": ["https://localhost:8880"],
"auth": {
"type": "basic",
"options": {
"username": "gwadmin",
"password": "password"
}
},
"options": {
"tls": { "rejectUnauthorized": false, "ca": "ca.pem" }
}
}Entra ID — Client Secret
"connection": {
"baseUrls": [],
"auth": {
"type": "oauth",
"options": {
"azureTenantId": "<tenant-id>",
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}
}
}Entra ID — Certificate Secret
"connection": {
"baseUrls": [],
"auth": {
"type": "oauthJwtBearer",
"options": {
"azureTenantId": "<tenant-id>",
"clientId": "<client-id>",
"tls": {
"key": "key.pem",
"cert": "cert.pem"
}
}
}
}Entra ID — Federated Credentials (no secrets)
"connection": {
"baseUrls": [],
"auth": {
"type": "oauthJwtBearer",
"options": {
"azureTenantId": "<tenant-id>",
"fedCred": {
"issuer": "<https://FQDN-scimgateway>", // https://scimgateway.my-company.com
"subject": "<entra-application-object-id>",
"name": "<entra-fed-cred-unique-name>" // plugin-entra-id
}
}
}
}The
issuer,subject, andnamemust match the Federated Credentials configured in Entra ID (scenario: "Other issuer"). The gateway must be reachable from the internet at theissuerURL, or use Azure Relay for outbound-only communication.
General OAuth (Client Credentials)
"connection": {
"baseUrls": ["https://api.example.com"],
"auth": {
"type": "oauth",
"options": {
"tokenUrl": "https://idp.example.com/oauth/token",
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}
}
}Use VS Code IntelliSense on HelperRest.doRequest() for full type and option documentation.
Single Binary Deployment
Compile a plugin to a self-contained native binary (no Bun/Node runtime required):
cd my-scimgateway
bun build --compile ./lib/plugin-loki.ts \
--target=bun-darwin-arm64 \
--outfile ./build/plugin-loki
# See https://bun.sh/docs/bundler/executables#cross-compile-to-other-platforms for all targets
cp -r ./config ./build
cd build
./plugin-loki # binary name must match the config file prefixThe config/ directory must be in the same folder as the binary.
Running the Gateway
Manual Startup
# All three are equivalent:
bun c:\my-scimgateway
bun c:\my-scimgateway\index.ts
bun . # from the package rootPress Ctrl+C to stop.
Windows Task Scheduler
Open Task Scheduler (taskschd.msc), right-click "Task Scheduler Library" → "Create Task":
| Tab | Setting |
|---|---|
| General | Name: SCIM Gateway; User: SYSTEM; Run with highest privileges |
| Triggers | Begin the task: At startup |
| Actions | Start a program: <bun-install-path>\bun.exe; Arguments: c:\my-scimgateway |
| Settings | Stop the task if it runs longer than: Disabled |
Verify:
- Right-click → Run → confirm process appears in Task Manager
- Right-click → End → confirm process disappears
- Reboot → confirm auto-start
Docker
Single Image
mkdir /opt/my-scimgateway
cd /opt/my-scimgateway
bun init -y
bun install scimgateway
bun pm trust scimgateway
cp ./config/docker/* .
cp ./config/docker/.dockerignore .
# Build
docker build --platform linux/amd64 --force-rm=true -t my-scimgateway:1.0.0 .
# Create and run
docker create --init --ulimit memlock=-1:-1 --name my-scimgateway -p 8880:8880 my-scimgateway:1.0.0
docker start my-scimgateway
docker stop my-scimgatewayConsider passing -e SEED=<random> at create time if using encrypted configuration files.
Docker Compose
Pre-requisites: docker-compose and docker-ce
mkdir /opt/my-scimgateway && cd /opt/my-scimgateway
bun init -y && bun install scimgateway && bun pm trust scimgateway
cp ./config/docker/* .
adduser scimgateway
mkdir /home/scimgateway/config
# Copy your plugin config to the persistent volume
scp config/plugin-loki.json scimgateway@host:/home/scimgateway/config/
docker-compose up --build -dProvided compose files:
| File | Purpose |
|---|---|
| docker-compose.yml | Main compose file — set exposed ports and environment here |
| Dockerfile | Main image definition |
| DataDockerfile | Volume mapping |
| docker-compose-debug.yml | Attach VS Code debugger |
| docker-compose-mssql.yml | Compose example including an MSSQL container |
Common Docker commands:
docker ps # list running containers
docker images # list images
docker logs scimgateway # view logs
docker exec scimgateway <command> # run command in container
docker-compose stop / start # stop / restart
docker-compose -f docker-compose.yml \
-f docker-compose-debug.yml up -d # debug mode (VS Code)
# Upgrade — remove old container and dangling images first
docker rm scimgateway
docker rm $(docker ps -a -q)
docker rmi $(docker images -q -f "dangling=true")Identity Provider Integration
Microsoft Entra ID as IdP
Entra ID can automatically provision users to SCIM Gateway, which then forwards to your endpoint plugin.
Plugin configuration requirements:
"scimgateway": {
"scim": { "version": "2.0" },
"auth": {
"bearerToken": [
{ "token": "shared-secret" }
],
"bearerJwt": [
{ "azureTenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }
]
}
}tokenmust match the "Secret Token" in the Entra ID provisioning configurationazureTenantIdmust match the Entra tenant ID- If "Secret Token" is left blank in Entra ID, JWT (
azureTenantId) is used automatically
Azure Portal paths:
Secret Token: Microsoft Entra ID → Enterprise Apps → <App> → Provisioning → Secret Token
Tenant ID: Microsoft Entra ID → Overview → Tenant ID
Attribute maps: Enterprise Apps → <App> → Provisioning → Edit attribute mappings → MappingsRequired attribute mappings:
| Object | Source | Target | Matching |
|---|---|---|---|
| User | userPrincipalName | userName | Precedence #1 |
| Group | displayName | displayName | Precedence #1 |
| Group | members | members | — |
Entra ID behavior notes:
- Deleting a user sends
PATCH { "active": "False" }rather than aDELETErequest - Entra ID periodically checks for non-existent users/groups as a keep-alive
- Entra ID checks existence before creating (no full user explore like some other IdPs)
Symantec/Broadcom Identity Manager as IdP
Use SCIM version "1.1" for Symantec/Broadcom Provisioning.
In Provisioning Manager use endpoint type SCIM (DYN Endpoint) or create a custom type.
Example endpoint configuration (plugin-loki):
Endpoint Name: Loki-8880
User Name: gwadmin
Password: password
SCIM Authentication Method: HTTP Basic Authentication
SCIM Based URL: http://localhost:8880
or: http://localhost:8880/<baseEntity>The baseEntity parameter enables multi-tenant setups — create multiple endpoints with the same base URL but different baseEntity values (e.g. /client-a, /client-b). Define per-entity connection attributes in the plugin JSON configuration.
Entra ID Provisioning Plugin
plugin-entra-id provisions users and groups to Microsoft Entra ID via the Microsoft Graph API.
Entra ID App Registration
- Microsoft Entra ID → App registrations → New registration
- Name:
SCIM Gateway Inbound - Accounts: This organizational directory only
- Name:
- Overview — copy Application (client) ID and Directory (tenant) ID
- Certificates & secrets → New client secret — copy the value
- API permissions → Add → Microsoft Graph → Application permissions:
Directory.ReadWriteAllOrganization.ReadWrite.All- Additional for signInActivity, roles, licenses and access packages:
AuditLog.Read.All(only if usingmap.user.signInActivity; requires Entra ID Premium)RoleEligibilitySchedule.ReadWrite.Directory(PIM Eligible roles; only if usingmap.user.roles)RoleManagement.ReadWrite.Directory(PIM Permanent roles; only if usingmap.user.roles)EntitlementManagement.ReadWrite.All(IGA Access Packages; only if usingmap.user.entitlements)
- Click Grant admin consent
- Entra ID → Roles and administrators → User administrator → Add assignments — add
SCIM Gateway Inbound
For full access to admin users, assign the
Global Administratorrole. TheUser Administratorrole has limitations on users with admin roles.
signInActivity, roles, licenses and access packagesrequires permissions above. Note,ReadWritecan be replaced withReadif management is not required. Remove any mapping configuration whose conditions are not met — Minimum read permissions are validated at startup.
Plugin Configuration
index.ts:
import './lib/plugin-entra-id.ts'
export {}config/plugin-entra-id.json (key sections):
{
"scimgateway": {
"scim": { "version": "2.0", "skipTypeConvert": true}, // skipTypeConvert if Access Package management (entitlements)
"auth": {
"basic": [
{
"username": "gwadmin",
"password": "password",
"readOnly": false
}
]
}
},
"endpoint": {
"entity": {
"undefined": {
"connection": {
"baseUrls": [],
"auth": {
"type": "oauth",
"options": {
"azureTenantId": "<Tenant ID>",
"clientId": "<Application ID>",
"clientSecret": "<Secret value>"
}
},
"proxy": {
"host": null,
"username": null,
"password": null
}
}
}
}
}
}clientSecret and any proxy passwords are automatically encrypted on the first connection.
Multi-tenant setup:
"endpoint": {
"entity": {
"undefined": { ... },
"client-a": { ... },
"client-b": { ... }
}
}Using with Symantec/Broadcom (ConnectorXpress)
- Start SCIM Gateway with
plugin-entra-id - Open ConnectorXpress → Setup Data Sources → Add Layer7 → Base URL:
http://localhost:8881 - Import the endpoint type metadata:
node_modules/scimgateway/config/resources/Azure - ScimGateway.xml - Create endpoint type
Azure - ScimGateway
Provisioning Manager endpoint example:
Endpoint Name: AzureAD-8881
User Name: gwadmin
Password: password
SCIM Authentication Method: HTTP Basic Authentication
SCIM Based URL: http://localhost:8881API Gateway
SCIM Gateway doubles as a general API gateway via the /api path (no SCIM schema required):
GET /api
GET /api?<query>
GET /api/{id}
POST /api + body
PUT /api/{id} + body
PATCH /api/{id} + body
DELETE /api/{id}With baseEntity: /<baseEntity>/api
A public (unauthenticated) API path is also available:
GET /pub/api?model=TeslaSee lib/plugin-api.ts for a complete example.
Building Custom Plugins
Recommended editor: Visual Studio Code — provides IntelliSense for all scimgateway methods.
Setup
- Copy the closest matching example plugin (e.g.
lib/plugin-mssql.ts+config/plugin-mssql.json) and rename both with your prefix (e.g.plugin-mine) - Set a unique
portinconfig/plugin-mine.json - Add your plugin to
index.ts:import './lib/plugin-mine.ts' - Start the gateway and verify
Mandatory Plugin Initialization
// start - mandatory plugin initialization
import { ScimGateway, HelperRest } from 'scimgateway'
const scimgateway = new ScimGateway()
const helper = new HelperRest(scimgateway) // include if using REST
const config = scimgateway.getConfig()
scimgateway.authPassThroughAllowed = false
scimgateway.pluginAndOrFilterEnabled = false
// end - mandatory plugin initializationImplementation Order
Build and test incrementally:
getGroups— return empty response to disable group handling initially (seeplugin-saphanafor a groups-free example)getUsers— retrieve all accounts and a single account by filtercreateUser— create new accountsdeleteUser— delete accountsmodifyUser— update accountsgetGroups— re-enable with real logic if groups are supportedcreateGroup,deleteGroup,modifyGroup— group lifecycle
Plugin Methods
SCIM methods (implement in your plugin):
| Method | Description |
|---|---|
| scimgateway.getUsers() | Retrieve users (all or filtered) |
| scimgateway.createUser() | Create a new user |
| scimgateway.deleteUser() | Delete a user |
| scimgateway.modifyUser() | Update user attributes |
| scimgateway.getGroups() | Retrieve groups (all or filtered) |
| scimgateway.createGroup() | Create a new group |
| scimgateway.deleteGroup() | Delete a group |
| scimgateway.modifyGroup() | Update group members/attributes |
| scimgateway.getEntitlements() | Retrieve entitlements (e.g. Entra ID licenses) |
| scimgateway.getRoles() | Retrieve roles (e.g. Entra ID PIM roles) |
API Gateway methods:
| Method | Path |
|---|---|
| scimgateway.getApi() | GET /api |
| scimgateway.postApi() | POST /api |
| scimgateway.putApi() | PUT /api/{id} |
| scimgateway.patchApi() | PATCH /api/{id} |
| scimgateway.deleteApi() | DELETE /api/{id} |
| scimgateway.publicApi() | GET /pub/api (no auth) |
Use VS Code IntelliSense on any method for inline documentation and type information.
Custom Schemas
If plugin use endpointMapper, SCIM schemas will be generated based on configured mapping.
To use custom SCIM schemas, copy node_modules/scimgateway/lib/scimdef-v2.json (or scimdef-v1.json) to lib/ and edit as needed. The gateway will use your version when it detects the file.
License
MIT © Jarle Elshaug
Change Log
v6.2.2
- [Improved]
plugin-entra-idnow supports Entra ID IGA Access Packages. For required API permissions, see Entra ID App Registration
v6.2.1
HelperRest: fixed minor log cosmetics introduced in v6.2.0
v6.2.0
[Fixed]
HelperRest: failed on Bun v1.3.14 due to stricter Fetch standards compliance[Improved] New
plugin-genericreplacesplugin-scim. UsesendpointMapperwith the newvalueMapoption for group allowlisting and name mapping. Default config uses one-to-one SCIM mapping with plugin-loki as the target endpoint.[Improved]
endpointMappernow supportsvalueMap:"map": { "group": { "displayName": { "mapTo": "displayName", "type": "string", "valueMap": { "outboundEndpointGrp1": "inboundScimGrp1", "Employees": "Admins" } } } }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.
v6.1.20
plugin-entra-id: roles introduced in v6.1.19 were missing when retrieving a single user
v6.1.19
- [Fixed] SCIM v2.0 ResourceType endpoint schemas using incorrect id
- [Improved]
GET /RolesandGET /Entitlementsendpoint support, with user management via SCIMrolesandentitlementsattributes - [Improved]
plugin-entra-id:entitlementsfor Entra ID licenses (read-only);rolesfor Permanent and Eligible PIM roles (full management)- PIM Eligible roles: requires
RoleEligibilitySchedule.ReadWrite.All - PIM Permanent roles: requires
RoleManagement.ReadWrite.Directory - Remove
map.user.rolesif above conditions are not met skipSignInActivityoption (v6.1.17) no longer used;signInActivityand PIM role permissions are validated at startup
- PIM Eligible roles: requires
v6.1.18
createUserandmodifyUsernow return the full user object, ensuring returned data reflects what was modified even when the endpoint hasn't internally synced yet
v6.1.17
plugin-entra-id: fixed brokenfilter=userName eq "user_upn"introduced in v6.1.11 when using updated config withmap.user.signInActivityplugin-entra-id: new optionendpoint.entity.[baseEntity].skipSignInActivity = trueto excludesignInActivity(requires Entra ID Premium +AuditLog.Read.All)
v6.1.16
plugin-entra-id:GET /Entitlementsnow usesderivedIncludeswith full recursive expansion
v6.1.15
plugin-entra-id: fixedfilter=entitlements pr
v6.1.14
- Support for filter
attribute not pr - Dependencies bump
v6.1.13
plugin-entra-id:signInActivityattributes are now filterable
v6.1.12
- Filter operator
pr(presence) now forwarded to plugins (previously rejected) plugin-entra-id: handlesprfilter on entitlements
v6.1.11
- [Fixed] Incorrect schema generation when using
endpointMapper(regression from v6.1.6) - [Improved] New
GET /Entitlementsendpoint andscimgateway.getEntitlements()method plugin-entra-id: user license information viaentitlements; removemap.user.signInActivityif Entra ID Premium is unavailable
v6.1.10
plugin-entra-id: group membership now includes nested (transitive) groups (directandindirect)- Fixed missing Docker files:
config/docker/.dockerignoreanddocker-compose-mssql.yml
v6.1.9
createUser/createGroupresponses now correctly include the generated ID
v6.1.8 / v6.1.7
- Fixed incorrect masking of secrets in request info log messages
plugin-entra-id: fixed edge case wherecreateUserwith a manager could fail
v6.1.6
- Fixed
plugin-lokiandplugin-mongodbreturning empty results when using extension schema attributes in search - Auth failure due to
readOnlynow returns HTTP 405 instead of 401 postinstallensures"type": "module"is set inpackage.jsonendpointMappernow generates a custom schema; supports"x-agent-schema"for AI MCP tool instructions
v6.1.5
- Complex filtering (
and/or) handled by the gateway using the plugin's simple filter logic modifyGroupnow returns HTTP 204 instead of 200- New
/authendpoint for validating external authentication plugin-entra-id: supportssw(startsWith) filter
v6.1.4
- Fixed OData paging in
plugin-entra-idandhelper-rest— missing users/groups/members in large directories - Fixed incomplete group membership when paging not fully iterated
v6.1.3
- Azure Relay: improved recovery on failure
plugin-ldap: improvements for Active Directory andobjectGUID/mS-DS-ConsistencyGuidmodifyGroup: adding an existing member or removing a non-existent member now returns 200 OK instead of an error
v6.1.2
- Fixed SMTP mail failure caused by an updated dependency
- Fixed
endpointMapperwhenmapTocontained multiple comma-separated attributes including a multivalued one
v6.1.1
plugin-ldap: fixed race condition wherecreateUserimmediately followed byreadUsercould fail on some systems (e.g. Samba AD)- Final info log message now includes full JSON serialization (durationMs, status, requestBody, responseBody, …)
v6.1.0
tsxincluded — SCIM Gateway now runs as ES module (TypeScript) in Node.js:node --import=tsx ./index.ts- Simplified mandatory plugin initialization using static
import index.tsupdated to use static imports- Bun binary builds now supported (see Single Binary Deployment)
v6.0.0 — Major
- API method response bodies returned as-is (previously wrapped in
{ result: <content> }) — clients parsing responses must be updated - New
scimgateway.publicApi()for unauthenticated/pub/apiroutes bearerJwtAzure.tenantIdGUIDreplaced bybearerJwt.azureTenantId— existing configurations must be updated
v5.x — Previous Major Series
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.
