# SCIM provisioning (Express) (https://docs.idra.zone/docs/quickstarts/scim)

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](#inbound-push-people-into-idra).

The server below uses [Express](https://expressjs.com) and nothing else. Node.js 20.12 or later.

Sample repository (being opened soon): [IdRaHQ/idra-sample-scim](https://github.com/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](#before-production).

## Install

```sh
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

```sh title=".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

```js title="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

```sh
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 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:

```sh
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

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

```sh
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: 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](https://docs.idra.zone/docs/admin/groups#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 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](https://docs.idra.zone/docs/admin/scim).