@better-auth/scim 1.4.0-beta.27
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/.turbo/turbo-build.log +16 -0
- package/LICENSE.md +17 -0
- package/dist/client.d.mts +9 -0
- package/dist/client.mjs +10 -0
- package/dist/index-DZOZXCsn.d.mts +3212 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1247 -0
- package/package.json +57 -0
- package/src/client.ts +9 -0
- package/src/index.ts +1000 -0
- package/src/mappings.ts +38 -0
- package/src/middlewares.ts +64 -0
- package/src/patch-operations.ts +80 -0
- package/src/scim-error.ts +99 -0
- package/src/scim-filters.ts +69 -0
- package/src/scim-metadata.ts +128 -0
- package/src/scim-resources.ts +35 -0
- package/src/scim-tokens.ts +71 -0
- package/src/scim.test.ts +1995 -0
- package/src/types.ts +65 -0
- package/src/user-schemas.ts +213 -0
- package/src/utils.ts +5 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +7 -0
- package/vitest.config.ts +3 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
import { base64Url } from "@better-auth/utils/base64";
|
|
2
|
+
import { APIError, sessionMiddleware } from "better-auth/api";
|
|
3
|
+
import { generateRandomString, symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
|
4
|
+
import { createAuthEndpoint, createAuthMiddleware, defaultKeyHasher } from "better-auth/plugins";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import { APIError as APIError$1 } from "better-auth";
|
|
7
|
+
import { statusCodes } from "better-call";
|
|
8
|
+
|
|
9
|
+
//#region src/mappings.ts
|
|
10
|
+
const getAccountId = (userName, externalId) => {
|
|
11
|
+
return externalId ?? userName;
|
|
12
|
+
};
|
|
13
|
+
const getFormattedName = (name) => {
|
|
14
|
+
if (name.givenName && name.familyName) return `${name.givenName} ${name.familyName}`;
|
|
15
|
+
if (name.givenName) return name.givenName;
|
|
16
|
+
return name.familyName ?? "";
|
|
17
|
+
};
|
|
18
|
+
const getUserFullName = (email, name) => {
|
|
19
|
+
if (name) {
|
|
20
|
+
const formatted = name.formatted?.trim() ?? "";
|
|
21
|
+
if (formatted.length > 0) return formatted;
|
|
22
|
+
return getFormattedName(name) || email;
|
|
23
|
+
}
|
|
24
|
+
return email;
|
|
25
|
+
};
|
|
26
|
+
const getUserPrimaryEmail = (userName, emails) => {
|
|
27
|
+
return emails?.find((email) => email.primary)?.value ?? emails?.[0]?.value ?? userName;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/scim-error.ts
|
|
32
|
+
/**
|
|
33
|
+
* SCIM compliant error
|
|
34
|
+
* See: https://datatracker.ietf.org/doc/html/rfc7644#section-3.12
|
|
35
|
+
*/
|
|
36
|
+
var SCIMAPIError = class extends APIError$1 {
|
|
37
|
+
constructor(status = "INTERNAL_SERVER_ERROR", overrides = {}) {
|
|
38
|
+
const body = {
|
|
39
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
40
|
+
status: (typeof status === "number" ? status : statusCodes[status]).toString(),
|
|
41
|
+
detail: overrides.detail,
|
|
42
|
+
...overrides
|
|
43
|
+
};
|
|
44
|
+
super(status, body);
|
|
45
|
+
this.message = body.detail ?? body.message;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const SCIMErrorOpenAPISchema = {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
schemas: {
|
|
52
|
+
type: "array",
|
|
53
|
+
items: { type: "string" }
|
|
54
|
+
},
|
|
55
|
+
status: { type: "string" },
|
|
56
|
+
detail: { type: "string" },
|
|
57
|
+
scimType: { type: "string" }
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const SCIMErrorOpenAPISchemas = {
|
|
61
|
+
"400": {
|
|
62
|
+
description: "Bad Request. Usually due to missing parameters, or invalid parameters",
|
|
63
|
+
content: { "application/json": { schema: SCIMErrorOpenAPISchema } }
|
|
64
|
+
},
|
|
65
|
+
"401": {
|
|
66
|
+
description: "Unauthorized. Due to missing or invalid authentication.",
|
|
67
|
+
content: { "application/json": { schema: SCIMErrorOpenAPISchema } }
|
|
68
|
+
},
|
|
69
|
+
"403": {
|
|
70
|
+
description: "Unauthorized. Due to missing or invalid authentication.",
|
|
71
|
+
content: { "application/json": { schema: SCIMErrorOpenAPISchema } }
|
|
72
|
+
},
|
|
73
|
+
"404": {
|
|
74
|
+
description: "Not Found. The requested resource was not found.",
|
|
75
|
+
content: { "application/json": { schema: SCIMErrorOpenAPISchema } }
|
|
76
|
+
},
|
|
77
|
+
"429": {
|
|
78
|
+
description: "Too Many Requests. You have exceeded the rate limit. Try again later.",
|
|
79
|
+
content: { "application/json": { schema: SCIMErrorOpenAPISchema } }
|
|
80
|
+
},
|
|
81
|
+
"500": {
|
|
82
|
+
description: "Internal Server Error. This is a problem with the server that you cannot fix.",
|
|
83
|
+
content: { "application/json": { schema: SCIMErrorOpenAPISchema } }
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/scim-tokens.ts
|
|
89
|
+
async function storeSCIMToken(ctx, opts, scimToken) {
|
|
90
|
+
if (opts.storeSCIMToken === "encrypted") return await symmetricEncrypt({
|
|
91
|
+
key: ctx.context.secret,
|
|
92
|
+
data: scimToken
|
|
93
|
+
});
|
|
94
|
+
if (opts.storeSCIMToken === "hashed") return await defaultKeyHasher(scimToken);
|
|
95
|
+
if (typeof opts.storeSCIMToken === "object" && "hash" in opts.storeSCIMToken) return await opts.storeSCIMToken.hash(scimToken);
|
|
96
|
+
if (typeof opts.storeSCIMToken === "object" && "encrypt" in opts.storeSCIMToken) return await opts.storeSCIMToken.encrypt(scimToken);
|
|
97
|
+
return scimToken;
|
|
98
|
+
}
|
|
99
|
+
async function verifySCIMToken(ctx, opts, storedSCIMToken, scimToken) {
|
|
100
|
+
if (opts.storeSCIMToken === "encrypted") return await symmetricDecrypt({
|
|
101
|
+
key: ctx.context.secret,
|
|
102
|
+
data: storedSCIMToken
|
|
103
|
+
}) === scimToken;
|
|
104
|
+
if (opts.storeSCIMToken === "hashed") return await defaultKeyHasher(scimToken) === storedSCIMToken;
|
|
105
|
+
if (typeof opts.storeSCIMToken === "object" && "hash" in opts.storeSCIMToken) return await opts.storeSCIMToken.hash(scimToken) === storedSCIMToken;
|
|
106
|
+
if (typeof opts.storeSCIMToken === "object" && "decrypt" in opts.storeSCIMToken) return await opts.storeSCIMToken.decrypt(storedSCIMToken) === scimToken;
|
|
107
|
+
return scimToken === storedSCIMToken;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/middlewares.ts
|
|
112
|
+
/**
|
|
113
|
+
* The middleware forces the endpoint to have a valid token
|
|
114
|
+
*/
|
|
115
|
+
const authMiddlewareFactory = (opts) => createAuthMiddleware(async (ctx) => {
|
|
116
|
+
const authSCIMToken = (ctx.headers?.get("Authorization"))?.replace(/^Bearer\s+/i, "");
|
|
117
|
+
if (!authSCIMToken) throw new SCIMAPIError("UNAUTHORIZED", { detail: "SCIM token is required" });
|
|
118
|
+
const baseScimTokenParts = new TextDecoder().decode(base64Url.decode(authSCIMToken)).split(":");
|
|
119
|
+
const [scimToken, providerId] = baseScimTokenParts;
|
|
120
|
+
const organizationId = baseScimTokenParts.slice(2).join(":");
|
|
121
|
+
if (!scimToken || !providerId) throw new SCIMAPIError("UNAUTHORIZED", { detail: "Invalid SCIM token" });
|
|
122
|
+
const scimProvider = await ctx.context.adapter.findOne({
|
|
123
|
+
model: "scimProvider",
|
|
124
|
+
where: [{
|
|
125
|
+
field: "providerId",
|
|
126
|
+
value: providerId
|
|
127
|
+
}, ...organizationId ? [{
|
|
128
|
+
field: "organizationId",
|
|
129
|
+
value: organizationId
|
|
130
|
+
}] : []]
|
|
131
|
+
});
|
|
132
|
+
if (!scimProvider) throw new SCIMAPIError("UNAUTHORIZED", { detail: "Invalid SCIM token" });
|
|
133
|
+
if (!await verifySCIMToken(ctx, opts, scimProvider.scimToken, scimToken)) throw new SCIMAPIError("UNAUTHORIZED", { detail: "Invalid SCIM token" });
|
|
134
|
+
return {
|
|
135
|
+
authSCIMToken: scimToken,
|
|
136
|
+
scimProvider
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/patch-operations.ts
|
|
142
|
+
const identity = (user, op) => {
|
|
143
|
+
return op.value;
|
|
144
|
+
};
|
|
145
|
+
const lowerCase = (user, op) => {
|
|
146
|
+
return op.value.toLowerCase();
|
|
147
|
+
};
|
|
148
|
+
const givenName = (user, op) => {
|
|
149
|
+
const familyName$1 = user.name.split(" ").slice(1).join(" ").trim();
|
|
150
|
+
const givenName$1 = op.value;
|
|
151
|
+
return getUserFullName(user.email, {
|
|
152
|
+
givenName: givenName$1,
|
|
153
|
+
familyName: familyName$1
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
const familyName = (user, op) => {
|
|
157
|
+
const givenName$1 = (user.name.split(" ").slice(0, -1).join(" ") || user.name).trim();
|
|
158
|
+
const familyName$1 = op.value;
|
|
159
|
+
return getUserFullName(user.email, {
|
|
160
|
+
givenName: givenName$1,
|
|
161
|
+
familyName: familyName$1
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
const userPatchMappings = {
|
|
165
|
+
"/name/formatted": {
|
|
166
|
+
resource: "user",
|
|
167
|
+
target: "name",
|
|
168
|
+
map: identity
|
|
169
|
+
},
|
|
170
|
+
"/name/givenName": {
|
|
171
|
+
resource: "user",
|
|
172
|
+
target: "name",
|
|
173
|
+
map: givenName
|
|
174
|
+
},
|
|
175
|
+
"/name/familyName": {
|
|
176
|
+
resource: "user",
|
|
177
|
+
target: "name",
|
|
178
|
+
map: familyName
|
|
179
|
+
},
|
|
180
|
+
"/externalId": {
|
|
181
|
+
resource: "account",
|
|
182
|
+
target: "accountId",
|
|
183
|
+
map: identity
|
|
184
|
+
},
|
|
185
|
+
"/userName": {
|
|
186
|
+
resource: "user",
|
|
187
|
+
target: "email",
|
|
188
|
+
map: lowerCase
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const buildUserPatch = (user, operations) => {
|
|
192
|
+
const resources = {
|
|
193
|
+
user: {},
|
|
194
|
+
account: {}
|
|
195
|
+
};
|
|
196
|
+
for (const operation of operations) {
|
|
197
|
+
if (operation.op !== "replace" || !operation.path) continue;
|
|
198
|
+
const mapping = userPatchMappings[operation.path];
|
|
199
|
+
if (mapping) {
|
|
200
|
+
const resource = resources[mapping.resource];
|
|
201
|
+
resource[mapping.target] = mapping.map(user, operation);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return resources;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/user-schemas.ts
|
|
209
|
+
const APIUserSchema = z.object({
|
|
210
|
+
userName: z.string().lowercase(),
|
|
211
|
+
externalId: z.string().optional(),
|
|
212
|
+
name: z.object({
|
|
213
|
+
formatted: z.string().optional(),
|
|
214
|
+
givenName: z.string().optional(),
|
|
215
|
+
familyName: z.string().optional()
|
|
216
|
+
}).optional(),
|
|
217
|
+
emails: z.array(z.object({
|
|
218
|
+
value: z.email(),
|
|
219
|
+
primary: z.boolean().optional()
|
|
220
|
+
})).optional()
|
|
221
|
+
});
|
|
222
|
+
const OpenAPIUserResourceSchema = {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {
|
|
225
|
+
id: { type: "string" },
|
|
226
|
+
meta: {
|
|
227
|
+
type: "object",
|
|
228
|
+
properties: {
|
|
229
|
+
resourceType: { type: "string" },
|
|
230
|
+
created: {
|
|
231
|
+
type: "string",
|
|
232
|
+
format: "date-time"
|
|
233
|
+
},
|
|
234
|
+
lastModified: {
|
|
235
|
+
type: "string",
|
|
236
|
+
format: "date-time"
|
|
237
|
+
},
|
|
238
|
+
location: { type: "string" }
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
userName: { type: "string" },
|
|
242
|
+
name: {
|
|
243
|
+
type: "object",
|
|
244
|
+
properties: {
|
|
245
|
+
formatted: { type: "string" },
|
|
246
|
+
givenName: { type: "string" },
|
|
247
|
+
familyName: { type: "string" }
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
displayName: { type: "string" },
|
|
251
|
+
active: { type: "boolean" },
|
|
252
|
+
emails: {
|
|
253
|
+
type: "array",
|
|
254
|
+
items: {
|
|
255
|
+
type: "object",
|
|
256
|
+
properties: {
|
|
257
|
+
value: { type: "string" },
|
|
258
|
+
primary: { type: "boolean" }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
schemas: {
|
|
263
|
+
type: "array",
|
|
264
|
+
items: { type: "string" }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const SCIMUserResourceSchema = {
|
|
269
|
+
id: "urn:ietf:params:scim:schemas:core:2.0:User",
|
|
270
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
|
|
271
|
+
name: "User",
|
|
272
|
+
description: "User Account",
|
|
273
|
+
attributes: [
|
|
274
|
+
{
|
|
275
|
+
name: "id",
|
|
276
|
+
type: "string",
|
|
277
|
+
multiValued: false,
|
|
278
|
+
description: "Unique opaque identifier for the User",
|
|
279
|
+
required: false,
|
|
280
|
+
caseExact: true,
|
|
281
|
+
mutability: "readOnly",
|
|
282
|
+
returned: "default",
|
|
283
|
+
uniqueness: "server"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
name: "userName",
|
|
287
|
+
type: "string",
|
|
288
|
+
multiValued: false,
|
|
289
|
+
description: "Unique identifier for the User, typically used by the user to directly authenticate to the service provider",
|
|
290
|
+
required: true,
|
|
291
|
+
caseExact: false,
|
|
292
|
+
mutability: "readWrite",
|
|
293
|
+
returned: "default",
|
|
294
|
+
uniqueness: "server"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: "displayName",
|
|
298
|
+
type: "string",
|
|
299
|
+
multiValued: false,
|
|
300
|
+
description: "The name of the User, suitable for display to end-users. The name SHOULD be the full name of the User being described, if known.",
|
|
301
|
+
required: false,
|
|
302
|
+
caseExact: true,
|
|
303
|
+
mutability: "readOnly",
|
|
304
|
+
returned: "default",
|
|
305
|
+
uniqueness: "none"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: "active",
|
|
309
|
+
type: "boolean",
|
|
310
|
+
multiValued: false,
|
|
311
|
+
description: "A Boolean value indicating the User's administrative status.",
|
|
312
|
+
required: false,
|
|
313
|
+
mutability: "readOnly",
|
|
314
|
+
returned: "default"
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: "name",
|
|
318
|
+
type: "complex",
|
|
319
|
+
multiValued: false,
|
|
320
|
+
description: "The components of the user's real name.",
|
|
321
|
+
required: false,
|
|
322
|
+
subAttributes: [
|
|
323
|
+
{
|
|
324
|
+
name: "formatted",
|
|
325
|
+
type: "string",
|
|
326
|
+
multiValued: false,
|
|
327
|
+
description: "The full name, including all middlenames, titles, and suffixes as appropriate, formatted for display(e.g., 'Ms. Barbara J Jensen, III').",
|
|
328
|
+
required: false,
|
|
329
|
+
caseExact: false,
|
|
330
|
+
mutability: "readWrite",
|
|
331
|
+
returned: "default",
|
|
332
|
+
uniqueness: "none"
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "familyName",
|
|
336
|
+
type: "string",
|
|
337
|
+
multiValued: false,
|
|
338
|
+
description: "The family name of the User, or last name in most Western languages (e.g., 'Jensen' given the fullname 'Ms. Barbara J Jensen, III').",
|
|
339
|
+
required: false,
|
|
340
|
+
caseExact: false,
|
|
341
|
+
mutability: "readWrite",
|
|
342
|
+
returned: "default",
|
|
343
|
+
uniqueness: "none"
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "givenName",
|
|
347
|
+
type: "string",
|
|
348
|
+
multiValued: false,
|
|
349
|
+
description: "The given name of the User, or first name in most Western languages (e.g., 'Barbara' given the full name 'Ms. Barbara J Jensen, III').",
|
|
350
|
+
required: false,
|
|
351
|
+
caseExact: false,
|
|
352
|
+
mutability: "readWrite",
|
|
353
|
+
returned: "default",
|
|
354
|
+
uniqueness: "none"
|
|
355
|
+
}
|
|
356
|
+
]
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: "emails",
|
|
360
|
+
type: "complex",
|
|
361
|
+
multiValued: true,
|
|
362
|
+
description: "Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.",
|
|
363
|
+
required: false,
|
|
364
|
+
subAttributes: [{
|
|
365
|
+
name: "value",
|
|
366
|
+
type: "string",
|
|
367
|
+
multiValued: false,
|
|
368
|
+
description: "Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.",
|
|
369
|
+
required: false,
|
|
370
|
+
caseExact: false,
|
|
371
|
+
mutability: "readWrite",
|
|
372
|
+
returned: "default",
|
|
373
|
+
uniqueness: "server"
|
|
374
|
+
}, {
|
|
375
|
+
name: "primary",
|
|
376
|
+
type: "boolean",
|
|
377
|
+
multiValued: false,
|
|
378
|
+
description: "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred mailing address or primary email address. The primary attribute value 'true' MUST appear no more than once.",
|
|
379
|
+
required: false,
|
|
380
|
+
mutability: "readWrite",
|
|
381
|
+
returned: "default"
|
|
382
|
+
}],
|
|
383
|
+
mutability: "readWrite",
|
|
384
|
+
returned: "default",
|
|
385
|
+
uniqueness: "none"
|
|
386
|
+
}
|
|
387
|
+
],
|
|
388
|
+
meta: {
|
|
389
|
+
resourceType: "Schema",
|
|
390
|
+
location: "/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User"
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
const SCIMUserResourceType = {
|
|
394
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
395
|
+
id: "User",
|
|
396
|
+
name: "User",
|
|
397
|
+
endpoint: "/Users",
|
|
398
|
+
description: "User Account",
|
|
399
|
+
schema: "urn:ietf:params:scim:schemas:core:2.0:User",
|
|
400
|
+
meta: {
|
|
401
|
+
resourceType: "ResourceType",
|
|
402
|
+
location: "/scim/v2/ResourceTypes/User"
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/scim-filters.ts
|
|
408
|
+
const SCIMOperators = { eq: "eq" };
|
|
409
|
+
const SCIMUserAttributes = { userName: "email" };
|
|
410
|
+
var SCIMParseError = class extends Error {};
|
|
411
|
+
const SCIMFilterRegex = /^\s*(?<attribute>[^\s]+)\s+(?<op>eq|ne|co|sw|ew|pr)\s*(?:(?<value>"[^"]*"|[^\s]+))?\s*$/i;
|
|
412
|
+
const parseSCIMFilter = (filter) => {
|
|
413
|
+
const match = filter.match(SCIMFilterRegex);
|
|
414
|
+
if (!match) throw new SCIMParseError("Invalid filter expression");
|
|
415
|
+
const attribute = match.groups?.attribute;
|
|
416
|
+
const op = match.groups?.op?.toLowerCase();
|
|
417
|
+
const value = match.groups?.value;
|
|
418
|
+
if (!attribute || !op || !value) throw new SCIMParseError("Invalid filter expression");
|
|
419
|
+
const operator = SCIMOperators[op];
|
|
420
|
+
if (!operator) throw new SCIMParseError(`The operator "${op}" is not supported`);
|
|
421
|
+
return {
|
|
422
|
+
attribute,
|
|
423
|
+
operator,
|
|
424
|
+
value
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
const parseSCIMUserFilter = (filter) => {
|
|
428
|
+
const { attribute, operator, value } = parseSCIMFilter(filter);
|
|
429
|
+
const filters = [];
|
|
430
|
+
const targetAttribute = SCIMUserAttributes[attribute];
|
|
431
|
+
const resourceAttribute = SCIMUserResourceSchema.attributes.find((attr) => attr.name === attribute);
|
|
432
|
+
if (!targetAttribute || !resourceAttribute) throw new SCIMParseError(`The attribute "${attribute}" is not supported`);
|
|
433
|
+
let finalValue = value.replaceAll("\"", "");
|
|
434
|
+
if (!resourceAttribute.caseExact) finalValue = finalValue.toLowerCase();
|
|
435
|
+
filters.push({
|
|
436
|
+
field: targetAttribute,
|
|
437
|
+
value: finalValue,
|
|
438
|
+
operator
|
|
439
|
+
});
|
|
440
|
+
return filters;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/scim-metadata.ts
|
|
445
|
+
const MetadataFieldSupportOpenAPISchema = {
|
|
446
|
+
type: "object",
|
|
447
|
+
properties: { supported: { type: "boolean" } }
|
|
448
|
+
};
|
|
449
|
+
const ServiceProviderOpenAPISchema = {
|
|
450
|
+
type: "object",
|
|
451
|
+
properties: {
|
|
452
|
+
patch: MetadataFieldSupportOpenAPISchema,
|
|
453
|
+
bulk: MetadataFieldSupportOpenAPISchema,
|
|
454
|
+
filter: MetadataFieldSupportOpenAPISchema,
|
|
455
|
+
changePassword: MetadataFieldSupportOpenAPISchema,
|
|
456
|
+
sort: MetadataFieldSupportOpenAPISchema,
|
|
457
|
+
etag: MetadataFieldSupportOpenAPISchema,
|
|
458
|
+
authenticationSchemes: {
|
|
459
|
+
type: "array",
|
|
460
|
+
items: {
|
|
461
|
+
type: "object",
|
|
462
|
+
properties: {
|
|
463
|
+
name: { type: "string" },
|
|
464
|
+
description: { type: "string" },
|
|
465
|
+
specUri: { type: "string" },
|
|
466
|
+
type: { type: "string" },
|
|
467
|
+
primary: { type: "boolean" }
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
schemas: {
|
|
472
|
+
type: "array",
|
|
473
|
+
items: { type: "string" }
|
|
474
|
+
},
|
|
475
|
+
meta: {
|
|
476
|
+
type: "object",
|
|
477
|
+
properties: { resourceType: { type: "string" } }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
const ResourceTypeOpenAPISchema = {
|
|
482
|
+
type: "object",
|
|
483
|
+
properties: {
|
|
484
|
+
schemas: {
|
|
485
|
+
type: "array",
|
|
486
|
+
items: { type: "string" }
|
|
487
|
+
},
|
|
488
|
+
id: { type: "string" },
|
|
489
|
+
name: { type: "string" },
|
|
490
|
+
endpoint: { type: "string" },
|
|
491
|
+
description: { type: "string" },
|
|
492
|
+
schema: { type: "string" },
|
|
493
|
+
meta: {
|
|
494
|
+
type: "object",
|
|
495
|
+
properties: {
|
|
496
|
+
resourceType: { type: "string" },
|
|
497
|
+
location: { type: "string" }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
const SCIMSchemaAttributesOpenAPISchema = {
|
|
503
|
+
type: "object",
|
|
504
|
+
properties: {
|
|
505
|
+
name: { type: "string" },
|
|
506
|
+
type: { type: "string" },
|
|
507
|
+
multiValued: { type: "boolean" },
|
|
508
|
+
description: { type: "string" },
|
|
509
|
+
required: { type: "boolean" },
|
|
510
|
+
caseExact: { type: "boolean" },
|
|
511
|
+
mutability: { type: "string" },
|
|
512
|
+
returned: { type: "string" },
|
|
513
|
+
uniqueness: { type: "string" }
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const SCIMSchemaOpenAPISchema = {
|
|
517
|
+
type: "object",
|
|
518
|
+
properties: {
|
|
519
|
+
id: { type: "string" },
|
|
520
|
+
schemas: {
|
|
521
|
+
type: "array",
|
|
522
|
+
items: { type: "string" }
|
|
523
|
+
},
|
|
524
|
+
name: { type: "string" },
|
|
525
|
+
description: { type: "string" },
|
|
526
|
+
attributes: {
|
|
527
|
+
type: "array",
|
|
528
|
+
items: {
|
|
529
|
+
...SCIMSchemaAttributesOpenAPISchema,
|
|
530
|
+
properties: {
|
|
531
|
+
...SCIMSchemaAttributesOpenAPISchema.properties,
|
|
532
|
+
subAttributes: {
|
|
533
|
+
type: "array",
|
|
534
|
+
items: SCIMSchemaAttributesOpenAPISchema
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
meta: {
|
|
540
|
+
type: "object",
|
|
541
|
+
properties: {
|
|
542
|
+
resourceType: { type: "string" },
|
|
543
|
+
location: { type: "string" }
|
|
544
|
+
},
|
|
545
|
+
required: ["resourceType", "location"]
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/utils.ts
|
|
552
|
+
const getResourceURL = (path, baseURL) => {
|
|
553
|
+
const normalizedBaseURL = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
|
554
|
+
const normalizedPath = path.replace(/^\/+/, "");
|
|
555
|
+
return new URL(normalizedPath, normalizedBaseURL).toString();
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
//#endregion
|
|
559
|
+
//#region src/scim-resources.ts
|
|
560
|
+
const createUserResource = (baseURL, user, account) => {
|
|
561
|
+
return {
|
|
562
|
+
id: user.id,
|
|
563
|
+
externalId: account?.accountId,
|
|
564
|
+
meta: {
|
|
565
|
+
resourceType: "User",
|
|
566
|
+
created: user.createdAt,
|
|
567
|
+
lastModified: user.updatedAt,
|
|
568
|
+
location: getResourceURL(`/scim/v2/Users/${user.id}`, baseURL)
|
|
569
|
+
},
|
|
570
|
+
userName: user.email,
|
|
571
|
+
name: { formatted: user.name },
|
|
572
|
+
displayName: user.name,
|
|
573
|
+
active: true,
|
|
574
|
+
emails: [{
|
|
575
|
+
primary: true,
|
|
576
|
+
value: user.email
|
|
577
|
+
}],
|
|
578
|
+
schemas: [SCIMUserResourceSchema.id]
|
|
579
|
+
};
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
//#endregion
|
|
583
|
+
//#region src/index.ts
|
|
584
|
+
const supportedSCIMSchemas = [SCIMUserResourceSchema];
|
|
585
|
+
const supportedSCIMResourceTypes = [SCIMUserResourceType];
|
|
586
|
+
const findUserById = async (adapter, { userId, providerId, organizationId }) => {
|
|
587
|
+
const account = await adapter.findOne({
|
|
588
|
+
model: "account",
|
|
589
|
+
where: [{
|
|
590
|
+
field: "userId",
|
|
591
|
+
value: userId
|
|
592
|
+
}, {
|
|
593
|
+
field: "providerId",
|
|
594
|
+
value: providerId
|
|
595
|
+
}]
|
|
596
|
+
});
|
|
597
|
+
if (!account) return {
|
|
598
|
+
user: null,
|
|
599
|
+
account: null
|
|
600
|
+
};
|
|
601
|
+
let member = null;
|
|
602
|
+
if (organizationId) member = await adapter.findOne({
|
|
603
|
+
model: "member",
|
|
604
|
+
where: [{
|
|
605
|
+
field: "organizationId",
|
|
606
|
+
value: organizationId
|
|
607
|
+
}, {
|
|
608
|
+
field: "userId",
|
|
609
|
+
value: userId
|
|
610
|
+
}]
|
|
611
|
+
});
|
|
612
|
+
if (organizationId && !member) return {
|
|
613
|
+
user: null,
|
|
614
|
+
account: null
|
|
615
|
+
};
|
|
616
|
+
const user = await adapter.findOne({
|
|
617
|
+
model: "user",
|
|
618
|
+
where: [{
|
|
619
|
+
field: "id",
|
|
620
|
+
value: userId
|
|
621
|
+
}]
|
|
622
|
+
});
|
|
623
|
+
if (!user) return {
|
|
624
|
+
user: null,
|
|
625
|
+
account: null
|
|
626
|
+
};
|
|
627
|
+
return {
|
|
628
|
+
user,
|
|
629
|
+
account
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
const scim = (options) => {
|
|
633
|
+
const opts = {
|
|
634
|
+
storeSCIMToken: "plain",
|
|
635
|
+
...options
|
|
636
|
+
};
|
|
637
|
+
const authMiddleware = authMiddlewareFactory(opts);
|
|
638
|
+
return {
|
|
639
|
+
id: "scim",
|
|
640
|
+
endpoints: {
|
|
641
|
+
generateSCIMToken: createAuthEndpoint("/scim/generate-token", {
|
|
642
|
+
method: "POST",
|
|
643
|
+
body: z.object({
|
|
644
|
+
providerId: z.string().meta({ description: "Unique provider identifier" }),
|
|
645
|
+
organizationId: z.string().optional().meta({ description: "Optional organization id" })
|
|
646
|
+
}),
|
|
647
|
+
metadata: { openapi: {
|
|
648
|
+
summary: "Generates a new SCIM token for the given provider",
|
|
649
|
+
description: "Generates a new SCIM token to be used for SCIM operations",
|
|
650
|
+
responses: { "201": {
|
|
651
|
+
description: "SCIM token response",
|
|
652
|
+
content: { "application/json": { schema: {
|
|
653
|
+
type: "object",
|
|
654
|
+
properties: { scimToken: {
|
|
655
|
+
description: "SCIM token",
|
|
656
|
+
type: "string"
|
|
657
|
+
} }
|
|
658
|
+
} } }
|
|
659
|
+
} }
|
|
660
|
+
} },
|
|
661
|
+
use: [sessionMiddleware]
|
|
662
|
+
}, async (ctx) => {
|
|
663
|
+
const { providerId, organizationId } = ctx.body;
|
|
664
|
+
const user = ctx.context.session.user;
|
|
665
|
+
if (providerId.includes(":")) throw new APIError("BAD_REQUEST", { message: "Provider id contains forbidden characters" });
|
|
666
|
+
const isOrgPluginEnabled = ctx.context.options.plugins?.some((p) => p.id === "organization");
|
|
667
|
+
if (organizationId && !isOrgPluginEnabled) throw new APIError("BAD_REQUEST", { message: "Restricting a token to an organization requires the organization plugin" });
|
|
668
|
+
let member = null;
|
|
669
|
+
if (organizationId) {
|
|
670
|
+
member = await ctx.context.adapter.findOne({
|
|
671
|
+
model: "member",
|
|
672
|
+
where: [{
|
|
673
|
+
field: "userId",
|
|
674
|
+
value: user.id
|
|
675
|
+
}, {
|
|
676
|
+
field: "organizationId",
|
|
677
|
+
value: organizationId
|
|
678
|
+
}]
|
|
679
|
+
});
|
|
680
|
+
if (!member) throw new APIError("FORBIDDEN", { message: "You are not a member of the organization" });
|
|
681
|
+
}
|
|
682
|
+
const scimProvider = await ctx.context.adapter.findOne({
|
|
683
|
+
model: "scimProvider",
|
|
684
|
+
where: [{
|
|
685
|
+
field: "providerId",
|
|
686
|
+
value: providerId
|
|
687
|
+
}, ...organizationId ? [{
|
|
688
|
+
field: "organizationId",
|
|
689
|
+
value: organizationId
|
|
690
|
+
}] : []]
|
|
691
|
+
});
|
|
692
|
+
if (scimProvider) await ctx.context.adapter.delete({
|
|
693
|
+
model: "scimProvider",
|
|
694
|
+
where: [{
|
|
695
|
+
field: "id",
|
|
696
|
+
value: scimProvider.id
|
|
697
|
+
}]
|
|
698
|
+
});
|
|
699
|
+
const baseToken = generateRandomString(24);
|
|
700
|
+
const scimToken = base64Url.encode(`${baseToken}:${providerId}${organizationId ? `:${organizationId}` : ""}`);
|
|
701
|
+
if (opts.beforeSCIMTokenGenerated) await opts.beforeSCIMTokenGenerated({
|
|
702
|
+
user,
|
|
703
|
+
member,
|
|
704
|
+
scimToken
|
|
705
|
+
});
|
|
706
|
+
const newSCIMProvider = await ctx.context.adapter.create({
|
|
707
|
+
model: "scimProvider",
|
|
708
|
+
data: {
|
|
709
|
+
providerId,
|
|
710
|
+
organizationId,
|
|
711
|
+
scimToken: await storeSCIMToken(ctx, opts, baseToken)
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
if (opts.afterSCIMTokenGenerated) await opts.afterSCIMTokenGenerated({
|
|
715
|
+
user,
|
|
716
|
+
member,
|
|
717
|
+
scimToken,
|
|
718
|
+
scimProvider: newSCIMProvider
|
|
719
|
+
});
|
|
720
|
+
ctx.setStatus(201);
|
|
721
|
+
return ctx.json({ scimToken });
|
|
722
|
+
}),
|
|
723
|
+
createSCIMUser: createAuthEndpoint("/scim/v2/Users", {
|
|
724
|
+
method: "POST",
|
|
725
|
+
body: APIUserSchema,
|
|
726
|
+
metadata: {
|
|
727
|
+
isAction: false,
|
|
728
|
+
openapi: {
|
|
729
|
+
summary: "Create SCIM user.",
|
|
730
|
+
description: "Provision a new user into the linked organization via SCIM. See https://datatracker.ietf.org/doc/html/rfc7644#section-3.3",
|
|
731
|
+
responses: {
|
|
732
|
+
"201": {
|
|
733
|
+
description: "SCIM user resource",
|
|
734
|
+
content: { "application/json": { schema: OpenAPIUserResourceSchema } }
|
|
735
|
+
},
|
|
736
|
+
...SCIMErrorOpenAPISchemas
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
use: [authMiddleware]
|
|
741
|
+
}, async (ctx) => {
|
|
742
|
+
const body = ctx.body;
|
|
743
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
744
|
+
const accountId = getAccountId(body.userName, body.externalId);
|
|
745
|
+
if (await ctx.context.adapter.findOne({
|
|
746
|
+
model: "account",
|
|
747
|
+
where: [{
|
|
748
|
+
field: "accountId",
|
|
749
|
+
value: accountId
|
|
750
|
+
}, {
|
|
751
|
+
field: "providerId",
|
|
752
|
+
value: providerId
|
|
753
|
+
}]
|
|
754
|
+
})) throw new SCIMAPIError("CONFLICT", {
|
|
755
|
+
detail: "User already exists",
|
|
756
|
+
scimType: "uniqueness"
|
|
757
|
+
});
|
|
758
|
+
const email = getUserPrimaryEmail(body.userName, body.emails);
|
|
759
|
+
const name = getUserFullName(email, body.name);
|
|
760
|
+
const existingUser = await ctx.context.adapter.findOne({
|
|
761
|
+
model: "user",
|
|
762
|
+
where: [{
|
|
763
|
+
field: "email",
|
|
764
|
+
value: email
|
|
765
|
+
}]
|
|
766
|
+
});
|
|
767
|
+
const createAccount = (userId) => ctx.context.internalAdapter.createAccount({
|
|
768
|
+
userId,
|
|
769
|
+
providerId,
|
|
770
|
+
accountId,
|
|
771
|
+
accessToken: "",
|
|
772
|
+
refreshToken: ""
|
|
773
|
+
});
|
|
774
|
+
const createUser = () => ctx.context.internalAdapter.createUser({
|
|
775
|
+
email,
|
|
776
|
+
name
|
|
777
|
+
});
|
|
778
|
+
const createOrgMembership = async (userId) => {
|
|
779
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
780
|
+
if (organizationId) {
|
|
781
|
+
if (!await ctx.context.adapter.findOne({
|
|
782
|
+
model: "member",
|
|
783
|
+
where: [{
|
|
784
|
+
field: "organizationId",
|
|
785
|
+
value: organizationId
|
|
786
|
+
}, {
|
|
787
|
+
field: "userId",
|
|
788
|
+
value: userId
|
|
789
|
+
}]
|
|
790
|
+
})) return await ctx.context.adapter.create({
|
|
791
|
+
model: "member",
|
|
792
|
+
data: {
|
|
793
|
+
userId,
|
|
794
|
+
role: "member",
|
|
795
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
796
|
+
organizationId
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
let user;
|
|
802
|
+
let account;
|
|
803
|
+
if (existingUser) {
|
|
804
|
+
user = existingUser;
|
|
805
|
+
account = await ctx.context.adapter.transaction(async () => {
|
|
806
|
+
const account$1 = await createAccount(user.id);
|
|
807
|
+
await createOrgMembership(user.id);
|
|
808
|
+
return account$1;
|
|
809
|
+
});
|
|
810
|
+
} else [user, account] = await ctx.context.adapter.transaction(async () => {
|
|
811
|
+
const user$1 = await createUser();
|
|
812
|
+
const account$1 = await createAccount(user$1.id);
|
|
813
|
+
await createOrgMembership(user$1.id);
|
|
814
|
+
return [user$1, account$1];
|
|
815
|
+
});
|
|
816
|
+
const userResource = createUserResource(ctx.context.baseURL, user, account);
|
|
817
|
+
ctx.setStatus(201);
|
|
818
|
+
ctx.setHeader("location", userResource.meta.location);
|
|
819
|
+
return ctx.json(userResource);
|
|
820
|
+
}),
|
|
821
|
+
updateSCIMUser: createAuthEndpoint("/scim/v2/Users/:userId", {
|
|
822
|
+
method: "PUT",
|
|
823
|
+
body: APIUserSchema,
|
|
824
|
+
metadata: {
|
|
825
|
+
isAction: false,
|
|
826
|
+
openapi: {
|
|
827
|
+
summary: "Update SCIM user.",
|
|
828
|
+
description: "Updates an existing user into the linked organization via SCIM. See https://datatracker.ietf.org/doc/html/rfc7644#section-3.3",
|
|
829
|
+
responses: {
|
|
830
|
+
"200": {
|
|
831
|
+
description: "SCIM user resource",
|
|
832
|
+
content: { "application/json": { schema: OpenAPIUserResourceSchema } }
|
|
833
|
+
},
|
|
834
|
+
...SCIMErrorOpenAPISchemas
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
use: [authMiddleware]
|
|
839
|
+
}, async (ctx) => {
|
|
840
|
+
const body = ctx.body;
|
|
841
|
+
const userId = ctx.params.userId;
|
|
842
|
+
const { organizationId, providerId } = ctx.context.scimProvider;
|
|
843
|
+
const accountId = getAccountId(body.userName, body.externalId);
|
|
844
|
+
const { user, account } = await findUserById(ctx.context.adapter, {
|
|
845
|
+
userId,
|
|
846
|
+
providerId,
|
|
847
|
+
organizationId
|
|
848
|
+
});
|
|
849
|
+
if (!user) throw new SCIMAPIError("NOT_FOUND", { detail: "User not found" });
|
|
850
|
+
const [updatedUser, updatedAccount] = await ctx.context.adapter.transaction(async () => {
|
|
851
|
+
const email = getUserPrimaryEmail(body.userName, body.emails);
|
|
852
|
+
const name = getUserFullName(email, body.name);
|
|
853
|
+
return [await ctx.context.internalAdapter.updateUser(userId, {
|
|
854
|
+
email,
|
|
855
|
+
name,
|
|
856
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
857
|
+
}), await ctx.context.internalAdapter.updateAccount(account.id, {
|
|
858
|
+
accountId,
|
|
859
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
860
|
+
})];
|
|
861
|
+
});
|
|
862
|
+
const userResource = createUserResource(ctx.context.baseURL, updatedUser, updatedAccount);
|
|
863
|
+
return ctx.json(userResource);
|
|
864
|
+
}),
|
|
865
|
+
listSCIMUsers: createAuthEndpoint("/scim/v2/Users", {
|
|
866
|
+
method: "GET",
|
|
867
|
+
query: z.object({ filter: z.string().optional() }).optional(),
|
|
868
|
+
metadata: {
|
|
869
|
+
isAction: false,
|
|
870
|
+
openapi: {
|
|
871
|
+
summary: "List SCIM users",
|
|
872
|
+
description: "Returns all users provisioned via SCIM for the linked organization. See https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2",
|
|
873
|
+
responses: {
|
|
874
|
+
"200": {
|
|
875
|
+
description: "SCIM user list",
|
|
876
|
+
content: { "application/json": { schema: {
|
|
877
|
+
type: "object",
|
|
878
|
+
properties: {
|
|
879
|
+
totalResults: { type: "number" },
|
|
880
|
+
itemsPerPage: { type: "number" },
|
|
881
|
+
startIndex: { type: "number" },
|
|
882
|
+
Resources: {
|
|
883
|
+
type: "array",
|
|
884
|
+
items: OpenAPIUserResourceSchema
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
} } }
|
|
888
|
+
},
|
|
889
|
+
...SCIMErrorOpenAPISchemas
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
use: [authMiddleware]
|
|
894
|
+
}, async (ctx) => {
|
|
895
|
+
let apiFilters = parseSCIMAPIUserFilter(ctx.query?.filter);
|
|
896
|
+
ctx.context.logger.info("Querying result with filters: ", apiFilters);
|
|
897
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
898
|
+
const accounts = await ctx.context.adapter.findMany({
|
|
899
|
+
model: "account",
|
|
900
|
+
where: [{
|
|
901
|
+
field: "providerId",
|
|
902
|
+
value: providerId
|
|
903
|
+
}]
|
|
904
|
+
});
|
|
905
|
+
const accountUserIds = accounts.map((account) => account.userId);
|
|
906
|
+
let userFilters = [{
|
|
907
|
+
field: "id",
|
|
908
|
+
value: accountUserIds,
|
|
909
|
+
operator: "in"
|
|
910
|
+
}];
|
|
911
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
912
|
+
if (organizationId) userFilters = [{
|
|
913
|
+
field: "id",
|
|
914
|
+
value: (await ctx.context.adapter.findMany({
|
|
915
|
+
model: "member",
|
|
916
|
+
where: [{
|
|
917
|
+
field: "organizationId",
|
|
918
|
+
value: organizationId
|
|
919
|
+
}, {
|
|
920
|
+
field: "userId",
|
|
921
|
+
value: accountUserIds,
|
|
922
|
+
operator: "in"
|
|
923
|
+
}]
|
|
924
|
+
})).map((member) => member.userId),
|
|
925
|
+
operator: "in"
|
|
926
|
+
}];
|
|
927
|
+
const users = await ctx.context.adapter.findMany({
|
|
928
|
+
model: "user",
|
|
929
|
+
where: [...userFilters, ...apiFilters]
|
|
930
|
+
});
|
|
931
|
+
return ctx.json({
|
|
932
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
933
|
+
totalResults: users.length,
|
|
934
|
+
startIndex: 1,
|
|
935
|
+
itemsPerPage: users.length,
|
|
936
|
+
Resources: users.map((user) => {
|
|
937
|
+
const account = accounts.find((a) => a.userId === user.id);
|
|
938
|
+
return createUserResource(ctx.context.baseURL, user, account);
|
|
939
|
+
})
|
|
940
|
+
});
|
|
941
|
+
}),
|
|
942
|
+
getSCIMUser: createAuthEndpoint("/scim/v2/Users/:userId", {
|
|
943
|
+
method: "GET",
|
|
944
|
+
metadata: {
|
|
945
|
+
isAction: false,
|
|
946
|
+
openapi: {
|
|
947
|
+
summary: "Get SCIM user details",
|
|
948
|
+
description: "Returns the provisioned SCIM user details. See https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.1",
|
|
949
|
+
responses: {
|
|
950
|
+
"200": {
|
|
951
|
+
description: "SCIM user resource",
|
|
952
|
+
content: { "application/json": { schema: OpenAPIUserResourceSchema } }
|
|
953
|
+
},
|
|
954
|
+
...SCIMErrorOpenAPISchemas
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
use: [authMiddleware]
|
|
959
|
+
}, async (ctx) => {
|
|
960
|
+
const userId = ctx.params.userId;
|
|
961
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
962
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
963
|
+
const { user, account } = await findUserById(ctx.context.adapter, {
|
|
964
|
+
userId,
|
|
965
|
+
providerId,
|
|
966
|
+
organizationId
|
|
967
|
+
});
|
|
968
|
+
if (!user) throw new SCIMAPIError("NOT_FOUND", { detail: "User not found" });
|
|
969
|
+
return ctx.json(createUserResource(ctx.context.baseURL, user, account));
|
|
970
|
+
}),
|
|
971
|
+
patchSCIMUser: createAuthEndpoint("/scim/v2/Users/:userId", {
|
|
972
|
+
method: "PATCH",
|
|
973
|
+
body: z.object({
|
|
974
|
+
schemas: z.array(z.string()).refine((s) => s.includes("urn:ietf:params:scim:api:messages:2.0:PatchOp"), { message: "Invalid schemas for PatchOp" }),
|
|
975
|
+
Operations: z.array(z.object({
|
|
976
|
+
op: z.enum([
|
|
977
|
+
"replace",
|
|
978
|
+
"add",
|
|
979
|
+
"remove"
|
|
980
|
+
]).default("replace"),
|
|
981
|
+
path: z.string().optional(),
|
|
982
|
+
value: z.any()
|
|
983
|
+
}))
|
|
984
|
+
}),
|
|
985
|
+
metadata: {
|
|
986
|
+
isAction: false,
|
|
987
|
+
openapi: {
|
|
988
|
+
summary: "Patch SCIM user",
|
|
989
|
+
description: "Updates fields on a SCIM user record",
|
|
990
|
+
responses: {
|
|
991
|
+
"204": { description: "Patch update applied correctly" },
|
|
992
|
+
...SCIMErrorOpenAPISchemas
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
use: [authMiddleware]
|
|
997
|
+
}, async (ctx) => {
|
|
998
|
+
const userId = ctx.params.userId;
|
|
999
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
1000
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
1001
|
+
const { user, account } = await findUserById(ctx.context.adapter, {
|
|
1002
|
+
userId,
|
|
1003
|
+
providerId,
|
|
1004
|
+
organizationId
|
|
1005
|
+
});
|
|
1006
|
+
if (!user) throw new SCIMAPIError("NOT_FOUND", { detail: "User not found" });
|
|
1007
|
+
const { user: userPatch, account: accountPatch } = buildUserPatch(user, ctx.body.Operations);
|
|
1008
|
+
if (Object.keys(userPatch).length === 0 && Object.keys(accountPatch).length === 0) throw new SCIMAPIError("BAD_REQUEST", { detail: "No valid fields to update" });
|
|
1009
|
+
await Promise.all([Object.keys(userPatch).length > 0 ? ctx.context.internalAdapter.updateUser(userId, {
|
|
1010
|
+
...userPatch,
|
|
1011
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1012
|
+
}) : Promise.resolve(), Object.keys(accountPatch).length > 0 ? ctx.context.internalAdapter.updateAccount(account.id, {
|
|
1013
|
+
...accountPatch,
|
|
1014
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1015
|
+
}) : Promise.resolve()]);
|
|
1016
|
+
ctx.setStatus(204);
|
|
1017
|
+
}),
|
|
1018
|
+
deleteSCIMUser: createAuthEndpoint("/scim/v2/Users/:userId", {
|
|
1019
|
+
method: "DELETE",
|
|
1020
|
+
metadata: {
|
|
1021
|
+
isAction: false,
|
|
1022
|
+
openapi: {
|
|
1023
|
+
summary: "Delete SCIM user",
|
|
1024
|
+
description: "Deletes (or deactivates) a user within the linked organization.",
|
|
1025
|
+
responses: {
|
|
1026
|
+
"204": { description: "Delete applied successfully" },
|
|
1027
|
+
...SCIMErrorOpenAPISchemas
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
},
|
|
1031
|
+
use: [authMiddleware]
|
|
1032
|
+
}, async (ctx) => {
|
|
1033
|
+
const userId = ctx.params.userId;
|
|
1034
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
1035
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
1036
|
+
const { user } = await findUserById(ctx.context.adapter, {
|
|
1037
|
+
userId,
|
|
1038
|
+
providerId,
|
|
1039
|
+
organizationId
|
|
1040
|
+
});
|
|
1041
|
+
if (!user) throw new SCIMAPIError("NOT_FOUND", { detail: "User not found" });
|
|
1042
|
+
await ctx.context.internalAdapter.deleteUser(userId);
|
|
1043
|
+
ctx.setStatus(204);
|
|
1044
|
+
}),
|
|
1045
|
+
getSCIMServiceProviderConfig: createAuthEndpoint("/scim/v2/ServiceProviderConfig", {
|
|
1046
|
+
method: "GET",
|
|
1047
|
+
metadata: {
|
|
1048
|
+
isAction: false,
|
|
1049
|
+
openapi: {
|
|
1050
|
+
summary: "SCIM Service Provider Configuration",
|
|
1051
|
+
description: "Standard SCIM metadata endpoint used by identity providers. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
1052
|
+
responses: {
|
|
1053
|
+
"200": {
|
|
1054
|
+
description: "SCIM metadata object",
|
|
1055
|
+
content: { "application/json": { schema: ServiceProviderOpenAPISchema } }
|
|
1056
|
+
},
|
|
1057
|
+
...SCIMErrorOpenAPISchemas
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}, async (ctx) => {
|
|
1062
|
+
return ctx.json({
|
|
1063
|
+
patch: { supported: true },
|
|
1064
|
+
bulk: { supported: false },
|
|
1065
|
+
filter: { supported: true },
|
|
1066
|
+
changePassword: { supported: false },
|
|
1067
|
+
sort: { supported: false },
|
|
1068
|
+
etag: { supported: false },
|
|
1069
|
+
authenticationSchemes: [{
|
|
1070
|
+
name: "OAuth Bearer Token",
|
|
1071
|
+
description: "Authentication scheme using the Authorization header with a bearer token tied to an organization.",
|
|
1072
|
+
specUri: "http://www.rfc-editor.org/info/rfc6750",
|
|
1073
|
+
type: "oauthbearertoken",
|
|
1074
|
+
primary: true
|
|
1075
|
+
}],
|
|
1076
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
1077
|
+
meta: { resourceType: "ServiceProviderConfig" }
|
|
1078
|
+
});
|
|
1079
|
+
}),
|
|
1080
|
+
getSCIMSchemas: createAuthEndpoint("/scim/v2/Schemas", {
|
|
1081
|
+
method: "GET",
|
|
1082
|
+
metadata: {
|
|
1083
|
+
isAction: false,
|
|
1084
|
+
openapi: {
|
|
1085
|
+
summary: "SCIM Service Provider Configuration Schemas",
|
|
1086
|
+
description: "Standard SCIM metadata endpoint used by identity providers to acquire information about supported schemas. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
1087
|
+
responses: {
|
|
1088
|
+
"200": {
|
|
1089
|
+
description: "SCIM metadata object",
|
|
1090
|
+
content: { "application/json": { schema: {
|
|
1091
|
+
type: "array",
|
|
1092
|
+
items: SCIMSchemaOpenAPISchema
|
|
1093
|
+
} } }
|
|
1094
|
+
},
|
|
1095
|
+
...SCIMErrorOpenAPISchemas
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}, async (ctx) => {
|
|
1100
|
+
return ctx.json({
|
|
1101
|
+
totalResults: supportedSCIMSchemas.length,
|
|
1102
|
+
itemsPerPage: supportedSCIMSchemas.length,
|
|
1103
|
+
startIndex: 1,
|
|
1104
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
1105
|
+
Resources: supportedSCIMSchemas.map((s) => {
|
|
1106
|
+
return {
|
|
1107
|
+
...s,
|
|
1108
|
+
meta: {
|
|
1109
|
+
...s.meta,
|
|
1110
|
+
location: getResourceURL(s.meta.location, ctx.context.baseURL)
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
})
|
|
1114
|
+
});
|
|
1115
|
+
}),
|
|
1116
|
+
getSCIMSchema: createAuthEndpoint("/scim/v2/Schemas/:schemaId", {
|
|
1117
|
+
method: "GET",
|
|
1118
|
+
metadata: {
|
|
1119
|
+
isAction: false,
|
|
1120
|
+
openapi: {
|
|
1121
|
+
summary: "SCIM a Service Provider Configuration Schema",
|
|
1122
|
+
description: "Standard SCIM metadata endpoint used by identity providers to acquire information about a given schema. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
1123
|
+
responses: {
|
|
1124
|
+
"200": {
|
|
1125
|
+
description: "SCIM metadata object",
|
|
1126
|
+
content: { "application/json": { schema: SCIMSchemaOpenAPISchema } }
|
|
1127
|
+
},
|
|
1128
|
+
...SCIMErrorOpenAPISchemas
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}, async (ctx) => {
|
|
1133
|
+
const schema = supportedSCIMSchemas.find((s) => s.id === ctx.params.schemaId);
|
|
1134
|
+
if (!schema) throw new SCIMAPIError("NOT_FOUND", { detail: "Schema not found" });
|
|
1135
|
+
return ctx.json({
|
|
1136
|
+
...schema,
|
|
1137
|
+
meta: {
|
|
1138
|
+
...schema.meta,
|
|
1139
|
+
location: getResourceURL(schema.meta.location, ctx.context.baseURL)
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
}),
|
|
1143
|
+
getSCIMResourceTypes: createAuthEndpoint("/scim/v2/ResourceTypes", {
|
|
1144
|
+
method: "GET",
|
|
1145
|
+
metadata: {
|
|
1146
|
+
isAction: false,
|
|
1147
|
+
openapi: {
|
|
1148
|
+
summary: "SCIM Service Provider Supported Resource Types",
|
|
1149
|
+
description: "Standard SCIM metadata endpoint used by identity providers to get a list of server supported types. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
1150
|
+
responses: {
|
|
1151
|
+
"200": {
|
|
1152
|
+
description: "SCIM metadata object",
|
|
1153
|
+
content: { "application/json": { schema: {
|
|
1154
|
+
type: "object",
|
|
1155
|
+
properties: {
|
|
1156
|
+
totalResults: { type: "number" },
|
|
1157
|
+
itemsPerPage: { type: "number" },
|
|
1158
|
+
startIndex: { type: "number" },
|
|
1159
|
+
Resources: {
|
|
1160
|
+
type: "array",
|
|
1161
|
+
items: ResourceTypeOpenAPISchema
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
} } }
|
|
1165
|
+
},
|
|
1166
|
+
...SCIMErrorOpenAPISchemas
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}, async (ctx) => {
|
|
1171
|
+
return ctx.json({
|
|
1172
|
+
totalResults: supportedSCIMResourceTypes.length,
|
|
1173
|
+
itemsPerPage: supportedSCIMResourceTypes.length,
|
|
1174
|
+
startIndex: 1,
|
|
1175
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
1176
|
+
Resources: supportedSCIMResourceTypes.map((s) => {
|
|
1177
|
+
return {
|
|
1178
|
+
...s,
|
|
1179
|
+
meta: {
|
|
1180
|
+
...s.meta,
|
|
1181
|
+
location: getResourceURL(s.meta.location, ctx.context.baseURL)
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
})
|
|
1185
|
+
});
|
|
1186
|
+
}),
|
|
1187
|
+
getSCIMResourceType: createAuthEndpoint("/scim/v2/ResourceTypes/:resourceTypeId", {
|
|
1188
|
+
method: "GET",
|
|
1189
|
+
metadata: {
|
|
1190
|
+
isAction: false,
|
|
1191
|
+
openapi: {
|
|
1192
|
+
summary: "SCIM Service Provider Supported Resource Type",
|
|
1193
|
+
description: "Standard SCIM metadata endpoint used by identity providers to get a server supported type. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
1194
|
+
responses: {
|
|
1195
|
+
"200": {
|
|
1196
|
+
description: "SCIM metadata object",
|
|
1197
|
+
content: { "application/json": { schema: ResourceTypeOpenAPISchema } }
|
|
1198
|
+
},
|
|
1199
|
+
...SCIMErrorOpenAPISchemas
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}, async (ctx) => {
|
|
1204
|
+
const resourceType = supportedSCIMResourceTypes.find((s) => s.id === ctx.params.resourceTypeId);
|
|
1205
|
+
if (!resourceType) throw new SCIMAPIError("NOT_FOUND", { detail: "Resource type not found" });
|
|
1206
|
+
return ctx.json({
|
|
1207
|
+
...resourceType,
|
|
1208
|
+
meta: {
|
|
1209
|
+
...resourceType.meta,
|
|
1210
|
+
location: getResourceURL(resourceType.meta.location, ctx.context.baseURL)
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
})
|
|
1214
|
+
},
|
|
1215
|
+
schema: { scimProvider: { fields: {
|
|
1216
|
+
providerId: {
|
|
1217
|
+
type: "string",
|
|
1218
|
+
required: true,
|
|
1219
|
+
unique: true
|
|
1220
|
+
},
|
|
1221
|
+
scimToken: {
|
|
1222
|
+
type: "string",
|
|
1223
|
+
required: true,
|
|
1224
|
+
unique: true
|
|
1225
|
+
},
|
|
1226
|
+
organizationId: {
|
|
1227
|
+
type: "string",
|
|
1228
|
+
required: false
|
|
1229
|
+
}
|
|
1230
|
+
} } }
|
|
1231
|
+
};
|
|
1232
|
+
};
|
|
1233
|
+
const parseSCIMAPIUserFilter = (filter) => {
|
|
1234
|
+
let filters = [];
|
|
1235
|
+
try {
|
|
1236
|
+
filters = filter ? parseSCIMUserFilter(filter) : [];
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
throw new SCIMAPIError("BAD_REQUEST", {
|
|
1239
|
+
detail: error instanceof SCIMParseError ? error.message : "Invalid SCIM filter",
|
|
1240
|
+
scimType: "invalidFilter"
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
return filters;
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
//#endregion
|
|
1247
|
+
export { scim };
|