@lpshanley/scim-core 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +193 -2
- package/dist/adapter.d.ts +45 -0
- package/dist/adapter.js +4 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +28 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/operations.d.ts +104 -0
- package/dist/operations.js +204 -0
- package/dist/protocol.d.ts +33 -0
- package/dist/protocol.js +462 -0
- package/dist/resources.d.ts +129 -0
- package/dist/resources.js +212 -0
- package/dist/scim.d.ts +62 -0
- package/dist/scim.js +171 -0
- package/dist/testing/contract.d.ts +14 -0
- package/dist/testing/contract.js +164 -0
- package/dist/testing/memory.d.ts +10 -0
- package/dist/testing/memory.js +181 -0
- package/dist/testing/provider.d.ts +67 -0
- package/dist/testing/provider.js +108 -0
- package/dist/testing/recorder.d.ts +38 -0
- package/dist/testing/recorder.js +128 -0
- package/dist/testing.d.ts +4 -0
- package/dist/testing.js +4 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +1 -0
- package/package.json +46 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Shanley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,194 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lpshanley/scim-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A SCIM 2.0 service provider for Node.js servers built on `Request` and `Response`. An identity provider such as Okta or Microsoft Entra ID calls it to create, update and deactivate your users and groups.
|
|
4
|
+
|
|
5
|
+
The package owns the protocol: validation, PATCH, filters, paging, discovery and SCIM responses. Your app owns tenants, tokens, storage, identity linking and what deprovisioning means.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
This is an early release. It covers Users, Groups and discovery. It was extracted from a production app whose SCIM endpoint has received authentik pushes, and the package itself has handled live pushes from a disposable authentik 2026.8.5 and from an Okta trial org, using the catalog's SCIM 2.0 Test App, a classic App Integration Wizard app and an app built in the newer Okta Integration Wizard. That traffic is [kept in the repository](https://github.com/lpshanley/oidc-start/tree/main/packages/scim-core/recordings) and replayed in the tests. Failures were tested too: Okta's and authentik's reactions to 503 and 409 answers are recorded below. **No real Entra push has been tested**, so try it before relying on it.
|
|
10
|
+
|
|
11
|
+
| Supported | Not supported yet |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `/Users` and `/Groups`: create, read, list, PUT, PATCH, DELETE | Bulk operations and `/Me`, which answer 501 |
|
|
14
|
+
| `/ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | Sorting, ETags and `POST /.search`, which answers 405 |
|
|
15
|
+
| Bearer tokens, resolved to a connection per request | Filters beyond `eq` clauses joined by `and` |
|
|
16
|
+
| The core User and Group schemas, except `password` and `groups`, plus the enterprise extension | Custom schema extensions. Unknown attributes are accepted and not stored |
|
|
17
|
+
| `attributes` and `excludedAttributes` on every resource response | Groups as group members |
|
|
18
|
+
| Filters on `id`, `userName`, `externalId`, `displayName`, `emails.value` and `emails[type eq "x"].value` | |
|
|
19
|
+
| Provider quirks: capitalized `op`, string booleans, no-path PATCH | |
|
|
20
|
+
|
|
21
|
+
## Mount it
|
|
22
|
+
|
|
23
|
+
The package needs Node.js 22.12 or newer.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createScim, createScimAdapter } from "@lpshanley/scim-core";
|
|
27
|
+
|
|
28
|
+
const adapter = createScimAdapter({
|
|
29
|
+
read: (connection) => directoryStore(db, connection),
|
|
30
|
+
transaction: (connection, fn) => db.transaction((tx) => fn(directoryStore(tx, connection), tx)),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const scim = createScim({
|
|
34
|
+
adapter,
|
|
35
|
+
authenticate: (token) => connections.findByTokenDigest(sha256(token)),
|
|
36
|
+
onUserDeprovisioned: (user, { tx }) => deleteMemberData(tx, user),
|
|
37
|
+
onChange: (change, { tx }) => audit(tx, change),
|
|
38
|
+
onCommitted: (change) => roles.clearCache(change),
|
|
39
|
+
onProblem: (problem) => log.warn("scim", problem.kind, problem.status),
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`scim.handle` takes a `Request` and returns a `Response`, so it works anywhere those exist. In TanStack Start, mount it at `src/routes/scim/v2/$.ts`:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
47
|
+
|
|
48
|
+
const handle = async ({ request }: { request: Request }) => {
|
|
49
|
+
const { scim } = await import("../../../scim.server");
|
|
50
|
+
return scim.handle(request);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Route = createFileRoute("/scim/v2/$")({
|
|
54
|
+
server: { handlers: { GET: handle, POST: handle, PUT: handle, PATCH: handle, DELETE: handle } },
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Providers authenticate with a bearer token, so exempt this path from any browser CSRF check. If you mount somewhere else, set `basePath`. Behind a proxy that changes the host, set `origin` so resource locations stay correct.
|
|
59
|
+
|
|
60
|
+
## Options
|
|
61
|
+
|
|
62
|
+
| Option | Purpose |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `adapter` | Your storage. See [Write an adapter](#write-an-adapter). |
|
|
65
|
+
| `authenticate(token, request)` | Returns the connection for a token, or null to answer 401. Compare digests, and never store or log the token. |
|
|
66
|
+
| `enabled()` | Answers 404 while false, for example until a token exists. |
|
|
67
|
+
| `onUserDeprovisioned(user, context)` | Runs inside the transaction when a user is deactivated or deleted. If it throws, the change rolls back and the provider gets a 503 it can retry. What it returns reaches the change as `details`. |
|
|
68
|
+
| `onChange(change, context)` | Runs inside the transaction for every change. Use it for audit rows. |
|
|
69
|
+
| `onCommitted(change, context)` | Runs after commit. A failure here can't undo the change, and it is reported to `onProblem`. |
|
|
70
|
+
| `onProblem(problem)` | Reports refused and failed requests. A refusal can leave a leaver with access, so alert on these. |
|
|
71
|
+
| `externalId` | `"mutable"` (the default) or `"fixed"`. See [Behaviour worth knowing](#behaviour-worth-knowing). |
|
|
72
|
+
| `basePath`, `origin`, `documentationUri` | Where the routes live and what discovery reports. |
|
|
73
|
+
|
|
74
|
+
### Behaviour worth knowing
|
|
75
|
+
|
|
76
|
+
- **Omitted fields on PUT:** a missing `active` or `externalId` keeps its current value, so a sparse update can't reactivate someone or break a link.
|
|
77
|
+
- **`externalId` can change:** Entra sends a mail alias as `externalId` by default, and it changes when a person is renamed. The new value is stored, and the change carries `previousExternalId`, so relink there if your app links on it. Removing it is still refused.
|
|
78
|
+
- **`externalId: "fixed"` refuses that change:** choose it when your app treats `externalId` as the link to a sign-in identity. A change then answers `400 mutability`, which is stricter than RFC 7643 and fails the whole request, including a rename sent with it. A deactivation still goes through and keeps the stored value, and the change carries `externalIdConflict`.
|
|
79
|
+
- **Reactivation is its own change:** an inactive user made active again reports `user.reactivated`, not `user.updated`, so you can restore what `onUserDeprovisioned` ended.
|
|
80
|
+
- **Storage failures answer 503, never 404:** a 404 would tell the provider the user is gone. Okta shows its admin the package's own `detail` text, and then does not retry. See [When a request fails](#when-a-request-fails).
|
|
81
|
+
- **Unknown attributes are accepted and dropped:** refusing one would fail the whole request, which might also be a deactivation. This includes unknown sub-attributes inside a list entry or `manager`. `/Schemas` lists what is kept.
|
|
82
|
+
- **`null` means unassigned:** on POST and PUT, a null value is treated as omitted. A null `userName` is still refused.
|
|
83
|
+
- **No `meta.version`:** ETags aren't supported, so responses don't claim a version. Your store still tracks one for your own use.
|
|
84
|
+
- **A typed value is created when missing:** a `replace` of `emails[type eq "work"].value` adds the address if there is none, where RFC 7644 asks for `noTarget`. Other unmatched value paths do answer `noTarget`.
|
|
85
|
+
- **PATCH paths are checked:** a malformed path answers `invalidPath`, and a path naming `id`, `meta`, `groups` or `schemas` answers `mutability`. A no-path PATCH ignores those keys, because Okta sends the group's `id` when renaming it.
|
|
86
|
+
- **A value filter beyond `type` or `value` is dropped:** a path such as `emails[type eq "work" and primary eq true].value` is valid, not applied, and answers 200 like an unknown attribute.
|
|
87
|
+
- **Two spellings of one attribute:** if a body has both `active` and `Active`, the canonical spelling wins.
|
|
88
|
+
- **A missing `schemas` is accepted:** on any request, which is more lenient than the RFC.
|
|
89
|
+
- **A change that changes nothing is skipped:** a PATCH or PUT that leaves the resource as it was keeps its `lastModified`, and no hook runs. Okta's wizard apps send such PUTs for groups.
|
|
90
|
+
|
|
91
|
+
## Use it without HTTP
|
|
92
|
+
|
|
93
|
+
`forConnection` gives application code the same operations, with the same transaction and hooks as a provider's request:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
await scim.forConnection(connection).users.deactivate(id);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Write an adapter
|
|
100
|
+
|
|
101
|
+
A connection is whatever `authenticate` returns, usually one customer's directory. Implement [`ScimStore`](https://github.com/lpshanley/oidc-start/blob/main/packages/scim-core/src/adapter.ts) for one connection, and return it from `read` and from `transaction`. Stores must only see that connection's rows.
|
|
102
|
+
|
|
103
|
+
A user has first-class `emails`, and `attributes` holds the rest of the schema, such as `title`, `phoneNumbers` and the enterprise extension under its URN. Store `attributes` as JSON and return it unchanged. `store.users` must also filter on `id`, `email` and `emailType`, and `testScimAdapter` checks this.
|
|
104
|
+
|
|
105
|
+
To give hooks your own user fields, such as a linked OIDC subject, return them from the store and pass your type to `createScimAdapter<Connection, MyUser, MyTransaction>`.
|
|
106
|
+
|
|
107
|
+
## Test it
|
|
108
|
+
|
|
109
|
+
`@lpshanley/scim-core/testing` has these tools:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { createMemoryAdapter, createTestProvider, testScimAdapter } from "@lpshanley/scim-core/testing";
|
|
113
|
+
import { expect, test } from "vitest";
|
|
114
|
+
|
|
115
|
+
// Does my adapter honour the contract? Run this against your real database.
|
|
116
|
+
testScimAdapter({
|
|
117
|
+
setup: async () => ({ adapter: await emptyAdapter(), connections: ["acme", "globex"] }),
|
|
118
|
+
test,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Does my app behave when a provider's requests arrive?
|
|
122
|
+
test("a leaver loses access", async () => {
|
|
123
|
+
const idp = createTestProvider(scim, { dialect: "entra", token });
|
|
124
|
+
const user = await idp.createUser({ userName: "a@x.test" });
|
|
125
|
+
await idp.deactivate(user);
|
|
126
|
+
expect(await sessions.forUser(user)).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`createMemoryAdapter()` is a single-process adapter for trying the package and for fast tests. The `okta`, `okta-wizard`, `entra` and `authentik` dialects are modelled on vendor documentation and that app's records, not captured traffic, so they show your app handles those shapes, not that a real push works. The repository's [`recordings/`](https://github.com/lpshanley/oidc-start/tree/main/packages/scim-core/recordings) folder holds captured traffic from authentik and from three Okta app types. All three Okta apps update a profile with a GET and a PUT. The test app and the newer wizard app deactivate and reactivate with a no-path PATCH and change groups with PATCH, and the classic wizard app uses PUT for all of those. `okta` is an OIN integration, and `okta-wizard` is an App Integration Wizard app, which updates with PUT.
|
|
131
|
+
|
|
132
|
+
### When a request fails
|
|
133
|
+
|
|
134
|
+
Okta's behaviour, observed against writes that failed ([`recordings/okta-failures-2026-11.jsonl`](https://github.com/lpshanley/oidc-start/blob/main/packages/scim-core/recordings/okta-failures-2026-11.jsonl)):
|
|
135
|
+
|
|
136
|
+
- **It does not retry.** A create that answered 503 stayed failed for eight minutes, including after the server recovered. The admin has to select the assignment on the Tasks page and press **Retry Selected**, which repeats the lookup and the create.
|
|
137
|
+
- **It shows your `detail` text** in the assignment's error, so keep those messages free of anything private.
|
|
138
|
+
- **A 409 on create is a plain failure.** Okta does not look the user up again or link them.
|
|
139
|
+
- **A failed deactivation becomes a manual task.** Okta creates "Application accounts need deprovisioning" for the admin and sent no retry in the three minutes we watched. Until someone acts, the person is still active in your app. A deactivation sent by unassigning someone and one sent by deactivating them in Okta are the same `PATCH`.
|
|
140
|
+
|
|
141
|
+
So alert on `onProblem` with `kind: "failed"` for a `PATCH`, `PUT` or `DELETE`. Okta will not try again, and it only raises a task in its own console.
|
|
142
|
+
|
|
143
|
+
To reproduce a failure against a real provider, the harness can fail writes on demand: write `{"mode":"unavailable","times":2}` (or `"conflict"`) to `harness/fault.json` in the repository's [harness](https://github.com/lpshanley/oidc-start/blob/main/packages/scim-core/harness/server.mjs) while it runs.
|
|
144
|
+
|
|
145
|
+
### authentik
|
|
146
|
+
|
|
147
|
+
Observed against authentik 2026.8.5 ([`recordings/authentik-2026.8.5.jsonl`](https://github.com/lpshanley/oidc-start/blob/main/packages/scim-core/recordings/authentik-2026.8.5.jsonl) and [`recordings/authentik-failures-2026.8.5.jsonl`](https://github.com/lpshanley/oidc-start/blob/main/packages/scim-core/recordings/authentik-failures-2026.8.5.jsonl)):
|
|
148
|
+
|
|
149
|
+
- **It retries failed writes by itself.** A user update that answered 503 was retried through authentik's job queue with growing delays, about one and a half, four and five minutes, and applied about a minute after the server recovered, ten minutes after the first failure. That is the opposite of Okta, so a short outage heals without anyone acting. How long it keeps trying is not known.
|
|
150
|
+
- **A 409 on create makes it look the user up by `userName`,** then link to the match, or create again if there is none. A user that already existed is linked and not changed at first. Its next update, and every update in a full sync, then sends authentik's own `externalId`. By default the package stores it. With `externalId: "fixed"` each of those updates answers `400 mutability`, so a pre-existing user whose `externalId` differs stays stuck. Don't pre-create people with a different `externalId` if you choose `"fixed"`.
|
|
151
|
+
- **Attributes outside the kept set are dropped.** A custom property mapping sent `costCenter` and a custom schema URN, and the package answered 200 and stored neither. `title`, `phoneNumbers` and the enterprise extension were kept.
|
|
152
|
+
- **All seven compatibility modes work:** default, aws, slack, sfdc, gitlab, webex and vcenter. AWS, Salesforce, GitLab and vCenter change groups with `PUT` where the others use `PATCH`, and Webex adds `"type": "user"` to members. Salesforce mode asks for `/ServiceProviderConfigs`, which answers 404 and does no harm.
|
|
153
|
+
- **The default full sync runs every four hours** (`49 */4 * * *`), not every hour. It sends a `PUT` for every user, and the package skips the ones that changed nothing.
|
|
154
|
+
- **Volume:** 300 new users synced in under two minutes, listing paged correctly at 100 a time, and a full sync of 305 users finished in under a minute with no errors. Authentik itself left two of the 300 unpushed until the next full sync, with no error in either system, so a user can be missing for up to four hours.
|
|
155
|
+
|
|
156
|
+
### Capture real traffic
|
|
157
|
+
|
|
158
|
+
`recordTraffic` wraps an instance and reports each exchange. Headers are never kept, passwords are dropped, and names, emails and filter values become placeholders. `replayTraffic` sends a recording to a fresh instance and returns any exchange whose status changed, so a provider's real requests can become a regression test:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const recording = readFileSync("okta.jsonl", "utf8").trim().split("\n").map((line) => JSON.parse(line));
|
|
162
|
+
expect(await replayTraffic(scim, recording, { token })).toEqual([]);
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Read a recording before you commit it, because an attribute the scrubber doesn't know about keeps its value.
|
|
166
|
+
|
|
167
|
+
In the repository, [`harness/server.mjs`](https://github.com/lpshanley/oidc-start/blob/main/packages/scim-core/harness/server.mjs) is a throwaway server for this. It keeps users in memory, and it listens on localhost, so a provider needs an HTTPS tunnel to reach it:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
pnpm --filter @lpshanley/scim-core build
|
|
171
|
+
cd packages/scim-core
|
|
172
|
+
SCIM_HARNESS_TOKEN="$(openssl rand -hex 32)" node harness/server.mjs
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Use it with oidc-start
|
|
176
|
+
|
|
177
|
+
The two packages don't depend on each other, and SCIM users aren't `AuthUser`s: someone can be provisioned before they ever sign in. Linking them is a choice your app makes:
|
|
178
|
+
|
|
179
|
+
- **Link deliberately.** A SCIM `id` or `externalId` is not automatically an OIDC `(issuer, sub)`. Only treat `externalId` as `sub` if your provider's configuration guarantees it, and then set `externalId: "fixed"`. Linking by email at first sign-in needs a verified email claim from a trusted issuer.
|
|
180
|
+
- **Refuse deprovisioned users.** Check your directory in `allowSession`.
|
|
181
|
+
- **End current sessions.** Call `SessionRegistry.endAll` once the change commits:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const scim = createScim({
|
|
185
|
+
// ...
|
|
186
|
+
onCommitted: async (change, { connection }) => {
|
|
187
|
+
if (change.type !== "user.deactivated" && change.type !== "user.deleted") return;
|
|
188
|
+
if (change.user.subject)
|
|
189
|
+
await registry.endAll({ issuer: connection.issuer, clientId: connection.clientId, sub: change.user.subject });
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
If `endAll` fails, the change has already committed, and `onProblem` hears about it as `after-commit`. `allowSession` covers that gap, because oidc-start checks it whenever it resolves a session, not only at sign-in. The directory check there must fail closed.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ScimGroup, ScimGroupFields, ScimGroupFilter, ScimPage, ScimUser, ScimUserFields, ScimUserFilter } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Storage for one connection. Inside a transaction, `user` and `group` should
|
|
4
|
+
* lock the row, because a write is based on what they return.
|
|
5
|
+
*
|
|
6
|
+
* Absent resources come back as undefined or false. A write that would
|
|
7
|
+
* duplicate a live userName, externalId or group name throws ScimConflictError,
|
|
8
|
+
* and member changes naming an unknown user throw ScimError 400 invalidValue.
|
|
9
|
+
* Adding a member twice and removing an absent member are no-ops.
|
|
10
|
+
*/
|
|
11
|
+
export interface ScimStore<TUser extends ScimUser = ScimUser> {
|
|
12
|
+
user(id: string): Promise<TUser | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Every given key must match. `userName`, `email` and `emailType` match
|
|
15
|
+
* case-insensitively, and `emailType` applies to the same address as `email`.
|
|
16
|
+
* Order must be stable across pages.
|
|
17
|
+
*/
|
|
18
|
+
users(filter: ScimUserFilter, offset: number, limit: number): Promise<ScimPage<TUser>>;
|
|
19
|
+
createUser(fields: ScimUserFields): Promise<TUser>;
|
|
20
|
+
replaceUser(id: string, fields: ScimUserFields): Promise<TUser | undefined>;
|
|
21
|
+
/** Also removes the user from every group. */
|
|
22
|
+
deleteUser(id: string): Promise<TUser | undefined>;
|
|
23
|
+
group(id: string): Promise<ScimGroup | undefined>;
|
|
24
|
+
/** `displayName` matches case-insensitively, as it does for uniqueness. */
|
|
25
|
+
groups(filter: ScimGroupFilter, offset: number, limit: number): Promise<ScimPage<ScimGroup>>;
|
|
26
|
+
createGroup(fields: ScimGroupFields): Promise<string>;
|
|
27
|
+
/** An undefined externalId keeps the current value. */
|
|
28
|
+
renameGroup(id: string, displayName: string, externalId?: string | null): Promise<boolean>;
|
|
29
|
+
setMembers(groupId: string, userIds: string[]): Promise<void>;
|
|
30
|
+
addMembers(groupId: string, userIds: string[]): Promise<void>;
|
|
31
|
+
removeMembers(groupId: string, userIds: string[]): Promise<void>;
|
|
32
|
+
deleteGroup(id: string): Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* How the package reaches the host's storage. A connection is whatever the
|
|
36
|
+
* host's `authenticate` returns, usually one customer's directory. Stores must
|
|
37
|
+
* only see that connection's rows. `TTransaction` is the host's handle, passed
|
|
38
|
+
* to hooks so their writes commit or roll back with the change.
|
|
39
|
+
*/
|
|
40
|
+
export interface ScimAdapter<TConnection, TUser extends ScimUser = ScimUser, TTransaction = undefined> {
|
|
41
|
+
read(connection: TConnection): ScimStore<TUser>;
|
|
42
|
+
transaction<T>(connection: TConnection, fn: (store: ScimStore<TUser>, tx: TTransaction) => Promise<T>): Promise<T>;
|
|
43
|
+
}
|
|
44
|
+
/** Declares an adapter and infers its types for `createScim`. */
|
|
45
|
+
export declare function createScimAdapter<TConnection, TUser extends ScimUser = ScimUser, TTransaction = undefined>(adapter: ScimAdapter<TConnection, TUser, TTransaction>): ScimAdapter<TConnection, TUser, TTransaction>;
|
package/dist/adapter.js
ADDED
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A SCIM protocol error (RFC 7644 §3.12). */
|
|
2
|
+
export declare class ScimError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly scimType?: string;
|
|
5
|
+
constructor(status: number, scimType?: string, detail?: string);
|
|
6
|
+
body(): {
|
|
7
|
+
detail: string;
|
|
8
|
+
scimType?: string | undefined;
|
|
9
|
+
schemas: string[];
|
|
10
|
+
status: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Thrown by an adapter when a write would duplicate a unique value. Answers 409. */
|
|
14
|
+
export declare class ScimConflictError extends ScimError {
|
|
15
|
+
constructor(detail?: string);
|
|
16
|
+
}
|
|
17
|
+
export declare const notFound: () => ScimError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
|
|
2
|
+
/** A SCIM protocol error (RFC 7644 §3.12). */
|
|
3
|
+
export class ScimError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
scimType;
|
|
6
|
+
constructor(status, scimType, detail = "") {
|
|
7
|
+
super(detail || scimType || String(status));
|
|
8
|
+
this.name = "ScimError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.scimType = scimType;
|
|
11
|
+
}
|
|
12
|
+
body() {
|
|
13
|
+
return {
|
|
14
|
+
schemas: [ERROR],
|
|
15
|
+
status: String(this.status),
|
|
16
|
+
...(this.scimType ? { scimType: this.scimType } : {}),
|
|
17
|
+
detail: this.message,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Thrown by an adapter when a write would duplicate a unique value. Answers 409. */
|
|
22
|
+
export class ScimConflictError extends ScimError {
|
|
23
|
+
constructor(detail = "Already exists") {
|
|
24
|
+
super(409, "uniqueness", detail);
|
|
25
|
+
this.name = "ScimConflictError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export const notFound = () => new ScimError(404, undefined, "Resource not found");
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createScim, type ScimOptions, type ScimProblem } from "./scim.js";
|
|
2
|
+
export { createScimAdapter, type ScimAdapter, type ScimStore } from "./adapter.js";
|
|
3
|
+
export { ScimConflictError, ScimError } from "./errors.js";
|
|
4
|
+
export type { ScimChange, ScimHooks } from "./operations.js";
|
|
5
|
+
export type { ScimAttributes, ScimEmail, ScimGroup, ScimGroupChange, ScimGroupFields, ScimGroupFilter, ScimName, ScimPage, ScimUser, ScimUserFields, ScimUserFilter, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ScimAdapter } from "./adapter.js";
|
|
2
|
+
import type { ScimGroup, ScimGroupChange, ScimGroupFields, ScimGroupFilter, ScimUser, ScimUserFilter } from "./types.js";
|
|
3
|
+
type Details = Record<string, unknown>;
|
|
4
|
+
type GroupSummary = {
|
|
5
|
+
id: string;
|
|
6
|
+
displayName: string;
|
|
7
|
+
};
|
|
8
|
+
/** Present when the stored externalId changed, with the value it replaced. Relink on this if you link by externalId. */
|
|
9
|
+
type ExternalIdChange = {
|
|
10
|
+
previousExternalId?: string | null;
|
|
11
|
+
};
|
|
12
|
+
/** What a committed request changed. */
|
|
13
|
+
export type ScimChange<TUser extends ScimUser = ScimUser> = {
|
|
14
|
+
type: "user.created";
|
|
15
|
+
user: TUser;
|
|
16
|
+
} | ({
|
|
17
|
+
type: "user.updated";
|
|
18
|
+
user: TUser;
|
|
19
|
+
} & ExternalIdChange)
|
|
20
|
+
/** An inactive user became active again, so access ended at deactivation can be restored. */
|
|
21
|
+
| ({
|
|
22
|
+
type: "user.reactivated";
|
|
23
|
+
user: TUser;
|
|
24
|
+
} & ExternalIdChange)
|
|
25
|
+
/** `externalIdConflict`: with a fixed externalId, the request sent a different one, which was not stored. */
|
|
26
|
+
| ({
|
|
27
|
+
type: "user.deactivated";
|
|
28
|
+
user: TUser;
|
|
29
|
+
details?: Details;
|
|
30
|
+
externalIdConflict?: true;
|
|
31
|
+
} & ExternalIdChange) | {
|
|
32
|
+
type: "user.deleted";
|
|
33
|
+
user: TUser;
|
|
34
|
+
details?: Details;
|
|
35
|
+
} | {
|
|
36
|
+
type: "group.created";
|
|
37
|
+
group: GroupSummary;
|
|
38
|
+
members: number;
|
|
39
|
+
} | {
|
|
40
|
+
type: "group.replaced";
|
|
41
|
+
group: GroupSummary;
|
|
42
|
+
members: number;
|
|
43
|
+
} | {
|
|
44
|
+
type: "group.patched";
|
|
45
|
+
group: GroupSummary;
|
|
46
|
+
added: number;
|
|
47
|
+
removed: number;
|
|
48
|
+
replaced: boolean;
|
|
49
|
+
} | {
|
|
50
|
+
type: "group.deleted";
|
|
51
|
+
group: GroupSummary;
|
|
52
|
+
};
|
|
53
|
+
export type ScimHooks<TConnection, TUser extends ScimUser, TTransaction> = {
|
|
54
|
+
/**
|
|
55
|
+
* Runs inside the transaction when a user is deactivated or deleted, so a
|
|
56
|
+
* failure rolls the change back. End access and delete the user's data
|
|
57
|
+
* here. What it returns reaches `onChange` and `onCommitted` as `details`.
|
|
58
|
+
*/
|
|
59
|
+
onUserDeprovisioned?(user: TUser, context: {
|
|
60
|
+
connection: TConnection;
|
|
61
|
+
tx: TTransaction;
|
|
62
|
+
reason: "deactivated" | "deleted";
|
|
63
|
+
}): Promise<Details | void>;
|
|
64
|
+
/** Runs inside the transaction for every change, for example to write an audit row. */
|
|
65
|
+
onChange?(change: ScimChange<TUser>, context: {
|
|
66
|
+
connection: TConnection;
|
|
67
|
+
tx: TTransaction;
|
|
68
|
+
}): Promise<void>;
|
|
69
|
+
/** Runs after commit, for example to clear a cache. A failure here cannot undo the change. */
|
|
70
|
+
onCommitted?(change: ScimChange<TUser>, context: {
|
|
71
|
+
connection: TConnection;
|
|
72
|
+
}): Promise<void> | void;
|
|
73
|
+
};
|
|
74
|
+
type Options<TConnection, TUser extends ScimUser, TTransaction> = ScimHooks<TConnection, TUser, TTransaction> & {
|
|
75
|
+
adapter: ScimAdapter<TConnection, TUser, TTransaction>;
|
|
76
|
+
externalId?: "mutable" | "fixed";
|
|
77
|
+
/** Reports a hook that failed after commit. */
|
|
78
|
+
afterCommitFailed(error: unknown): void;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Every change runs in one transaction with its hooks. HTTP requests and direct
|
|
82
|
+
* calls share this path, so both end access the same way.
|
|
83
|
+
*/
|
|
84
|
+
export declare function createOperations<TConnection, TUser extends ScimUser, TTransaction>(options: Options<TConnection, TUser, TTransaction>, connection: TConnection): {
|
|
85
|
+
users: {
|
|
86
|
+
get: (id: string) => Promise<TUser>;
|
|
87
|
+
list: (filter: ScimUserFilter, offset: number, limit: number) => Promise<import("./types.js").ScimPage<TUser>>;
|
|
88
|
+
create: (body: unknown) => Promise<TUser>;
|
|
89
|
+
replace: (id: string, body: unknown) => Promise<TUser>;
|
|
90
|
+
patch: (id: string, body: unknown) => Promise<TUser>;
|
|
91
|
+
/** An offboarding the application started. It takes the same path as a provider's request. */
|
|
92
|
+
deactivate: (id: string) => Promise<TUser>;
|
|
93
|
+
delete: (id: string) => Promise<void>;
|
|
94
|
+
};
|
|
95
|
+
groups: {
|
|
96
|
+
get: (id: string) => Promise<ScimGroup>;
|
|
97
|
+
list: (filter: ScimGroupFilter, offset: number, limit: number) => Promise<import("./types.js").ScimPage<ScimGroup>>;
|
|
98
|
+
create: (fields: ScimGroupFields) => Promise<ScimGroup>;
|
|
99
|
+
replace: (id: string, fields: ScimGroupFields) => Promise<ScimGroup>;
|
|
100
|
+
patch: (id: string, patch: ScimGroupChange[]) => Promise<ScimGroup>;
|
|
101
|
+
delete: (id: string) => Promise<void>;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
export {};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { notFound, ScimError } from "./errors.js";
|
|
3
|
+
import { patchUser, parseUser, replaceUser } from "./protocol.js";
|
|
4
|
+
const fieldsOf = ({ userName, externalId, displayName, name, emails, attributes, active }) => ({
|
|
5
|
+
userName,
|
|
6
|
+
externalId,
|
|
7
|
+
displayName,
|
|
8
|
+
name,
|
|
9
|
+
emails,
|
|
10
|
+
attributes,
|
|
11
|
+
active,
|
|
12
|
+
});
|
|
13
|
+
const summary = (g) => ({ id: g.id, displayName: g.displayName });
|
|
14
|
+
/** Whether a group PATCH would leave the group as it is. */
|
|
15
|
+
function unchanged(group, patch) {
|
|
16
|
+
const before = group.members.map((m) => m.id);
|
|
17
|
+
const members = new Set(before);
|
|
18
|
+
let { displayName, externalId } = group;
|
|
19
|
+
for (const change of patch) {
|
|
20
|
+
if (change.kind === "rename")
|
|
21
|
+
displayName = change.displayName;
|
|
22
|
+
else if (change.kind === "externalId")
|
|
23
|
+
externalId = change.externalId;
|
|
24
|
+
else if (change.kind === "add")
|
|
25
|
+
for (const id of change.ids)
|
|
26
|
+
members.add(id);
|
|
27
|
+
else if (change.kind === "remove")
|
|
28
|
+
for (const id of change.ids)
|
|
29
|
+
members.delete(id);
|
|
30
|
+
else {
|
|
31
|
+
members.clear();
|
|
32
|
+
for (const id of change.ids)
|
|
33
|
+
members.add(id);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return (displayName === group.displayName &&
|
|
37
|
+
externalId === group.externalId &&
|
|
38
|
+
members.size === before.length &&
|
|
39
|
+
before.every((id) => members.has(id)));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every change runs in one transaction with its hooks. HTTP requests and direct
|
|
43
|
+
* calls share this path, so both end access the same way.
|
|
44
|
+
*/
|
|
45
|
+
export function createOperations(options, connection) {
|
|
46
|
+
const { adapter, onUserDeprovisioned, onChange, onCommitted } = options;
|
|
47
|
+
async function commit(fn) {
|
|
48
|
+
const changes = [];
|
|
49
|
+
const result = await adapter.transaction(connection, (store, tx) => fn(store, tx, async (change) => {
|
|
50
|
+
await onChange?.(change, { connection, tx });
|
|
51
|
+
changes.push(change);
|
|
52
|
+
}));
|
|
53
|
+
for (const change of changes) {
|
|
54
|
+
try {
|
|
55
|
+
await onCommitted?.(change, { connection });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
options.afterCommitFailed(error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
const deprovision = async (user, tx, reason) => (await onUserDeprovisioned?.(user, { connection, tx, reason })) ?? undefined;
|
|
64
|
+
const write = (id, next) => commit(async (store, tx, record) => {
|
|
65
|
+
const current = await store.user(id);
|
|
66
|
+
if (!current)
|
|
67
|
+
throw notFound();
|
|
68
|
+
const fields = next(fieldsOf(current));
|
|
69
|
+
const conflict = options.externalId === "fixed" && current.externalId !== null && fields.externalId !== current.externalId;
|
|
70
|
+
const deactivating = current.active && !fields.active;
|
|
71
|
+
const reactivating = !current.active && fields.active;
|
|
72
|
+
// Ending access matters more than a clean request, so a deactivation
|
|
73
|
+
// still goes through and the stored externalId stays as it was.
|
|
74
|
+
if (conflict && !deactivating)
|
|
75
|
+
throw new ScimError(400, "mutability", "externalId cannot change");
|
|
76
|
+
const stored = conflict ? { ...fields, externalId: current.externalId } : fields;
|
|
77
|
+
// An unchanged resource keeps its lastModified (RFC 7644 §3.5.2.1).
|
|
78
|
+
if (isDeepStrictEqual(stored, fieldsOf(current)))
|
|
79
|
+
return current;
|
|
80
|
+
const user = await store.replaceUser(id, stored);
|
|
81
|
+
if (!user)
|
|
82
|
+
throw notFound();
|
|
83
|
+
const relinked = stored.externalId === current.externalId ? {} : { previousExternalId: current.externalId };
|
|
84
|
+
if (deactivating) {
|
|
85
|
+
const details = await deprovision(user, tx, "deactivated");
|
|
86
|
+
await record({
|
|
87
|
+
type: "user.deactivated",
|
|
88
|
+
user,
|
|
89
|
+
...(details ? { details } : {}),
|
|
90
|
+
...(conflict ? { externalIdConflict: true } : {}),
|
|
91
|
+
...relinked,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
await record({ type: reactivating ? "user.reactivated" : "user.updated", user, ...relinked });
|
|
96
|
+
return user;
|
|
97
|
+
});
|
|
98
|
+
const group = async (id) => (await adapter.read(connection).group(id)) ?? Promise.reject(notFound());
|
|
99
|
+
return {
|
|
100
|
+
users: {
|
|
101
|
+
get: async (id) => (await adapter.read(connection).user(id)) ?? Promise.reject(notFound()),
|
|
102
|
+
list: (filter, offset, limit) => adapter.read(connection).users(filter, offset, limit),
|
|
103
|
+
create: (body) => {
|
|
104
|
+
const fields = parseUser(body);
|
|
105
|
+
return commit(async (store, _tx, record) => {
|
|
106
|
+
const user = await store.createUser(fields);
|
|
107
|
+
await record({ type: "user.created", user });
|
|
108
|
+
return user;
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
replace: (id, body) => write(id, (current) => replaceUser(current, body)),
|
|
112
|
+
patch: (id, body) => write(id, (current) => patchUser(current, body)),
|
|
113
|
+
/** An offboarding the application started. It takes the same path as a provider's request. */
|
|
114
|
+
deactivate: (id) => write(id, (current) => ({ ...current, active: false })),
|
|
115
|
+
delete: (id) => commit(async (store, tx, record) => {
|
|
116
|
+
const user = await store.deleteUser(id);
|
|
117
|
+
if (!user)
|
|
118
|
+
throw notFound();
|
|
119
|
+
const details = await deprovision(user, tx, "deleted");
|
|
120
|
+
await record({ type: "user.deleted", user, ...(details ? { details } : {}) });
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
groups: {
|
|
124
|
+
get: group,
|
|
125
|
+
list: (filter, offset, limit) => adapter.read(connection).groups(filter, offset, limit),
|
|
126
|
+
create: async (fields) => {
|
|
127
|
+
const id = await commit(async (store, _tx, record) => {
|
|
128
|
+
const id = await store.createGroup(fields);
|
|
129
|
+
await record({
|
|
130
|
+
type: "group.created",
|
|
131
|
+
group: { id, displayName: fields.displayName },
|
|
132
|
+
members: fields.memberIds.length,
|
|
133
|
+
});
|
|
134
|
+
return id;
|
|
135
|
+
});
|
|
136
|
+
return group(id);
|
|
137
|
+
},
|
|
138
|
+
replace: async (id, fields) => {
|
|
139
|
+
await commit(async (store, _tx, record) => {
|
|
140
|
+
const current = await store.group(id);
|
|
141
|
+
if (!current)
|
|
142
|
+
throw notFound();
|
|
143
|
+
// Okta's wizard apps send a PUT that repeats the group as it is. Like a
|
|
144
|
+
// no-op PATCH, it keeps lastModified and runs no hook.
|
|
145
|
+
const same = unchanged(current, [
|
|
146
|
+
{ kind: "rename", displayName: fields.displayName },
|
|
147
|
+
{ kind: "externalId", externalId: fields.externalId ?? current.externalId },
|
|
148
|
+
{ kind: "replace", ids: fields.memberIds },
|
|
149
|
+
]);
|
|
150
|
+
if (same)
|
|
151
|
+
return;
|
|
152
|
+
await store.renameGroup(id, fields.displayName, fields.externalId ?? current.externalId);
|
|
153
|
+
await store.setMembers(id, fields.memberIds);
|
|
154
|
+
await record({
|
|
155
|
+
type: "group.replaced",
|
|
156
|
+
group: { id, displayName: fields.displayName },
|
|
157
|
+
members: fields.memberIds.length,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
return group(id);
|
|
161
|
+
},
|
|
162
|
+
patch: async (id, patch) => {
|
|
163
|
+
await commit(async (store, _tx, record) => {
|
|
164
|
+
const current = await store.group(id);
|
|
165
|
+
if (!current)
|
|
166
|
+
throw notFound();
|
|
167
|
+
if (unchanged(current, patch))
|
|
168
|
+
return;
|
|
169
|
+
let displayName = current.displayName;
|
|
170
|
+
const counts = { added: 0, removed: 0, replaced: false };
|
|
171
|
+
for (const change of patch) {
|
|
172
|
+
if (change.kind === "rename") {
|
|
173
|
+
displayName = change.displayName;
|
|
174
|
+
await store.renameGroup(id, displayName);
|
|
175
|
+
}
|
|
176
|
+
else if (change.kind === "externalId") {
|
|
177
|
+
await store.renameGroup(id, displayName, change.externalId);
|
|
178
|
+
}
|
|
179
|
+
else if (change.kind === "add") {
|
|
180
|
+
counts.added += change.ids.length;
|
|
181
|
+
await store.addMembers(id, change.ids);
|
|
182
|
+
}
|
|
183
|
+
else if (change.kind === "remove") {
|
|
184
|
+
counts.removed += change.ids.length;
|
|
185
|
+
await store.removeMembers(id, change.ids);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
counts.replaced = true;
|
|
189
|
+
await store.setMembers(id, change.ids);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
await record({ type: "group.patched", group: { id, displayName }, ...counts });
|
|
193
|
+
});
|
|
194
|
+
return group(id);
|
|
195
|
+
},
|
|
196
|
+
delete: (id) => commit(async (store, _tx, record) => {
|
|
197
|
+
const current = await store.group(id);
|
|
198
|
+
if (!current || !(await store.deleteGroup(id)))
|
|
199
|
+
throw notFound();
|
|
200
|
+
await record({ type: "group.deleted", group: summary(current) });
|
|
201
|
+
}),
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|