@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/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 };