@maroonedsoftware/scim 0.2.24 → 0.3.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/AGENTS.md +23 -5
- package/README.md +29 -14
- package/dist/errors/scim.error.d.ts +10 -1
- package/dist/errors/scim.error.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +170 -27
- package/dist/index.js.map +1 -1
- package/dist/projection/attribute.projection.d.ts +38 -0
- package/dist/projection/attribute.projection.d.ts.map +1 -0
- package/dist/projection/index.d.ts +2 -0
- package/dist/projection/index.d.ts.map +1 -0
- package/dist/router/scim.router.d.ts +9 -0
- package/dist/router/scim.router.d.ts.map +1 -1
- package/dist/services/scim.group.service.d.ts +2 -1
- package/dist/services/scim.group.service.d.ts.map +1 -1
- package/dist/services/scim.user.service.d.ts +3 -0
- package/dist/services/scim.user.service.d.ts.map +1 -1
- package/package.json +5 -5
package/AGENTS.md
CHANGED
|
@@ -83,7 +83,7 @@ contract is inseparable from HTTP.
|
|
|
83
83
|
| Export | Kind | Shape | Notes |
|
|
84
84
|
| ----------------- | ---------- | ----------------------------------------------------------- | -------------------------------------------- |
|
|
85
85
|
| `ScimError` | class | `extends HttpError` | So `errorMiddleware` already understands it. |
|
|
86
|
-
| `scimError` | function | `(status, scimType?,
|
|
86
|
+
| `scimError` | function | `(status, scimType?, statusText?) => ScimError` | The factory to use. The third argument is the HTTP status text; put the operator-facing reason on `.withDetails({ message })`, which `toScimBody` renders as `detail`. |
|
|
87
87
|
| `IsScimError` | type guard | — | — |
|
|
88
88
|
| `ScimErrorType` | type | `'invalidFilter'`, `'insufficientScope'`, `'mutability'`, … | RFC 7644 §3.12 `scimType` values. |
|
|
89
89
|
| `ScimErrorSchema` | constant | The error envelope URN | — |
|
|
@@ -95,6 +95,8 @@ contract is inseparable from HTTP.
|
|
|
95
95
|
| `ScimUserRepository` | abstract class | Implement over your datastore. |
|
|
96
96
|
| `ScimGroupRepository` | abstract class | Implement over your datastore. |
|
|
97
97
|
| `ScimListQuery` | interface | `{ filter?: ScimFilterNode; startIndex; count; sortBy?; sortOrder?; attributes?; excludedAttributes? }` — **the parsed AST**, never the raw string. |
|
|
98
|
+
| `projectScimResource` | function | `(resource, schemas: ScimSchema[], projection?) => resource` — applies `attributes` / `excludedAttributes` and strips `returned: 'never'`. |
|
|
99
|
+
| `ScimProjection` | interface | `{ attributes?: string[]; excludedAttributes?: string[] }` |
|
|
98
100
|
| `ScimListResult<TResource>` | interface | `{ resources; totalResults }` — `totalResults` is the **filter-matching total**, not the page size. |
|
|
99
101
|
| `ScimSortOrder` | type | `'ascending' \| 'descending'` |
|
|
100
102
|
| `ScimUserService` / `ScimGroupService` / `ScimServiceProviderService` | class | Sit between the router and the repositories. |
|
|
@@ -108,7 +110,7 @@ contract is inseparable from HTTP.
|
|
|
108
110
|
| `SCIM_MEDIA_TYPE` | constant | `'application/scim+json'` | — |
|
|
109
111
|
| `requireScimScope` | function | `(scope: string) => ServerKitRouterMiddleware` | Reads `ctx.authenticationSession.claims.scimScopes`. `*` grants everything. |
|
|
110
112
|
| `createScimRouter` | function | `(options: CreateScimRouterOptions) => Router<unknown, ServerKitContext>` | Mounts every endpoint below. |
|
|
111
|
-
| `CreateScimRouterOptions` | interface | `{ userService, groupService, serviceProviderService, routeGuards?, maxResults? }` | `maxResults` defaults to the service-provider config's `filter.maxResults`, then 200. |
|
|
113
|
+
| `CreateScimRouterOptions` | interface | `{ userService, groupService, serviceProviderService, routeGuards?, maxResults?, baseUrl? }` | `maxResults` defaults to the service-provider config's `filter.maxResults`, then 200. Set `baseUrl` whenever the router is mounted under a prefix. |
|
|
112
114
|
|
|
113
115
|
Endpoints mounted: `GET|POST /Users`, `GET|PUT|PATCH|DELETE /Users/:id`, `POST /Users/.search`, the
|
|
114
116
|
same six plus `.search` for `/Groups`, and `GET /Schemas`, `/Schemas/:id`, `/ResourceTypes`,
|
|
@@ -145,6 +147,7 @@ const router = createScimRouter({
|
|
|
145
147
|
groupService: new ScimGroupService(groupRepository, logger),
|
|
146
148
|
serviceProviderService: new ScimServiceProviderService(config),
|
|
147
149
|
routeGuards: [requireScimScope('scim:write')],
|
|
150
|
+
baseUrl: 'https://api.example.com/scim/v2',
|
|
148
151
|
});
|
|
149
152
|
|
|
150
153
|
// SCIM mountpoint — note scimErrorMiddleware, not errorMiddleware
|
|
@@ -166,12 +169,18 @@ app.use(router.routes()).use(router.allowedMethods());
|
|
|
166
169
|
Provisioning clients paginate on it, and a wrong value makes Okta or Entra loop or truncate.
|
|
167
170
|
- Populate `claims.scimScopes` (a string array) when minting the bearer session, or every request
|
|
168
171
|
gets a 403.
|
|
172
|
+
- Honour `count: 0` by returning an empty `Resources` array with the real `totalResults`, per RFC
|
|
173
|
+
7644 §3.4.2.4. The router already parses it that way from both the query string and a `.search`
|
|
174
|
+
body; a repository that treats `0` as "unset" breaks the count probe Okta and Entra make on
|
|
175
|
+
connection setup.
|
|
169
176
|
- Honour `startIndex` as **1-based**, per RFC 7644 §3.4.2.4. Off-by-one here silently skips or
|
|
170
177
|
repeats the first record.
|
|
171
178
|
- Apply PATCH with `applyScimPatch` rather than hand-rolling op semantics — value-path filters
|
|
172
179
|
(`emails[type eq "work"].value`) are easy to get subtly wrong.
|
|
173
|
-
- Throw `scimError(status, scimType,
|
|
174
|
-
branch on
|
|
180
|
+
- Throw `scimError(status, scimType, statusText).withDetails({ message })` with the right RFC 7644
|
|
181
|
+
§3.12 `scimType`. Clients branch on `scimType`, and `toScimBody` renders `details.message` as the
|
|
182
|
+
envelope's `detail` (falling back to the status text), which is what an operator reads in Okta or
|
|
183
|
+
Entra when provisioning fails.
|
|
175
184
|
- Serve `/Schemas`, `/ResourceTypes`, and `/ServiceProviderConfig`. Provisioning clients call them
|
|
176
185
|
during setup and fail the connection without them.
|
|
177
186
|
|
|
@@ -201,6 +210,15 @@ app.use(router.routes()).use(router.allowedMethods());
|
|
|
201
210
|
`scimContentTypeMiddleware` is what actually enforces the media type if you want strictness.
|
|
202
211
|
- **`schemas` on a resource is a required array of URNs**, not decoration. Omitting the enterprise
|
|
203
212
|
URN on a user with enterprise attributes makes clients ignore them.
|
|
213
|
+
- **`meta.location` needs `baseUrl` to be right.** The services assign a root-relative
|
|
214
|
+
`/Users/{id}`, because they cannot know where the router is mounted. `createScimRouter` rewrites
|
|
215
|
+
it (and the `Location` header) against `options.baseUrl`; without that option a deployment under
|
|
216
|
+
`/scim/v2` emits URIs that provisioning clients then follow to a 404.
|
|
217
|
+
- **Responses are projected by the router, not the repository.** `createScimRouter` runs every user
|
|
218
|
+
and group response through `projectScimResource`, which applies `attributes` /
|
|
219
|
+
`excludedAttributes` and strips attributes the schema declares `returned: 'never'`. A repository
|
|
220
|
+
that stores and returns `password` verbatim is therefore safe on the wire — but do not rely on
|
|
221
|
+
that if you serve resources through your own routes, and prefer not to store it at all.
|
|
204
222
|
|
|
205
223
|
## Working inside this package
|
|
206
224
|
|
|
@@ -227,7 +245,7 @@ Invariants a change must not break:
|
|
|
227
245
|
- Repositories receive the **parsed AST**, never the raw filter string. That is what keeps backend
|
|
228
246
|
translation the only concern a consumer has.
|
|
229
247
|
- `totalResults` is the filter-matching total. Provisioning clients' pagination depends on it.
|
|
230
|
-
- `startIndex` is 1-based throughout.
|
|
248
|
+
- `startIndex` is 1-based throughout, and `count: 0` means "none, but tell me the total".
|
|
231
249
|
- The SCIM error envelope (`ScimErrorSchema`, `scimType`, `detail`) is a wire contract with Okta,
|
|
232
250
|
Entra ID, and every other provisioning client. It is not ServerKit's error shape.
|
|
233
251
|
- The filter grammar and the PATCH value-path semantics follow RFC 7644. Divergence shows up as an
|
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ This package provides the protocol layer — schemas, filter parser, PATCH appli
|
|
|
9
9
|
- **Resource schemas** — `User`, `Group`, and the `EnterpriseUser` extension (RFC 7643).
|
|
10
10
|
- **Filter parser** — full SCIM filter grammar (RFC 7644 §3.4.2.2) returning a typed AST.
|
|
11
11
|
- **PATCH applier** — `add` / `replace` / `remove` ops with the path mini-language (RFC 7644 §3.5.2).
|
|
12
|
-
- **
|
|
12
|
+
- **Attribute projection** — `projectScimResource(resource, schemas, projection)` applies `attributes` / `excludedAttributes` (RFC 7644 §3.9) and always strips `returned: 'never'` attributes such as `password`. The router applies it to every user and group response.
|
|
13
|
+
- **Error envelope** — `scimError(status, scimType?, statusText?)` builder producing the SCIM error JSON; the operator-facing reason goes on `.withDetails({ message })` and is rendered as the envelope's `detail`.
|
|
13
14
|
- **Abstract repositories** — `ScimUserRepository`, `ScimGroupRepository`. The consumer implements these against their datastore.
|
|
14
15
|
- **Services** — `ScimUserService`, `ScimGroupService`, `ScimServiceProviderService`.
|
|
15
16
|
- **Koa middleware** — `scimErrorMiddleware()`, `scimContentTypeMiddleware()`, `requireScimScope(scope)`.
|
|
@@ -20,7 +21,16 @@ This package provides the protocol layer — schemas, filter parser, PATCH appli
|
|
|
20
21
|
```ts
|
|
21
22
|
import Koa from 'koa';
|
|
22
23
|
import { ServerKitContext, serverKitContextMiddleware, authenticationMiddleware } from '@maroonedsoftware/koa';
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
createScimRouter,
|
|
26
|
+
requireScimScope,
|
|
27
|
+
ScimGroupRepository,
|
|
28
|
+
ScimGroupService,
|
|
29
|
+
ScimServiceProviderService,
|
|
30
|
+
ScimUserRepository,
|
|
31
|
+
ScimUserService,
|
|
32
|
+
scimErrorMiddleware,
|
|
33
|
+
} from '@maroonedsoftware/scim';
|
|
24
34
|
|
|
25
35
|
class MyScimUserRepository extends ScimUserRepository {
|
|
26
36
|
// implement against your datastore
|
|
@@ -34,23 +44,28 @@ const app = new Koa();
|
|
|
34
44
|
app.use(serverKitContextMiddleware(container));
|
|
35
45
|
app.use(authenticationMiddleware());
|
|
36
46
|
|
|
47
|
+
// The router takes services, not repositories: the service owns id and meta
|
|
48
|
+
// assignment, uniqueness, and PATCH application.
|
|
49
|
+
const serviceProviderService = new ScimServiceProviderService({
|
|
50
|
+
documentationUri: 'https://example.com/scim/docs',
|
|
51
|
+
patch: { supported: true },
|
|
52
|
+
filter: { supported: true, maxResults: 200 },
|
|
53
|
+
sort: { supported: true },
|
|
54
|
+
});
|
|
55
|
+
|
|
37
56
|
const scimRouter = createScimRouter({
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
filter: { supported: true, maxResults: 200 },
|
|
46
|
-
changePassword: { supported: false },
|
|
47
|
-
sort: { supported: true },
|
|
48
|
-
etag: { supported: false },
|
|
49
|
-
},
|
|
57
|
+
userService: new ScimUserService(new MyScimUserRepository(), logger),
|
|
58
|
+
groupService: new ScimGroupService(new MyScimGroupRepository(), logger),
|
|
59
|
+
serviceProviderService,
|
|
60
|
+
routeGuards: [requireScimScope('scim')],
|
|
61
|
+
// Where this router is reachable. Set it whenever you mount under a prefix,
|
|
62
|
+
// or `meta.location` and the `Location` header will be wrong.
|
|
63
|
+
baseUrl: 'https://api.example.com/scim/v2',
|
|
50
64
|
});
|
|
51
65
|
|
|
52
66
|
app.use(scimErrorMiddleware());
|
|
53
67
|
app.use(scimRouter.routes());
|
|
68
|
+
app.use(scimRouter.allowedMethods());
|
|
54
69
|
```
|
|
55
70
|
|
|
56
71
|
## Authentication
|
|
@@ -18,7 +18,16 @@ export declare class ScimError extends HttpError {
|
|
|
18
18
|
/** SCIM-specific error subtype, see RFC 7644 §3.12. */
|
|
19
19
|
readonly scimType?: ScimErrorType;
|
|
20
20
|
constructor(statusCode: HttpStatusCodes, scimType?: ScimErrorType, message?: HttpStatusMessage<HttpStatusCodes>);
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Build the SCIM error JSON body for this error.
|
|
23
|
+
*
|
|
24
|
+
* `detail` prefers a string `message` from {@link ServerkitError.details}, falling
|
|
25
|
+
* back to the error's own message. The package raises errors as
|
|
26
|
+
* `scimError(409, 'uniqueness', 'Conflict').withDetails({ message: '...' })`, where
|
|
27
|
+
* the constructor's third argument is only the HTTP status text; without this
|
|
28
|
+
* preference every provisioning client would see `"detail": "Conflict"` and never
|
|
29
|
+
* the reason.
|
|
30
|
+
*/
|
|
22
31
|
toScimBody(): {
|
|
23
32
|
schemas: [typeof ScimErrorSchema];
|
|
24
33
|
status: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scim.error.d.ts","sourceRoot":"","sources":["../../src/errors/scim.error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,6CAA6C;AAC7C,eAAO,MAAM,eAAe,gDAAgD,CAAC;AAE7E;;;GAGG;AACH,MAAM,MAAM,aAAa,GACrB,eAAe,GACf,SAAS,GACT,YAAY,GACZ,YAAY,GACZ,eAAe,GACf,aAAa,GACb,UAAU,GACV,cAAc,GACd,aAAa,GACb,WAAW,GACX,mBAAmB,GACnB,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;;;;;GAOG;AACH,qBAAa,SAAU,SAAQ,SAAS;IACtC,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;gBAEtB,UAAU,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,iBAAiB,CAAC,eAAe,CAAC;IAQ/G
|
|
1
|
+
{"version":3,"file":"scim.error.d.ts","sourceRoot":"","sources":["../../src/errors/scim.error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,6CAA6C;AAC7C,eAAO,MAAM,eAAe,gDAAgD,CAAC;AAE7E;;;GAGG;AACH,MAAM,MAAM,aAAa,GACrB,eAAe,GACf,SAAS,GACT,YAAY,GACZ,YAAY,GACZ,eAAe,GACf,aAAa,GACb,UAAU,GACV,cAAc,GACd,aAAa,GACb,WAAW,GACX,mBAAmB,GACnB,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;AAElB;;;;;;;GAOG;AACH,qBAAa,SAAU,SAAQ,SAAS;IACtC,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;gBAEtB,UAAU,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,iBAAiB,CAAC,eAAe,CAAC;IAQ/G;;;;;;;;;OASG;IACH,UAAU,IAAI;QAAE,OAAO,EAAE,CAAC,OAAO,eAAe,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;CAS/G;AAED,wCAAwC;AACxC,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,SAAuC,CAAC;AAE9F;;;;;;;;;GASG;AACH,eAAO,MAAM,SAAS,GAAI,MAAM,SAAS,eAAe,EACtD,YAAY,MAAM,EAClB,WAAW,aAAa,EACxB,UAAU,iBAAiB,CAAC,MAAM,CAAC,KAClC,SAAyD,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './types/patch.op.js';
|
|
|
6
6
|
export * from './schemas/index.js';
|
|
7
7
|
export * from './filter/index.js';
|
|
8
8
|
export * from './patch/index.js';
|
|
9
|
+
export * from './projection/index.js';
|
|
9
10
|
export * from './errors/scim.error.js';
|
|
10
11
|
export * from './repositories/index.js';
|
|
11
12
|
export * from './services/index.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,mBAAmB,CAAC;AAGlC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,wBAAwB,CAAC;AAGvC,cAAc,yBAAyB,CAAC;AAGxC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,mBAAmB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,mBAAmB,CAAC;AAGlC,cAAc,kBAAkB,CAAC;AAGjC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,wBAAwB,CAAC;AAGvC,cAAc,yBAAyB,CAAC;AAGxC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,uBAAuB,CAAC;AAGtC,cAAc,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -662,8 +662,18 @@ var ScimError = class extends HttpError {
|
|
|
662
662
|
super(statusCode, message);
|
|
663
663
|
this.scimType = scimType;
|
|
664
664
|
}
|
|
665
|
-
/**
|
|
665
|
+
/**
|
|
666
|
+
* Build the SCIM error JSON body for this error.
|
|
667
|
+
*
|
|
668
|
+
* `detail` prefers a string `message` from {@link ServerkitError.details}, falling
|
|
669
|
+
* back to the error's own message. The package raises errors as
|
|
670
|
+
* `scimError(409, 'uniqueness', 'Conflict').withDetails({ message: '...' })`, where
|
|
671
|
+
* the constructor's third argument is only the HTTP status text; without this
|
|
672
|
+
* preference every provisioning client would see `"detail": "Conflict"` and never
|
|
673
|
+
* the reason.
|
|
674
|
+
*/
|
|
666
675
|
toScimBody() {
|
|
676
|
+
const detailFromDetails = this.details?.message;
|
|
667
677
|
return {
|
|
668
678
|
schemas: [
|
|
669
679
|
ScimErrorSchema
|
|
@@ -672,7 +682,7 @@ var ScimError = class extends HttpError {
|
|
|
672
682
|
...this.scimType ? {
|
|
673
683
|
scimType: this.scimType
|
|
674
684
|
} : {},
|
|
675
|
-
detail: this.message
|
|
685
|
+
detail: typeof detailFromDetails === "string" ? detailFromDetails : this.message
|
|
676
686
|
};
|
|
677
687
|
}
|
|
678
688
|
};
|
|
@@ -1320,18 +1330,26 @@ var mergeObject = /* @__PURE__ */ __name((target, source, kind) => {
|
|
|
1320
1330
|
for (const [key, val] of Object.entries(source)) {
|
|
1321
1331
|
const existing = out[key];
|
|
1322
1332
|
if (Array.isArray(existing) && Array.isArray(val)) {
|
|
1323
|
-
out
|
|
1333
|
+
defineOwn(out, key, [
|
|
1324
1334
|
...existing,
|
|
1325
1335
|
...val
|
|
1326
|
-
];
|
|
1336
|
+
]);
|
|
1327
1337
|
} else if (isPlainObject(existing) && isPlainObject(val)) {
|
|
1328
|
-
out
|
|
1338
|
+
defineOwn(out, key, mergeObject(existing, val, kind));
|
|
1329
1339
|
} else {
|
|
1330
|
-
out
|
|
1340
|
+
defineOwn(out, key, val);
|
|
1331
1341
|
}
|
|
1332
1342
|
}
|
|
1333
1343
|
return out;
|
|
1334
1344
|
}, "mergeObject");
|
|
1345
|
+
var defineOwn = /* @__PURE__ */ __name((target, key, value) => {
|
|
1346
|
+
Object.defineProperty(target, key, {
|
|
1347
|
+
value,
|
|
1348
|
+
writable: true,
|
|
1349
|
+
enumerable: true,
|
|
1350
|
+
configurable: true
|
|
1351
|
+
});
|
|
1352
|
+
}, "defineOwn");
|
|
1335
1353
|
var evaluateFilter = /* @__PURE__ */ __name((item, filter) => {
|
|
1336
1354
|
switch (filter.kind) {
|
|
1337
1355
|
case "comparison":
|
|
@@ -1391,6 +1409,98 @@ var isPlainObject = /* @__PURE__ */ __name((value) => {
|
|
|
1391
1409
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1392
1410
|
}, "isPlainObject");
|
|
1393
1411
|
|
|
1412
|
+
// src/projection/attribute.projection.ts
|
|
1413
|
+
var ALWAYS_RETURNED = /* @__PURE__ */ new Set([
|
|
1414
|
+
"id",
|
|
1415
|
+
"schemas",
|
|
1416
|
+
"meta"
|
|
1417
|
+
]);
|
|
1418
|
+
var isPlainObject2 = /* @__PURE__ */ __name((value) => typeof value === "object" && value !== null && !Array.isArray(value), "isPlainObject");
|
|
1419
|
+
var splitPath = /* @__PURE__ */ __name((path) => {
|
|
1420
|
+
const urnBoundary = path.lastIndexOf(":");
|
|
1421
|
+
if (urnBoundary === -1) return path.split(".");
|
|
1422
|
+
return [
|
|
1423
|
+
path.slice(0, urnBoundary),
|
|
1424
|
+
...path.slice(urnBoundary + 1).split(".")
|
|
1425
|
+
];
|
|
1426
|
+
}, "splitPath");
|
|
1427
|
+
var PathSet = class PathSet2 {
|
|
1428
|
+
static {
|
|
1429
|
+
__name(this, "PathSet");
|
|
1430
|
+
}
|
|
1431
|
+
paths;
|
|
1432
|
+
constructor(paths) {
|
|
1433
|
+
this.paths = paths.map((p) => splitPath(p).map((s) => s.toLowerCase()));
|
|
1434
|
+
}
|
|
1435
|
+
get empty() {
|
|
1436
|
+
return this.paths.length === 0;
|
|
1437
|
+
}
|
|
1438
|
+
/** Whether `segments` is at or above a requested path — `name` matches a request for `name.givenName`. */
|
|
1439
|
+
coversOrLeadsTo(segments) {
|
|
1440
|
+
const target = segments.map((s) => s.toLowerCase());
|
|
1441
|
+
return this.paths.some((path) => {
|
|
1442
|
+
const shared = Math.min(path.length, target.length);
|
|
1443
|
+
for (let i = 0; i < shared; i++) {
|
|
1444
|
+
if (path[i] !== target[i]) return false;
|
|
1445
|
+
}
|
|
1446
|
+
return true;
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
/** Whether `segments` is at or below a requested path — `name.givenName` matches a request for `name`. */
|
|
1450
|
+
isAtOrBelow(segments) {
|
|
1451
|
+
const target = segments.map((s) => s.toLowerCase());
|
|
1452
|
+
return this.paths.some((path) => {
|
|
1453
|
+
if (path.length > target.length) return false;
|
|
1454
|
+
return path.every((segment, i) => segment === target[i]);
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
var findDefinition = /* @__PURE__ */ __name((definitions, name) => definitions.find((d) => d.name.toLowerCase() === name.toLowerCase()), "findDefinition");
|
|
1459
|
+
var projectScimResource = /* @__PURE__ */ __name((resource, schemas, projection = {}) => {
|
|
1460
|
+
const requested = new PathSet(projection.attributes ?? []);
|
|
1461
|
+
const excluded = new PathSet(projection.excludedAttributes ?? []);
|
|
1462
|
+
const mode = !requested.empty ? "include" : !excluded.empty ? "exclude" : "default";
|
|
1463
|
+
const [core, ...extensions] = schemas;
|
|
1464
|
+
const extensionsById = new Map(extensions.map((schema) => [
|
|
1465
|
+
schema.id.toLowerCase(),
|
|
1466
|
+
schema
|
|
1467
|
+
]));
|
|
1468
|
+
return projectObject(resource, core?.attributes ?? [], extensionsById, [], mode, requested, excluded);
|
|
1469
|
+
}, "projectScimResource");
|
|
1470
|
+
var projectObject = /* @__PURE__ */ __name((source, definitions, extensionsById, prefix, mode, requested, excluded) => {
|
|
1471
|
+
const out = {};
|
|
1472
|
+
for (const [key, value] of Object.entries(source)) {
|
|
1473
|
+
const segments = [
|
|
1474
|
+
...prefix,
|
|
1475
|
+
key
|
|
1476
|
+
];
|
|
1477
|
+
const extension = prefix.length === 0 ? extensionsById.get(key.toLowerCase()) : void 0;
|
|
1478
|
+
const definition = findDefinition(definitions, key);
|
|
1479
|
+
const alwaysReturned = definition?.returned === "always" || prefix.length === 0 && ALWAYS_RETURNED.has(key.toLowerCase());
|
|
1480
|
+
if (definition?.returned === "never") continue;
|
|
1481
|
+
if (!alwaysReturned) {
|
|
1482
|
+
if (mode === "include" && !requested.coversOrLeadsTo(segments)) continue;
|
|
1483
|
+
if (mode === "exclude" && excluded.isAtOrBelow(segments)) continue;
|
|
1484
|
+
if (mode !== "include" && definition?.returned === "request") continue;
|
|
1485
|
+
}
|
|
1486
|
+
if (extension && isPlainObject2(value)) {
|
|
1487
|
+
out[key] = projectObject(value, extension.attributes, extensionsById, segments, mode, requested, excluded);
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
const subDefinitions = definition?.subAttributes;
|
|
1491
|
+
if (subDefinitions && Array.isArray(value)) {
|
|
1492
|
+
out[key] = value.map((entry) => isPlainObject2(entry) ? projectObject(entry, subDefinitions, extensionsById, segments, mode, requested, excluded) : entry);
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
if (subDefinitions && isPlainObject2(value)) {
|
|
1496
|
+
out[key] = projectObject(value, subDefinitions, extensionsById, segments, mode, requested, excluded);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
out[key] = value;
|
|
1500
|
+
}
|
|
1501
|
+
return out;
|
|
1502
|
+
}, "projectObject");
|
|
1503
|
+
|
|
1394
1504
|
// src/repositories/scim.user.repository.ts
|
|
1395
1505
|
var ScimUserRepository = class {
|
|
1396
1506
|
static {
|
|
@@ -1455,6 +1565,9 @@ var ScimUserService = class {
|
|
|
1455
1565
|
* and `meta.lastModified`, and ensures the resource's `schemas` includes the
|
|
1456
1566
|
* core User URN (and the EnterpriseUser URN when the extension is present).
|
|
1457
1567
|
*
|
|
1568
|
+
* An `id` on the payload is ignored: RFC 7643 §3.1 makes it readOnly and
|
|
1569
|
+
* server-assigned.
|
|
1570
|
+
*
|
|
1458
1571
|
* @throws {ScimError} 400 `invalidValue` when `userName` is missing.
|
|
1459
1572
|
* @throws {ScimError} 409 `uniqueness` when `userName` already exists.
|
|
1460
1573
|
*/
|
|
@@ -1471,7 +1584,7 @@ var ScimUserService = class {
|
|
|
1471
1584
|
});
|
|
1472
1585
|
}
|
|
1473
1586
|
const now = DateTime.utc().toISO();
|
|
1474
|
-
const id =
|
|
1587
|
+
const id = randomUUID();
|
|
1475
1588
|
const user = {
|
|
1476
1589
|
...payload,
|
|
1477
1590
|
id,
|
|
@@ -1645,7 +1758,8 @@ var ScimGroupService = class {
|
|
|
1645
1758
|
}
|
|
1646
1759
|
/**
|
|
1647
1760
|
* Create a new group. Assigns a server-generated `id` and fills `meta`
|
|
1648
|
-
* timestamps.
|
|
1761
|
+
* timestamps. An `id` on the payload is ignored: RFC 7643 §3.1 makes it
|
|
1762
|
+
* readOnly and server-assigned.
|
|
1649
1763
|
*
|
|
1650
1764
|
* @throws {ScimError} 400 `invalidValue` when `displayName` is missing.
|
|
1651
1765
|
* @throws {ScimError} 409 `uniqueness` when `displayName` already exists.
|
|
@@ -1663,7 +1777,7 @@ var ScimGroupService = class {
|
|
|
1663
1777
|
});
|
|
1664
1778
|
}
|
|
1665
1779
|
const now = DateTime2.utc().toISO();
|
|
1666
|
-
const id =
|
|
1780
|
+
const id = randomUUID2();
|
|
1667
1781
|
const group = {
|
|
1668
1782
|
...payload,
|
|
1669
1783
|
id,
|
|
@@ -1947,6 +2061,14 @@ var createScimRouter = /* @__PURE__ */ __name((options) => {
|
|
|
1947
2061
|
"application/json"
|
|
1948
2062
|
]);
|
|
1949
2063
|
const maxResults = options.maxResults ?? options.serviceProviderService.getServiceProviderConfig().filter.maxResults ?? 200;
|
|
2064
|
+
const baseUrl = options.baseUrl?.replace(/\/+$/, "");
|
|
2065
|
+
const withLocation = /* @__PURE__ */ __name((resource, endpoint) => baseUrl ? {
|
|
2066
|
+
...resource,
|
|
2067
|
+
meta: {
|
|
2068
|
+
...resource.meta,
|
|
2069
|
+
location: `${baseUrl}/${endpoint}/${resource.id}`
|
|
2070
|
+
}
|
|
2071
|
+
} : resource, "withLocation");
|
|
1950
2072
|
router.get("/ServiceProviderConfig", ...guards, async (ctx) => {
|
|
1951
2073
|
ctx.body = options.serviceProviderService.getServiceProviderConfig();
|
|
1952
2074
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
@@ -1970,37 +2092,38 @@ var createScimRouter = /* @__PURE__ */ __name((options) => {
|
|
|
1970
2092
|
router.get("/Users", ...guards, async (ctx) => {
|
|
1971
2093
|
const query = parseListQueryFromUrl(ctx.query, maxResults);
|
|
1972
2094
|
const result = await options.userService.list(query);
|
|
1973
|
-
ctx.body = listEnvelope(result.resources, query, result.totalResults);
|
|
2095
|
+
ctx.body = listEnvelope(result.resources.map((user) => projectUser(withLocation(user, "Users"), query)), query, result.totalResults);
|
|
1974
2096
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
1975
2097
|
});
|
|
1976
2098
|
router.post("/Users/.search", ...guards, json, async (ctx) => {
|
|
1977
2099
|
const requestBody = takeRequestBody(ctx);
|
|
1978
2100
|
const query = parseListQueryFromBody(requestBody, maxResults);
|
|
1979
2101
|
const result = await options.userService.list(query);
|
|
1980
|
-
ctx.body = listEnvelope(result.resources, query, result.totalResults);
|
|
2102
|
+
ctx.body = listEnvelope(result.resources.map((user) => projectUser(withLocation(user, "Users"), query)), query, result.totalResults);
|
|
1981
2103
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
1982
2104
|
});
|
|
1983
2105
|
router.post("/Users", ...guards, json, async (ctx) => {
|
|
1984
2106
|
const payload = takeRequestBody(ctx);
|
|
1985
2107
|
const created = await options.userService.create(payload);
|
|
1986
2108
|
ctx.status = 201;
|
|
1987
|
-
|
|
2109
|
+
const located = withLocation(created, "Users");
|
|
2110
|
+
ctx.body = projectUser(located, parseProjectionFromUrl(ctx.query));
|
|
1988
2111
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
1989
|
-
if (
|
|
2112
|
+
if (located.meta.location) ctx.set("Location", located.meta.location);
|
|
1990
2113
|
});
|
|
1991
2114
|
router.get("/Users/:id", ...guards, async (ctx) => {
|
|
1992
|
-
ctx.body = await options.userService.get(ctx.params.id);
|
|
2115
|
+
ctx.body = projectUser(withLocation(await options.userService.get(ctx.params.id), "Users"), parseProjectionFromUrl(ctx.query));
|
|
1993
2116
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
1994
2117
|
});
|
|
1995
2118
|
router.put("/Users/:id", ...guards, json, async (ctx) => {
|
|
1996
2119
|
const payload = takeRequestBody(ctx);
|
|
1997
|
-
ctx.body = await options.userService.replace(ctx.params.id, payload);
|
|
2120
|
+
ctx.body = projectUser(withLocation(await options.userService.replace(ctx.params.id, payload), "Users"), parseProjectionFromUrl(ctx.query));
|
|
1998
2121
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
1999
2122
|
});
|
|
2000
2123
|
router.patch("/Users/:id", ...guards, json, async (ctx) => {
|
|
2001
2124
|
const requestBody = takeRequestBody(ctx);
|
|
2002
2125
|
const ops = validatePatchRequest(requestBody);
|
|
2003
|
-
ctx.body = await options.userService.patch(ctx.params.id, ops);
|
|
2126
|
+
ctx.body = projectUser(withLocation(await options.userService.patch(ctx.params.id, ops), "Users"), parseProjectionFromUrl(ctx.query));
|
|
2004
2127
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
2005
2128
|
});
|
|
2006
2129
|
router.delete("/Users/:id", ...guards, async (ctx) => {
|
|
@@ -2010,37 +2133,38 @@ var createScimRouter = /* @__PURE__ */ __name((options) => {
|
|
|
2010
2133
|
router.get("/Groups", ...guards, async (ctx) => {
|
|
2011
2134
|
const query = parseListQueryFromUrl(ctx.query, maxResults);
|
|
2012
2135
|
const result = await options.groupService.list(query);
|
|
2013
|
-
ctx.body = listEnvelope(result.resources, query, result.totalResults);
|
|
2136
|
+
ctx.body = listEnvelope(result.resources.map((group) => projectGroup(withLocation(group, "Groups"), query)), query, result.totalResults);
|
|
2014
2137
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
2015
2138
|
});
|
|
2016
2139
|
router.post("/Groups/.search", ...guards, json, async (ctx) => {
|
|
2017
2140
|
const requestBody = takeRequestBody(ctx);
|
|
2018
2141
|
const query = parseListQueryFromBody(requestBody, maxResults);
|
|
2019
2142
|
const result = await options.groupService.list(query);
|
|
2020
|
-
ctx.body = listEnvelope(result.resources, query, result.totalResults);
|
|
2143
|
+
ctx.body = listEnvelope(result.resources.map((group) => projectGroup(withLocation(group, "Groups"), query)), query, result.totalResults);
|
|
2021
2144
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
2022
2145
|
});
|
|
2023
2146
|
router.post("/Groups", ...guards, json, async (ctx) => {
|
|
2024
2147
|
const payload = takeRequestBody(ctx);
|
|
2025
2148
|
const created = await options.groupService.create(payload);
|
|
2026
2149
|
ctx.status = 201;
|
|
2027
|
-
|
|
2150
|
+
const located = withLocation(created, "Groups");
|
|
2151
|
+
ctx.body = projectGroup(located, parseProjectionFromUrl(ctx.query));
|
|
2028
2152
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
2029
|
-
if (
|
|
2153
|
+
if (located.meta.location) ctx.set("Location", located.meta.location);
|
|
2030
2154
|
});
|
|
2031
2155
|
router.get("/Groups/:id", ...guards, async (ctx) => {
|
|
2032
|
-
ctx.body = await options.groupService.get(ctx.params.id);
|
|
2156
|
+
ctx.body = projectGroup(withLocation(await options.groupService.get(ctx.params.id), "Groups"), parseProjectionFromUrl(ctx.query));
|
|
2033
2157
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
2034
2158
|
});
|
|
2035
2159
|
router.put("/Groups/:id", ...guards, json, async (ctx) => {
|
|
2036
2160
|
const payload = takeRequestBody(ctx);
|
|
2037
|
-
ctx.body = await options.groupService.replace(ctx.params.id, payload);
|
|
2161
|
+
ctx.body = projectGroup(withLocation(await options.groupService.replace(ctx.params.id, payload), "Groups"), parseProjectionFromUrl(ctx.query));
|
|
2038
2162
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
2039
2163
|
});
|
|
2040
2164
|
router.patch("/Groups/:id", ...guards, json, async (ctx) => {
|
|
2041
2165
|
const requestBody = takeRequestBody(ctx);
|
|
2042
2166
|
const ops = validatePatchRequest(requestBody);
|
|
2043
|
-
ctx.body = await options.groupService.patch(ctx.params.id, ops);
|
|
2167
|
+
ctx.body = projectGroup(withLocation(await options.groupService.patch(ctx.params.id, ops), "Groups"), parseProjectionFromUrl(ctx.query));
|
|
2044
2168
|
ctx.type = SCIM_MEDIA_TYPE;
|
|
2045
2169
|
});
|
|
2046
2170
|
router.delete("/Groups/:id", ...guards, async (ctx) => {
|
|
@@ -2049,6 +2173,18 @@ var createScimRouter = /* @__PURE__ */ __name((options) => {
|
|
|
2049
2173
|
});
|
|
2050
2174
|
return router;
|
|
2051
2175
|
}, "createScimRouter");
|
|
2176
|
+
var userSchemas = [
|
|
2177
|
+
userSchema,
|
|
2178
|
+
enterpriseUserSchema
|
|
2179
|
+
];
|
|
2180
|
+
var projectUser = /* @__PURE__ */ __name((user, projection) => projectScimResource(user, userSchemas, projection), "projectUser");
|
|
2181
|
+
var projectGroup = /* @__PURE__ */ __name((group, projection) => projectScimResource(group, [
|
|
2182
|
+
groupSchema
|
|
2183
|
+
], projection), "projectGroup");
|
|
2184
|
+
var parseProjectionFromUrl = /* @__PURE__ */ __name((query) => ({
|
|
2185
|
+
attributes: parseCsvParam(pickStringParam(query, "attributes")),
|
|
2186
|
+
excludedAttributes: parseCsvParam(pickStringParam(query, "excludedAttributes"))
|
|
2187
|
+
}), "parseProjectionFromUrl");
|
|
2052
2188
|
var takeRequestBody = /* @__PURE__ */ __name((ctx) => {
|
|
2053
2189
|
return ctx.parsedBody;
|
|
2054
2190
|
}, "takeRequestBody");
|
|
@@ -2057,7 +2193,7 @@ var parseListQueryFromUrl = /* @__PURE__ */ __name((query, maxResults) => {
|
|
|
2057
2193
|
return {
|
|
2058
2194
|
filter: filterRaw ? parseScimFilter(filterRaw) : void 0,
|
|
2059
2195
|
startIndex: parsePositiveInt(pickStringParam(query, "startIndex"), 1),
|
|
2060
|
-
count: clamp(
|
|
2196
|
+
count: clamp(parseCount(pickStringParam(query, "count"), maxResults), 0, maxResults),
|
|
2061
2197
|
sortBy: pickStringParam(query, "sortBy"),
|
|
2062
2198
|
sortOrder: parseSortOrder(pickStringParam(query, "sortOrder")),
|
|
2063
2199
|
attributes: parseCsvParam(pickStringParam(query, "attributes")),
|
|
@@ -2065,7 +2201,7 @@ var parseListQueryFromUrl = /* @__PURE__ */ __name((query, maxResults) => {
|
|
|
2065
2201
|
};
|
|
2066
2202
|
}, "parseListQueryFromUrl");
|
|
2067
2203
|
var parseListQueryFromBody = /* @__PURE__ */ __name((body, maxResults) => {
|
|
2068
|
-
if (!
|
|
2204
|
+
if (!isPlainObject3(body)) {
|
|
2069
2205
|
throw scimError(400, "invalidSyntax", "Bad Request").withDetails({
|
|
2070
2206
|
message: "Search body must be a JSON object"
|
|
2071
2207
|
});
|
|
@@ -2085,6 +2221,12 @@ var parseSortOrder = /* @__PURE__ */ __name((raw) => {
|
|
|
2085
2221
|
if (raw === "ascending" || raw === "descending") return raw;
|
|
2086
2222
|
return void 0;
|
|
2087
2223
|
}, "parseSortOrder");
|
|
2224
|
+
var parseCount = /* @__PURE__ */ __name((raw, fallback) => {
|
|
2225
|
+
if (raw === void 0) return fallback;
|
|
2226
|
+
const n = Number(raw);
|
|
2227
|
+
if (!Number.isFinite(n)) return fallback;
|
|
2228
|
+
return n < 1 ? 0 : Math.floor(n);
|
|
2229
|
+
}, "parseCount");
|
|
2088
2230
|
var parsePositiveInt = /* @__PURE__ */ __name((raw, fallback) => {
|
|
2089
2231
|
if (raw === void 0) return fallback;
|
|
2090
2232
|
const n = Number(raw);
|
|
@@ -2104,7 +2246,7 @@ var pickStringParam = /* @__PURE__ */ __name((query, key) => {
|
|
|
2104
2246
|
return void 0;
|
|
2105
2247
|
}, "pickStringParam");
|
|
2106
2248
|
var validatePatchRequest = /* @__PURE__ */ __name((body) => {
|
|
2107
|
-
if (!
|
|
2249
|
+
if (!isPlainObject3(body)) {
|
|
2108
2250
|
throw scimError(400, "invalidSyntax", "Bad Request").withDetails({
|
|
2109
2251
|
message: "PATCH request must be a JSON object"
|
|
2110
2252
|
});
|
|
@@ -2130,7 +2272,7 @@ var listEnvelope = /* @__PURE__ */ __name((resources, query, total) => ({
|
|
|
2130
2272
|
itemsPerPage: resources.length,
|
|
2131
2273
|
Resources: resources
|
|
2132
2274
|
}), "listEnvelope");
|
|
2133
|
-
var
|
|
2275
|
+
var isPlainObject3 = /* @__PURE__ */ __name((value) => {
|
|
2134
2276
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2135
2277
|
}, "isPlainObject");
|
|
2136
2278
|
export {
|
|
@@ -2158,6 +2300,7 @@ export {
|
|
|
2158
2300
|
groupResourceType,
|
|
2159
2301
|
groupSchema,
|
|
2160
2302
|
parseScimFilter,
|
|
2303
|
+
projectScimResource,
|
|
2161
2304
|
requireScimScope,
|
|
2162
2305
|
scimContentTypeMiddleware,
|
|
2163
2306
|
scimError,
|