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