SCIM provisioning (Express)
Create, update and deactivate accounts in your app automatically: a SCIM 2.0 server in Express that IdRa pushes to, and pushing into IdRa from another system.
SCIM keeps accounts in step without anyone doing it by hand. IdRa supports both directions:
- Outbound: IdRa pushes people and groups to your app. You build a small SCIM server. Most of this page.
- Inbound: an HR system or directory pushes people into IdRa. See Inbound.
The server below uses Express and nothing else. Node.js 20.12 or later.
Sample repository (being opened soon): IdRaHQ/idra-sample-scim. It has this quickstart's code.
Tested locally over http only
This page was run through the admin console against a local IdRa on http://localhost, with the versions below. It hasn't been run over https, on the hosted service or behind a proxy. Hosted and production IdRa only call https addresses on the public internet, so a server on your laptop isn't reachable from them. See Before production.
Install
mkdir idra-scim && cd idra-scim
echo '{"private":true,"type":"module"}' > package.json
bun add --exact express@5.3.0Bun installs the package; Node.js runs the app.
Configure
# Output of: openssl rand -hex 32
SCIM_TOKEN=<your token>
# 1 turns on POST /scim/v2/_fault, used below to watch retries. Never in production.
ALLOW_FAULTS=1
PORT=3000Code
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import express from 'express';
const { SCIM_TOKEN, ALLOW_FAULTS, PORT = 3000 } = process.env;
if (!SCIM_TOKEN) throw new Error('Set SCIM_TOKEN');
const USER = 'urn:ietf:params:scim:schemas:core:2.0:User';
const GROUP = 'urn:ietf:params:scim:schemas:core:2.0:Group';
const LIST = 'urn:ietf:params:scim:api:messages:2.0:ListResponse';
const ERROR = 'urn:ietf:params:scim:api:messages:2.0:Error';
// In-memory, so a restart forgets everyone. Swap these for your database.
const stores = {
Users: { rows: new Map(), unique: 'userName', schema: USER },
Groups: { rows: new Map(), unique: 'displayName', schema: GROUP },
};
const sha256 = (s) => createHash('sha256').update(s).digest();
const tokenOk = (header = '') =>
header.startsWith('Bearer ') && timingSafeEqual(sha256(header.slice(7)), sha256(SCIM_TOKEN));
const scimError = (res, status, detail, scimType) =>
res.status(status).type('application/scim+json').json({ schemas: [ERROR], status: String(status), detail, ...(scimType && { scimType }) });
const send = (res, status, body) => res.status(status).type('application/scim+json').json(body);
const app = express();
// IdRa sends application/scim+json, which express.json() ignores by default.
app.use(express.json({ type: ['application/scim+json', 'application/json'] }));
const scim = express.Router();
let fault = { status: 0, times: 0 };
scim.use((req, res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} ${req.body ? JSON.stringify(req.body) : ''}`);
if (!tokenOk(req.get('authorization'))) return scimError(res, 401, 'Invalid or missing bearer token');
if (ALLOW_FAULTS === '1' && req.path === '/_fault') {
fault = req.body;
return res.sendStatus(204);
}
if (fault.times > 0) {
fault.times -= 1;
return scimError(res, fault.status, `Simulated ${fault.status}`);
}
next();
});
// What IdRa asks for: attr eq "value". Anything else is refused, not ignored.
function parseFilter(filter, attr) {
const m = new RegExp(`^${attr} eq "((?:[^"\\\\]|\\\\.)*)"$`, 'i').exec(filter);
return m && m[1].replace(/\\(.)/g, '$1');
}
function applyPatch(row, operations) {
for (const { op, path, value } of operations) {
const verb = String(op).toLowerCase();
if (!['add', 'replace', 'remove'].includes(verb)) return `Unsupported op: ${op}`;
if (!path && verb !== 'remove') Object.assign(row, value);
else if (!/^[a-zA-Z]+$/.test(path ?? '') || path === 'id') return `Unsupported path: ${path}`;
else if (verb === 'remove') delete row[path];
else row[path] = value;
}
return null;
}
for (const [collection, store] of Object.entries(stores)) {
const present = (row) => ({ ...row, schemas: row.schemas ?? [store.schema], meta: { resourceType: collection.slice(0, -1), location: `/scim/v2/${collection}/${row.id}` } });
const conflict = (value, id) => [...store.rows.values()].some((r) => r.id !== id && r[store.unique] === value);
scim.get(`/${collection}`, (req, res) => {
let rows = [...store.rows.values()];
if (req.query.filter) {
const value = parseFilter(req.query.filter, store.unique);
if (value === null) return scimError(res, 400, `Only ${store.unique} eq "value" is supported`, 'invalidFilter');
rows = rows.filter((r) => r[store.unique] === value);
}
const startIndex = Math.max(Number(req.query.startIndex) || 1, 1);
const count = Math.max(Number(req.query.count ?? 100) || 0, 0);
const page = rows.slice(startIndex - 1, startIndex - 1 + count);
send(res, 200, { schemas: [LIST], totalResults: rows.length, startIndex, itemsPerPage: page.length, Resources: page.map(present) });
});
scim.post(`/${collection}`, (req, res) => {
const value = req.body?.[store.unique];
if (!value) return scimError(res, 400, `${store.unique} is required`, 'invalidValue');
// IdRa answers a 409 by finding the resource with a filter and adopting it.
if (conflict(value)) return scimError(res, 409, `${store.unique} already exists`, 'uniqueness');
const row = { ...req.body, id: randomUUID() };
store.rows.set(row.id, row);
send(res, 201, present(row));
});
scim.get(`/${collection}/:id`, (req, res) => {
const row = store.rows.get(req.params.id);
return row ? send(res, 200, present(row)) : scimError(res, 404, 'Not found');
});
scim.put(`/${collection}/:id`, (req, res) => {
if (!store.rows.has(req.params.id)) return scimError(res, 404, 'Not found');
if (conflict(req.body?.[store.unique], req.params.id)) return scimError(res, 409, `${store.unique} already exists`, 'uniqueness');
const row = { ...req.body, id: req.params.id };
store.rows.set(row.id, row);
send(res, 200, present(row));
});
scim.patch(`/${collection}/:id`, (req, res) => {
const row = store.rows.get(req.params.id);
if (!row) return scimError(res, 404, 'Not found');
const problem = applyPatch(row, req.body?.Operations ?? []);
row.id = req.params.id;
if (problem) return scimError(res, 400, problem, 'invalidPath');
send(res, 200, present(row));
});
scim.delete(`/${collection}/:id`, (req, res) => {
if (!store.rows.delete(req.params.id)) return scimError(res, 404, 'Not found');
res.sendStatus(204);
});
}
app.use('/scim/v2', scim);
// Malformed JSON, and anything else that throws, still answers in SCIM's error shape.
app.use((err, req, res, next) => scimError(res, err.status ?? 500, err.status ? err.message : 'Server error'));
app.listen(PORT, () => console.log(`SCIM base URL: http://localhost:${PORT}/scim/v2`));It keeps everything in memory, so a restart forgets everyone. Replace stores with your database. It logs each request, so you can see what IdRa sends.
Run
node --env-file=.env server.jsRegister the app
In the admin console, go to Applications → Create App Integration, and set:
- Sign-in method: Bookmark for this test, or the method your app really uses. Provisioning works with any of them.
- App integration name: any name
- URL:
http://localhost:3000
Leave Assign this app to me so I can test it on, then select Create. On the app's Provisioning tab:
- Turn on Enable provisioning.
- Set SCIM base URL to
http://localhost:3000/scim/v2. - Set API token to your
SCIM_TOKEN. IdRa sends it asAuthorization: Bearerand stores it encrypted. - Select Test connection. You should see Connected: the app accepted the token.
- Under Features, Create users, Update user attributes and Deactivate users are on. Turn on Push groups too.
- Select Save.
Saving pushes everyone already assigned, so the server logs a POST /scim/v2/Users for you right away.
Verify
Each row below is what the server logged when we did it in the console.
| Do this in IdRa | The server receives |
|---|---|
| Assign someone on Assignments | POST /Users, with userName, emails, displayName, active: true, and externalId set to the IdRa user ID |
| Change their Profile, then Save | PUT /Users/:id, the whole resource |
| Unassign them | PATCH /Users/:id replacing active with false |
| Assign them again | PUT /Users/:id with active: true |
| More actions → Deactivate | PATCH /Users/:id replacing active with false |
| Reactivate | PUT /Users/:id with active: true |
| More actions → Suspend | PUT /Users/:id with active: true. See below. |
| Assign someone the app already has | POST /Users answered 409, GET /Users?filter=userName eq "…", then PUT /Users/:id |
Suspending doesn't deactivate
Suspend blocks sign-in to IdRa and to apps that sign in through it, but the account in your app stays active: true. Anything your app accepts without IdRa, such as its own API keys, keeps working. To cut that off too, use Deactivate or unassign the person.
People who haven't finished activating are pushed as well, with active: true.
Groups
On Provisioning, under Push groups, select Push group and choose a group. Everyone's own account comes first: a pushed group lists only members who are Provisioned in this app. Everyone can't be pushed.
| Do this in IdRa | The server receives |
|---|---|
| Push group | POST /Groups, with displayName, externalId and members |
| Add or remove a member, or rename the group | PATCH /Groups/:id replacing displayName and members |
| Unassign or deactivate a member | PATCH /Users/:id, then PATCH /Groups/:id without them |
| Assign or reactivate them again | PUT /Users/:id, then PATCH /Groups/:id with them |
| Stop pushing (the trash icon, then Unlink), or delete the group | DELETE /Groups/:id |
Retries
Make the next request fail, then assign someone:
curl -X POST http://localhost:3000/scim/v2/_fault \
-H "Authorization: Bearer $SCIM_TOKEN" -H 'Content-Type: application/json' \
-d '{"status":503,"times":1}'The People table shows Error and SCIM 503: Simulated 503. 20 seconds later the same POST arrives and the row turns Provisioned. With {"status":429,"times":2} we saw the POST at 0, 20 and 60 seconds.
- A job makes up to 8 attempts, waiting 20 seconds, then doubling up to an hour. That's about 42 minutes in total. We watched the first three attempts, and read the rest from the code.
- A
Retry-Afteron a 429 or 503, in seconds or as a date, makes the next attempt wait at least that long, up to an hour. - Otherwise every failure is treated alike: a 401, 404 or 400 is retried as often as a 503.
- After the last attempt the row stays on Error. Select Retry, or Push all for everyone.
What your server must do
| Request | Answer |
|---|---|
| Wrong or missing token | 401 |
GET /Users?startIndex=1&count=1 (Test connection) | 200. IdRa doesn't read the body. |
POST /Users, POST /Groups | 201 with id. IdRa stores that id and uses it from then on. |
POST of a userName or displayName that exists | 409, and GET …?filter=userName eq "…" (or displayName) must find it |
PUT /Users/:id | 200 |
PATCH /Users/:id, PATCH /Groups/:id | 200. Operations are replace on active, displayName and members. |
DELETE /Groups/:id | 2xx |
IdRa never deletes a user from your app, only sets active: false. Your app has to refuse sign-in and API use for inactive accounts: SCIM only carries the state. It doesn't call /ServiceProviderConfig, /Schemas or /ResourceTypes, so this server leaves them out.
Change what's sent under Attribute mappings. By default, title and the enterprise department come from the profile, and are left out while empty.
Inbound: push people into IdRa
Go to Directory → Directory Integrations → Add SCIM connection, enter a Name and select Create. Copy the SCIM bearer token, shown once and valid for a year, then select I've saved it. The base URL is https://<your IdRa address>/api/auth/scim/v2.
export B=http://localhost:4852/api/auth/scim/v2
curl -X POST "$B/Users" -H "Authorization: Bearer $SCIM_TOKEN" \
-H 'Content-Type: application/scim+json' \
-d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"hr1@acme.test","externalId":"hr-1","name":{"givenName":"Hana","familyName":"Ruiz"},"emails":[{"value":"hr1@acme.test","primary":true}]}'
curl -H "Authorization: Bearer $SCIM_TOKEN" -G "$B/Users" --data-urlencode 'filter=userName eq "hr1@acme.test"'We drove /Users and /Groups with curl and compared the answers with RFC 7644:
| Area | What IdRa does |
|---|---|
| Filter | eq only. On users: userName, externalId, emails.value. On groups: displayName. Attribute and operator names ignore case. Anything else gets 400, scimType: invalidFilter, such as filter operator sw is not supported. |
| Pagination | startIndex and count, with totalResults, itemsPerPage, startIndex and Resources. 100 results a page at most, the default too. startIndex=0 counts as 1, and a negative count as 0. count=abc gets 400. |
| Errors | schemas with the SCIM error URN, status as a string, detail, and scimType where one applies: uniqueness on 409, invalidValue and invalidSyntax on 400. A 401 has WWW-Authenticate: Bearer. |
| Create | 201 with a Location header. application/json is accepted as well. |
| PATCH | add, replace and remove. replace also works without path, and remove with members[value eq "…"]. |
| Not supported | Bulk, sort (sortBy is ignored) and ETags, as /ServiceProviderConfig says. |
What this means in IdRa:
active: falsedeactivates the person: sign-in blocked, sessions and tokens ended.active: trueonly undoes a deactivation SCIM made.DELETE /Users/:idremoves the SCIM record and deactivates the person. The IdRa account stays.- Groups are stored and answered over SCIM, but they don't become IdRa groups, and grant no app. Use group rules on attributes you send, such as the enterprise
department. - SCIM can't take over an existing account. A
userNamesomeone in IdRa already has gets409.
Before production
- Serve your SCIM server over
httpsat a public address, and update SCIM base URL. In production IdRa refuses anything else, and follows no redirects. - Move
storesto your database, and makePOST /Userssafe against two requests at once with a unique index onuserName. - Leave
ALLOW_FAULTSempty, and stop logging request bodies: they hold personal data. - Check
activeon every sign-in and API call in your app. - Answer quickly. IdRa gives up on a request after 15 seconds and retries it.
- To change the token, accept both old and new while you update API token. Saving with the field blank keeps the stored token.
Troubleshooting
| What you see | Cause | Fix |
|---|---|---|
Connection failed: SCIM 401: Invalid or missing bearer token | API token and SCIM_TOKEN differ. | Set them to the same value. |
Connection failed: SCIM 404: <!DOCTYPE html> … Cannot GET /Users | SCIM base URL is missing /scim/v2. | Use the full base URL. |
Connection failed: ECONNREFUSED: Unable to connect. Is the computer able to access the url? | The server isn't running, or the port is wrong. | Start it, and check the port. |
SCIM 400: userName is required on a person | express.json() ignores application/scim+json, so the body was empty. | Pass it the type option, as in the code above. |
SCIM 409: userName already exists on a person | The app answered 409, but its filter found nobody. | Make userName eq "…" work. |
SCIM 503: … or SCIM 429: … on a person | The app failed for now. | Nothing: IdRa retries. Retry tries again at once. |
A person stays active: true after Suspend | Suspending doesn't deactivate. | Use Deactivate, or unassign them. |
| Push group is greyed out | Push groups is off. | Turn it on and select Save. |
Inbound: SCIM bearer token is required, Invalid SCIM bearer token | The header is missing or the token is wrong, revoked or expired. | Select New token on the connection. |
Inbound: SCIM User userName already exists | That userName already came in over SCIM. | Find it with a filter and PUT it. |
Inbound: A Better Auth User already uses this email | The person already has an IdRa account. | Use a different address, or manage that person in IdRa. |
Inbound: [body.emails] emails must contain an email when userName is not an email address | userName isn't an email and there's no emails. | Send emails. |
For every setting, see SCIM provisioning.