IdRa

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.0

Bun installs the package; Node.js runs the app.

Configure

.env
# 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=3000

Code

server.js
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.js

Register 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:

  1. Turn on Enable provisioning.
  2. Set SCIM base URL to http://localhost:3000/scim/v2.
  3. Set API token to your SCIM_TOKEN. IdRa sends it as Authorization: Bearer and stores it encrypted.
  4. Select Test connection. You should see Connected: the app accepted the token.
  5. Under Features, Create users, Update user attributes and Deactivate users are on. Turn on Push groups too.
  6. 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 IdRaThe server receives
Assign someone on AssignmentsPOST /Users, with userName, emails, displayName, active: true, and externalId set to the IdRa user ID
Change their Profile, then SavePUT /Users/:id, the whole resource
Unassign themPATCH /Users/:id replacing active with false
Assign them againPUT /Users/:id with active: true
More actions → DeactivatePATCH /Users/:id replacing active with false
ReactivatePUT /Users/:id with active: true
More actions → SuspendPUT /Users/:id with active: true. See below.
Assign someone the app already hasPOST /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 IdRaThe server receives
Push groupPOST /Groups, with displayName, externalId and members
Add or remove a member, or rename the groupPATCH /Groups/:id replacing displayName and members
Unassign or deactivate a memberPATCH /Users/:id, then PATCH /Groups/:id without them
Assign or reactivate them againPUT /Users/:id, then PATCH /Groups/:id with them
Stop pushing (the trash icon, then Unlink), or delete the groupDELETE /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-After on 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

RequestAnswer
Wrong or missing token401
GET /Users?startIndex=1&count=1 (Test connection)200. IdRa doesn't read the body.
POST /Users, POST /Groups201 with id. IdRa stores that id and uses it from then on.
POST of a userName or displayName that exists409, and GET …?filter=userName eq "…" (or displayName) must find it
PUT /Users/:id200
PATCH /Users/:id, PATCH /Groups/:id200. Operations are replace on active, displayName and members.
DELETE /Groups/:id2xx

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:

AreaWhat IdRa does
Filtereq 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.
PaginationstartIndex 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.
Errorsschemas 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.
Create201 with a Location header. application/json is accepted as well.
PATCHadd, replace and remove. replace also works without path, and remove with members[value eq "…"].
Not supportedBulk, sort (sortBy is ignored) and ETags, as /ServiceProviderConfig says.

What this means in IdRa:

  • active: false deactivates the person: sign-in blocked, sessions and tokens ended. active: true only undoes a deactivation SCIM made.
  • DELETE /Users/:id removes 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 userName someone in IdRa already has gets 409.

Before production

  • Serve your SCIM server over https at a public address, and update SCIM base URL. In production IdRa refuses anything else, and follows no redirects.
  • Move stores to your database, and make POST /Users safe against two requests at once with a unique index on userName.
  • Leave ALLOW_FAULTS empty, and stop logging request bodies: they hold personal data.
  • Check active on 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 seeCauseFix
Connection failed: SCIM 401: Invalid or missing bearer tokenAPI token and SCIM_TOKEN differ.Set them to the same value.
Connection failed: SCIM 404: <!DOCTYPE html> … Cannot GET /UsersSCIM 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 personexpress.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 personThe app answered 409, but its filter found nobody.Make userName eq "…" work.
SCIM 503: … or SCIM 429: … on a personThe app failed for now.Nothing: IdRa retries. Retry tries again at once.
A person stays active: true after SuspendSuspending doesn't deactivate.Use Deactivate, or unassign them.
Push group is greyed outPush groups is off.Turn it on and select Save.
Inbound: SCIM bearer token is required, Invalid SCIM bearer tokenThe header is missing or the token is wrong, revoked or expired.Select New token on the connection.
Inbound: SCIM User userName already existsThat userName already came in over SCIM.Find it with a filter and PUT it.
Inbound: A Better Auth User already uses this emailThe 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 addressuserName isn't an email and there's no emails.Send emails.

For every setting, see SCIM provisioning.

On this page