@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.
@@ -0,0 +1,2525 @@
1
+ import { sso } from "@better-auth/sso";
2
+ import { APIError, betterAuth } from "better-auth";
3
+ import { memoryAdapter } from "better-auth/adapters/memory";
4
+ import { createAuthClient } from "better-auth/client";
5
+ import { setCookieToHeader } from "better-auth/cookies";
6
+ import { bearer, organization } from "better-auth/plugins";
7
+ import { describe, expect, it } from "vitest";
8
+ import { scim } from ".";
9
+ import { scimClient } from "./client";
10
+ import type { SCIMOptions } from "./types";
11
+
12
+ const createTestInstance = (scimOptions?: SCIMOptions) => {
13
+ const testUser = {
14
+ email: "test@email.com",
15
+ password: "password",
16
+ name: "Test User",
17
+ };
18
+
19
+ const data = {
20
+ user: [],
21
+ session: [],
22
+ verification: [],
23
+ account: [],
24
+ ssoProvider: [],
25
+ scimProvider: [],
26
+ organization: [],
27
+ member: [],
28
+ };
29
+ const memory = memoryAdapter(data);
30
+
31
+ const auth = betterAuth({
32
+ database: memory,
33
+ baseURL: "http://localhost:3000",
34
+ emailAndPassword: {
35
+ enabled: true,
36
+ },
37
+ plugins: [sso(), scim(scimOptions), organization()],
38
+ });
39
+
40
+ const authClient = createAuthClient({
41
+ baseURL: "http://localhost:3000",
42
+ plugins: [bearer(), scimClient()],
43
+ fetchOptions: {
44
+ customFetchImpl: async (url, init) => {
45
+ return auth.handler(new Request(url, init));
46
+ },
47
+ },
48
+ });
49
+
50
+ async function getAuthCookieHeaders() {
51
+ const headers = new Headers();
52
+
53
+ await authClient.signUp.email({
54
+ email: testUser.email,
55
+ password: testUser.password,
56
+ name: testUser.name,
57
+ });
58
+
59
+ await authClient.signIn.email(testUser, {
60
+ throw: true,
61
+ onSuccess: setCookieToHeader(headers),
62
+ });
63
+
64
+ return headers;
65
+ }
66
+
67
+ async function getSCIMToken(
68
+ providerId: string = "the-saml-provider-1",
69
+ organizationId?: string,
70
+ ) {
71
+ const headers = await getAuthCookieHeaders();
72
+ const { scimToken } = await auth.api.generateSCIMToken({
73
+ body: {
74
+ providerId,
75
+ organizationId,
76
+ },
77
+ headers,
78
+ });
79
+
80
+ return scimToken;
81
+ }
82
+
83
+ async function registerOrganization(org: string) {
84
+ const headers = await getAuthCookieHeaders();
85
+
86
+ return await auth.api.createOrganization({
87
+ body: {
88
+ slug: `the-${org}`,
89
+ name: `the organization ${org}`,
90
+ },
91
+ headers,
92
+ });
93
+ }
94
+
95
+ return {
96
+ auth,
97
+ authClient,
98
+ registerOrganization,
99
+ getSCIMToken,
100
+ getAuthCookieHeaders,
101
+ };
102
+ };
103
+
104
+ describe("SCIM", () => {
105
+ describe("POST /scim/generate-token", () => {
106
+ it("should require user session", async () => {
107
+ const { auth } = createTestInstance();
108
+ const generateSCIMToken = () =>
109
+ auth.api.generateSCIMToken({ body: { providerId: "the id" } });
110
+
111
+ await expect(generateSCIMToken()).rejects.toThrowError(
112
+ expect.objectContaining({
113
+ status: "UNAUTHORIZED",
114
+ }),
115
+ );
116
+ });
117
+
118
+ it("should fail if the authenticated user does not belong to the given org", async () => {
119
+ const { auth, getAuthCookieHeaders } = createTestInstance();
120
+ const headers = await getAuthCookieHeaders();
121
+ const generateSCIMToken = () =>
122
+ auth.api.generateSCIMToken({
123
+ body: { providerId: "the id", organizationId: "the-org" },
124
+ headers,
125
+ });
126
+
127
+ await expect(generateSCIMToken()).rejects.toThrowError(
128
+ expect.objectContaining({
129
+ message: "You are not a member of the organization",
130
+ }),
131
+ );
132
+ });
133
+
134
+ it("should fail to generate a SCIM token on invalid provider", async () => {
135
+ const { auth, getAuthCookieHeaders } = createTestInstance({
136
+ storeSCIMToken: "plain",
137
+ });
138
+ const headers = await getAuthCookieHeaders();
139
+
140
+ const generateSCIMToken = (providerId: string, organizationId?: string) =>
141
+ auth.api.generateSCIMToken({
142
+ body: { providerId, organizationId },
143
+ headers,
144
+ });
145
+
146
+ await expect(generateSCIMToken("the:provider")).rejects.toThrowError(
147
+ expect.objectContaining({
148
+ message: "Provider id contains forbidden characters",
149
+ }),
150
+ );
151
+ });
152
+
153
+ it("should generate a new scim token (client)", async () => {
154
+ const { auth, authClient, getAuthCookieHeaders } = createTestInstance();
155
+
156
+ const headers = await getAuthCookieHeaders();
157
+ const response = await authClient.scim.generateToken(
158
+ {
159
+ providerId: "the id",
160
+ },
161
+ { headers },
162
+ );
163
+
164
+ expect(response.data).toMatchObject({
165
+ scimToken: expect.any(String),
166
+ });
167
+
168
+ const createUser = () =>
169
+ auth.api.createSCIMUser({
170
+ body: {
171
+ userName: "the-username",
172
+ },
173
+ headers: {
174
+ authorization: `Bearer ${response.data?.scimToken}`,
175
+ },
176
+ });
177
+
178
+ await expect(createUser()).resolves.toBeTruthy();
179
+ });
180
+
181
+ it("should generate a new scim token (plain)", async () => {
182
+ const { auth, getAuthCookieHeaders } = createTestInstance({
183
+ storeSCIMToken: "plain",
184
+ });
185
+ const headers = await getAuthCookieHeaders();
186
+
187
+ const response = await auth.api.generateSCIMToken({
188
+ body: { providerId: "the id" },
189
+ headers,
190
+ });
191
+
192
+ expect(response).toMatchObject({
193
+ scimToken: expect.any(String),
194
+ });
195
+
196
+ const createUser = () =>
197
+ auth.api.createSCIMUser({
198
+ body: {
199
+ userName: "the-username",
200
+ },
201
+ headers: {
202
+ authorization: `Bearer ${response.scimToken}`,
203
+ },
204
+ });
205
+
206
+ await expect(createUser()).resolves.toBeTruthy();
207
+ });
208
+
209
+ it("should generate a new scim token (hashed)", async () => {
210
+ const { auth, getAuthCookieHeaders } = createTestInstance({
211
+ storeSCIMToken: "hashed",
212
+ });
213
+ const headers = await getAuthCookieHeaders();
214
+
215
+ const response = await auth.api.generateSCIMToken({
216
+ body: { providerId: "the id" },
217
+ headers,
218
+ });
219
+
220
+ expect(response).toMatchObject({
221
+ scimToken: expect.any(String),
222
+ });
223
+
224
+ const createUser = () =>
225
+ auth.api.createSCIMUser({
226
+ body: {
227
+ userName: "the-username",
228
+ },
229
+ headers: {
230
+ authorization: `Bearer ${response.scimToken}`,
231
+ },
232
+ });
233
+
234
+ await expect(createUser()).resolves.toBeTruthy();
235
+ });
236
+
237
+ it("should generate a new scim token (custom hash)", async () => {
238
+ const { auth, getAuthCookieHeaders } = createTestInstance({
239
+ storeSCIMToken: { hash: async (value) => value + "hello" },
240
+ });
241
+
242
+ const headers = await getAuthCookieHeaders();
243
+ const response = await auth.api.generateSCIMToken({
244
+ body: { providerId: "the id" },
245
+ headers,
246
+ });
247
+
248
+ const createUser = () =>
249
+ auth.api.createSCIMUser({
250
+ body: {
251
+ userName: "the-username",
252
+ },
253
+ headers: {
254
+ authorization: `Bearer ${response.scimToken}`,
255
+ },
256
+ });
257
+
258
+ await expect(createUser()).resolves.toBeTruthy();
259
+ });
260
+
261
+ it("should generate a new scim token (encrypted)", async () => {
262
+ const { auth, getAuthCookieHeaders } = createTestInstance({
263
+ storeSCIMToken: "encrypted",
264
+ });
265
+
266
+ const headers = await getAuthCookieHeaders();
267
+ const response = await auth.api.generateSCIMToken({
268
+ body: { providerId: "the id" },
269
+ headers,
270
+ });
271
+
272
+ const createUser = () =>
273
+ auth.api.createSCIMUser({
274
+ body: {
275
+ userName: "the-username",
276
+ },
277
+ headers: {
278
+ authorization: `Bearer ${response.scimToken}`,
279
+ },
280
+ });
281
+
282
+ await expect(createUser()).resolves.toBeTruthy();
283
+ });
284
+
285
+ it("should generate a new scim token (custom encryption)", async () => {
286
+ const { auth, getAuthCookieHeaders } = createTestInstance({
287
+ storeSCIMToken: {
288
+ encrypt: async (value) => value,
289
+ decrypt: async (value) => value,
290
+ },
291
+ });
292
+
293
+ const headers = await getAuthCookieHeaders();
294
+ const response = await auth.api.generateSCIMToken({
295
+ body: { providerId: "the id" },
296
+ headers,
297
+ });
298
+
299
+ const createUser = () =>
300
+ auth.api.createSCIMUser({
301
+ body: {
302
+ userName: "the-username",
303
+ },
304
+ headers: {
305
+ authorization: `Bearer ${response.scimToken}`,
306
+ },
307
+ });
308
+
309
+ await expect(createUser()).resolves.toBeTruthy();
310
+ });
311
+
312
+ it("should generate a new scim token associated to an org", async () => {
313
+ const { auth, registerOrganization, getAuthCookieHeaders } =
314
+ createTestInstance();
315
+ const orgA = await registerOrganization("org-a");
316
+ const headers = await getAuthCookieHeaders();
317
+
318
+ const response = await auth.api.generateSCIMToken({
319
+ body: { providerId: "the id", organizationId: orgA?.id },
320
+ headers,
321
+ });
322
+
323
+ expect(response).toMatchObject({
324
+ scimToken: expect.any(String),
325
+ });
326
+ });
327
+
328
+ it("should execute hooks before SCIM token generation", async () => {
329
+ const { auth, getAuthCookieHeaders, registerOrganization } =
330
+ createTestInstance({
331
+ beforeSCIMTokenGenerated: async ({ user, member, scimToken }) => {
332
+ if (member?.role === "owner") {
333
+ throw new APIError("FORBIDDEN", {
334
+ message:
335
+ "You do not have enough privileges to generate a SCIM token",
336
+ });
337
+ }
338
+ },
339
+ });
340
+ const headers = await getAuthCookieHeaders();
341
+ const orgA = await registerOrganization("the org");
342
+
343
+ const generateSCIMToken = () =>
344
+ auth.api.generateSCIMToken({
345
+ body: { providerId: "the id", organizationId: orgA?.id },
346
+ headers,
347
+ });
348
+
349
+ await expect(generateSCIMToken()).rejects.toThrowError(
350
+ expect.objectContaining({
351
+ message: "You do not have enough privileges to generate a SCIM token",
352
+ }),
353
+ );
354
+ });
355
+
356
+ it("should execute hooks after SCIM token generation", async () => {
357
+ const { auth, getAuthCookieHeaders } = createTestInstance({
358
+ storeSCIMToken: "plain",
359
+ afterSCIMTokenGenerated: async ({
360
+ user,
361
+ member,
362
+ scimProvider,
363
+ scimToken,
364
+ }) => {
365
+ expect(scimProvider.scimToken).toBeTypeOf("string");
366
+ },
367
+ });
368
+ const headers = await getAuthCookieHeaders();
369
+
370
+ const response = await auth.api.generateSCIMToken({
371
+ body: { providerId: "the id" },
372
+ headers,
373
+ });
374
+
375
+ expect(response).toMatchObject({
376
+ scimToken: expect.any(String),
377
+ });
378
+ });
379
+ });
380
+
381
+ describe("GET /scim/v2/ServiceProviderConfig", () => {
382
+ it("should fetch the service provider config", async () => {
383
+ const { auth } = createTestInstance();
384
+ const serviceProviderInfo = await auth.api.getSCIMServiceProviderConfig();
385
+
386
+ expect(serviceProviderInfo).toMatchInlineSnapshot(`
387
+ {
388
+ "authenticationSchemes": [
389
+ {
390
+ "description": "Authentication scheme using the Authorization header with a bearer token tied to an organization.",
391
+ "name": "OAuth Bearer Token",
392
+ "primary": true,
393
+ "specUri": "http://www.rfc-editor.org/info/rfc6750",
394
+ "type": "oauthbearertoken",
395
+ },
396
+ ],
397
+ "bulk": {
398
+ "supported": false,
399
+ },
400
+ "changePassword": {
401
+ "supported": false,
402
+ },
403
+ "etag": {
404
+ "supported": false,
405
+ },
406
+ "filter": {
407
+ "supported": true,
408
+ },
409
+ "meta": {
410
+ "resourceType": "ServiceProviderConfig",
411
+ },
412
+ "patch": {
413
+ "supported": true,
414
+ },
415
+ "schemas": [
416
+ "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
417
+ ],
418
+ "sort": {
419
+ "supported": false,
420
+ },
421
+ }
422
+ `);
423
+ });
424
+ });
425
+
426
+ describe("GET /scim/v2/Schemas", () => {
427
+ it("should fetch the list of supported schemas", async () => {
428
+ const { auth } = createTestInstance();
429
+ const schemas = await auth.api.getSCIMSchemas();
430
+
431
+ expect(schemas).toMatchInlineSnapshot(`
432
+ {
433
+ "Resources": [
434
+ {
435
+ "attributes": [
436
+ {
437
+ "caseExact": true,
438
+ "description": "Unique opaque identifier for the User",
439
+ "multiValued": false,
440
+ "mutability": "readOnly",
441
+ "name": "id",
442
+ "required": false,
443
+ "returned": "default",
444
+ "type": "string",
445
+ "uniqueness": "server",
446
+ },
447
+ {
448
+ "caseExact": false,
449
+ "description": "Unique identifier for the User, typically used by the user to directly authenticate to the service provider",
450
+ "multiValued": false,
451
+ "mutability": "readWrite",
452
+ "name": "userName",
453
+ "required": true,
454
+ "returned": "default",
455
+ "type": "string",
456
+ "uniqueness": "server",
457
+ },
458
+ {
459
+ "caseExact": true,
460
+ "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.",
461
+ "multiValued": false,
462
+ "mutability": "readOnly",
463
+ "name": "displayName",
464
+ "required": false,
465
+ "returned": "default",
466
+ "type": "string",
467
+ "uniqueness": "none",
468
+ },
469
+ {
470
+ "description": "A Boolean value indicating the User's administrative status.",
471
+ "multiValued": false,
472
+ "mutability": "readOnly",
473
+ "name": "active",
474
+ "required": false,
475
+ "returned": "default",
476
+ "type": "boolean",
477
+ },
478
+ {
479
+ "description": "The components of the user's real name.",
480
+ "multiValued": false,
481
+ "name": "name",
482
+ "required": false,
483
+ "subAttributes": [
484
+ {
485
+ "caseExact": false,
486
+ "description": "The full name, including all middlenames, titles, and suffixes as appropriate, formatted for display(e.g., 'Ms. Barbara J Jensen, III').",
487
+ "multiValued": false,
488
+ "mutability": "readWrite",
489
+ "name": "formatted",
490
+ "required": false,
491
+ "returned": "default",
492
+ "type": "string",
493
+ "uniqueness": "none",
494
+ },
495
+ {
496
+ "caseExact": false,
497
+ "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').",
498
+ "multiValued": false,
499
+ "mutability": "readWrite",
500
+ "name": "familyName",
501
+ "required": false,
502
+ "returned": "default",
503
+ "type": "string",
504
+ "uniqueness": "none",
505
+ },
506
+ {
507
+ "caseExact": false,
508
+ "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').",
509
+ "multiValued": false,
510
+ "mutability": "readWrite",
511
+ "name": "givenName",
512
+ "required": false,
513
+ "returned": "default",
514
+ "type": "string",
515
+ "uniqueness": "none",
516
+ },
517
+ ],
518
+ "type": "complex",
519
+ },
520
+ {
521
+ "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'.",
522
+ "multiValued": true,
523
+ "mutability": "readWrite",
524
+ "name": "emails",
525
+ "required": false,
526
+ "returned": "default",
527
+ "subAttributes": [
528
+ {
529
+ "caseExact": false,
530
+ "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'.",
531
+ "multiValued": false,
532
+ "mutability": "readWrite",
533
+ "name": "value",
534
+ "required": false,
535
+ "returned": "default",
536
+ "type": "string",
537
+ "uniqueness": "server",
538
+ },
539
+ {
540
+ "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.",
541
+ "multiValued": false,
542
+ "mutability": "readWrite",
543
+ "name": "primary",
544
+ "required": false,
545
+ "returned": "default",
546
+ "type": "boolean",
547
+ },
548
+ ],
549
+ "type": "complex",
550
+ "uniqueness": "none",
551
+ },
552
+ ],
553
+ "description": "User Account",
554
+ "id": "urn:ietf:params:scim:schemas:core:2.0:User",
555
+ "meta": {
556
+ "location": "http://localhost:3000/api/auth/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User",
557
+ "resourceType": "Schema",
558
+ },
559
+ "name": "User",
560
+ "schemas": [
561
+ "urn:ietf:params:scim:schemas:core:2.0:Schema",
562
+ ],
563
+ },
564
+ ],
565
+ "itemsPerPage": 1,
566
+ "schemas": [
567
+ "urn:ietf:params:scim:api:messages:2.0:ListResponse",
568
+ ],
569
+ "startIndex": 1,
570
+ "totalResults": 1,
571
+ }
572
+ `);
573
+ });
574
+
575
+ it("should fetch a single resource schema", async () => {
576
+ const { auth } = createTestInstance();
577
+ const schemas = await auth.api.getSCIMSchema({
578
+ params: {
579
+ schemaId: "urn:ietf:params:scim:schemas:core:2.0:User",
580
+ },
581
+ });
582
+
583
+ expect(schemas).toMatchInlineSnapshot(`
584
+ {
585
+ "attributes": [
586
+ {
587
+ "caseExact": true,
588
+ "description": "Unique opaque identifier for the User",
589
+ "multiValued": false,
590
+ "mutability": "readOnly",
591
+ "name": "id",
592
+ "required": false,
593
+ "returned": "default",
594
+ "type": "string",
595
+ "uniqueness": "server",
596
+ },
597
+ {
598
+ "caseExact": false,
599
+ "description": "Unique identifier for the User, typically used by the user to directly authenticate to the service provider",
600
+ "multiValued": false,
601
+ "mutability": "readWrite",
602
+ "name": "userName",
603
+ "required": true,
604
+ "returned": "default",
605
+ "type": "string",
606
+ "uniqueness": "server",
607
+ },
608
+ {
609
+ "caseExact": true,
610
+ "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.",
611
+ "multiValued": false,
612
+ "mutability": "readOnly",
613
+ "name": "displayName",
614
+ "required": false,
615
+ "returned": "default",
616
+ "type": "string",
617
+ "uniqueness": "none",
618
+ },
619
+ {
620
+ "description": "A Boolean value indicating the User's administrative status.",
621
+ "multiValued": false,
622
+ "mutability": "readOnly",
623
+ "name": "active",
624
+ "required": false,
625
+ "returned": "default",
626
+ "type": "boolean",
627
+ },
628
+ {
629
+ "description": "The components of the user's real name.",
630
+ "multiValued": false,
631
+ "name": "name",
632
+ "required": false,
633
+ "subAttributes": [
634
+ {
635
+ "caseExact": false,
636
+ "description": "The full name, including all middlenames, titles, and suffixes as appropriate, formatted for display(e.g., 'Ms. Barbara J Jensen, III').",
637
+ "multiValued": false,
638
+ "mutability": "readWrite",
639
+ "name": "formatted",
640
+ "required": false,
641
+ "returned": "default",
642
+ "type": "string",
643
+ "uniqueness": "none",
644
+ },
645
+ {
646
+ "caseExact": false,
647
+ "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').",
648
+ "multiValued": false,
649
+ "mutability": "readWrite",
650
+ "name": "familyName",
651
+ "required": false,
652
+ "returned": "default",
653
+ "type": "string",
654
+ "uniqueness": "none",
655
+ },
656
+ {
657
+ "caseExact": false,
658
+ "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').",
659
+ "multiValued": false,
660
+ "mutability": "readWrite",
661
+ "name": "givenName",
662
+ "required": false,
663
+ "returned": "default",
664
+ "type": "string",
665
+ "uniqueness": "none",
666
+ },
667
+ ],
668
+ "type": "complex",
669
+ },
670
+ {
671
+ "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'.",
672
+ "multiValued": true,
673
+ "mutability": "readWrite",
674
+ "name": "emails",
675
+ "required": false,
676
+ "returned": "default",
677
+ "subAttributes": [
678
+ {
679
+ "caseExact": false,
680
+ "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'.",
681
+ "multiValued": false,
682
+ "mutability": "readWrite",
683
+ "name": "value",
684
+ "required": false,
685
+ "returned": "default",
686
+ "type": "string",
687
+ "uniqueness": "server",
688
+ },
689
+ {
690
+ "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.",
691
+ "multiValued": false,
692
+ "mutability": "readWrite",
693
+ "name": "primary",
694
+ "required": false,
695
+ "returned": "default",
696
+ "type": "boolean",
697
+ },
698
+ ],
699
+ "type": "complex",
700
+ "uniqueness": "none",
701
+ },
702
+ ],
703
+ "description": "User Account",
704
+ "id": "urn:ietf:params:scim:schemas:core:2.0:User",
705
+ "meta": {
706
+ "location": "http://localhost:3000/api/auth/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User",
707
+ "resourceType": "Schema",
708
+ },
709
+ "name": "User",
710
+ "schemas": [
711
+ "urn:ietf:params:scim:schemas:core:2.0:Schema",
712
+ ],
713
+ }
714
+ `);
715
+ });
716
+
717
+ it("should return not found for unsupported schemas", async () => {
718
+ const { auth } = createTestInstance();
719
+
720
+ const getSchema = () =>
721
+ auth.api.getSCIMSchema({
722
+ params: {
723
+ schemaId: "unknown",
724
+ },
725
+ });
726
+
727
+ await expect(getSchema()).rejects.toThrowError(
728
+ expect.objectContaining({
729
+ message: "Schema not found",
730
+ body: {
731
+ detail: "Schema not found",
732
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
733
+ status: "404",
734
+ },
735
+ }),
736
+ );
737
+ });
738
+ });
739
+
740
+ describe("GET /scim/v2/ResourceTypes", () => {
741
+ it("should fetch the list of supported resource types", async () => {
742
+ const { auth } = createTestInstance();
743
+ const resourceTypes = await auth.api.getSCIMResourceTypes();
744
+
745
+ expect(resourceTypes).toMatchInlineSnapshot(`
746
+ {
747
+ "Resources": [
748
+ {
749
+ "description": "User Account",
750
+ "endpoint": "/Users",
751
+ "id": "User",
752
+ "meta": {
753
+ "location": "http://localhost:3000/api/auth/scim/v2/ResourceTypes/User",
754
+ "resourceType": "ResourceType",
755
+ },
756
+ "name": "User",
757
+ "schema": "urn:ietf:params:scim:schemas:core:2.0:User",
758
+ "schemas": [
759
+ "urn:ietf:params:scim:schemas:core:2.0:ResourceType",
760
+ ],
761
+ },
762
+ ],
763
+ "itemsPerPage": 1,
764
+ "schemas": [
765
+ "urn:ietf:params:scim:api:messages:2.0:ListResponse",
766
+ ],
767
+ "startIndex": 1,
768
+ "totalResults": 1,
769
+ }
770
+ `);
771
+ });
772
+
773
+ it("should fetch a single resource type", async () => {
774
+ const { auth } = createTestInstance();
775
+ const resourceType = await auth.api.getSCIMResourceType({
776
+ params: {
777
+ resourceTypeId: "User",
778
+ },
779
+ });
780
+
781
+ expect(resourceType).toMatchInlineSnapshot(`
782
+ {
783
+ "description": "User Account",
784
+ "endpoint": "/Users",
785
+ "id": "User",
786
+ "meta": {
787
+ "location": "http://localhost:3000/api/auth/scim/v2/ResourceTypes/User",
788
+ "resourceType": "ResourceType",
789
+ },
790
+ "name": "User",
791
+ "schema": "urn:ietf:params:scim:schemas:core:2.0:User",
792
+ "schemas": [
793
+ "urn:ietf:params:scim:schemas:core:2.0:ResourceType",
794
+ ],
795
+ }
796
+ `);
797
+ });
798
+
799
+ it("should return not found for unsupported resource types", async () => {
800
+ const { auth } = createTestInstance();
801
+ const getResourceType = () =>
802
+ auth.api.getSCIMResourceType({
803
+ params: {
804
+ resourceTypeId: "unknown",
805
+ },
806
+ });
807
+
808
+ await expect(getResourceType()).rejects.toThrowError(
809
+ expect.objectContaining({
810
+ message: "Resource type not found",
811
+ body: {
812
+ detail: "Resource type not found",
813
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
814
+ status: "404",
815
+ },
816
+ }),
817
+ );
818
+ });
819
+ });
820
+
821
+ describe("POST /scim/v2/Users", () => {
822
+ it("should create a new user", async () => {
823
+ const { auth, getSCIMToken } = createTestInstance();
824
+ const scimToken = await getSCIMToken();
825
+
826
+ const response = await auth.api.createSCIMUser({
827
+ body: {
828
+ userName: "the-username",
829
+ },
830
+ headers: {
831
+ authorization: `Bearer ${scimToken}`,
832
+ },
833
+ asResponse: true,
834
+ });
835
+
836
+ expect(response.status).toBe(201);
837
+ expect(response.headers.get("location")).toStrictEqual(
838
+ expect.stringContaining("/api/auth/scim/v2/Users/"),
839
+ );
840
+
841
+ const user = await response.json();
842
+ expect(user).toMatchObject({
843
+ active: true,
844
+ displayName: "the-username",
845
+ emails: [
846
+ {
847
+ primary: true,
848
+ value: "the-username",
849
+ },
850
+ ],
851
+ externalId: "the-username",
852
+ id: expect.any(String),
853
+ meta: expect.objectContaining({
854
+ created: expect.any(String),
855
+ lastModified: expect.any(String),
856
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
857
+ resourceType: "User",
858
+ }),
859
+ name: {
860
+ formatted: "the-username",
861
+ },
862
+ schemas: expect.arrayContaining([
863
+ "urn:ietf:params:scim:schemas:core:2.0:User",
864
+ ]),
865
+ userName: "the-username",
866
+ });
867
+ });
868
+
869
+ it("should create a new account linked to an existing user", async () => {
870
+ const { auth, authClient, getSCIMToken } = createTestInstance();
871
+ const scimToken = await getSCIMToken();
872
+
873
+ await authClient.signUp.email({
874
+ email: "existing@email.com",
875
+ password: "the password",
876
+ name: "existing user",
877
+ });
878
+
879
+ const response = await auth.api.createSCIMUser({
880
+ body: {
881
+ userName: "the-username",
882
+ emails: [{ value: "existing@email.com" }],
883
+ },
884
+ headers: {
885
+ authorization: `Bearer ${scimToken}`,
886
+ },
887
+ asResponse: true,
888
+ });
889
+
890
+ expect(response.status).toBe(201);
891
+ expect(response.headers.get("location")).toStrictEqual(
892
+ expect.stringContaining("/api/auth/scim/v2/Users/"),
893
+ );
894
+
895
+ const user = await response.json();
896
+ expect(user).toMatchObject({
897
+ active: true,
898
+ displayName: "existing user",
899
+ emails: [
900
+ {
901
+ primary: true,
902
+ value: "existing@email.com",
903
+ },
904
+ ],
905
+ externalId: "the-username",
906
+ id: expect.any(String),
907
+ meta: expect.objectContaining({
908
+ created: expect.any(String),
909
+ lastModified: expect.any(String),
910
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
911
+ resourceType: "User",
912
+ }),
913
+ name: {
914
+ formatted: "existing user",
915
+ },
916
+ schemas: expect.arrayContaining([
917
+ "urn:ietf:params:scim:schemas:core:2.0:User",
918
+ ]),
919
+ userName: "existing@email.com",
920
+ });
921
+ });
922
+
923
+ it("should create a new user with external id", async () => {
924
+ const { auth, getSCIMToken } = createTestInstance();
925
+ const scimToken = await getSCIMToken();
926
+
927
+ const user = await auth.api.createSCIMUser({
928
+ body: {
929
+ externalId: "external-username",
930
+ userName: "the-username",
931
+ },
932
+ headers: {
933
+ authorization: `Bearer ${scimToken}`,
934
+ },
935
+ });
936
+
937
+ expect(user).toMatchObject({
938
+ active: true,
939
+ displayName: "the-username",
940
+ emails: [
941
+ {
942
+ primary: true,
943
+ value: "the-username",
944
+ },
945
+ ],
946
+ externalId: "external-username",
947
+ id: expect.any(String),
948
+ meta: expect.objectContaining({
949
+ created: expect.any(Date),
950
+ lastModified: expect.any(Date),
951
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
952
+ resourceType: "User",
953
+ }),
954
+ name: {
955
+ formatted: "the-username",
956
+ },
957
+ schemas: expect.arrayContaining([
958
+ "urn:ietf:params:scim:schemas:core:2.0:User",
959
+ ]),
960
+ userName: "the-username",
961
+ });
962
+ });
963
+
964
+ it("should create a new user with name parts", async () => {
965
+ const { auth, getSCIMToken } = createTestInstance();
966
+ const scimToken = await getSCIMToken();
967
+
968
+ const user = await auth.api.createSCIMUser({
969
+ body: {
970
+ userName: "the-username",
971
+ name: {
972
+ givenName: "Juan",
973
+ familyName: "Perez",
974
+ },
975
+ },
976
+ headers: {
977
+ authorization: `Bearer ${scimToken}`,
978
+ },
979
+ });
980
+
981
+ expect(user).toMatchObject({
982
+ active: true,
983
+ displayName: "Juan Perez",
984
+ emails: [
985
+ {
986
+ primary: true,
987
+ value: "the-username",
988
+ },
989
+ ],
990
+ externalId: "the-username",
991
+ id: expect.any(String),
992
+ meta: expect.objectContaining({
993
+ created: expect.any(Date),
994
+ lastModified: expect.any(Date),
995
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
996
+ resourceType: "User",
997
+ }),
998
+ name: {
999
+ formatted: "Juan Perez",
1000
+ },
1001
+ schemas: expect.arrayContaining([
1002
+ "urn:ietf:params:scim:schemas:core:2.0:User",
1003
+ ]),
1004
+ userName: "the-username",
1005
+ });
1006
+ });
1007
+
1008
+ it("should create a new user with formatted name", async () => {
1009
+ const { auth, getSCIMToken } = createTestInstance();
1010
+ const scimToken = await getSCIMToken();
1011
+
1012
+ const user = await auth.api.createSCIMUser({
1013
+ body: {
1014
+ userName: "the-username",
1015
+ name: {
1016
+ formatted: "Juan Perez",
1017
+ },
1018
+ },
1019
+ headers: {
1020
+ authorization: `Bearer ${scimToken}`,
1021
+ },
1022
+ });
1023
+
1024
+ expect(user).toMatchObject({
1025
+ active: true,
1026
+ displayName: "Juan Perez",
1027
+ emails: [
1028
+ {
1029
+ primary: true,
1030
+ value: "the-username",
1031
+ },
1032
+ ],
1033
+ externalId: "the-username",
1034
+ id: expect.any(String),
1035
+ meta: expect.objectContaining({
1036
+ created: expect.any(Date),
1037
+ lastModified: expect.any(Date),
1038
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
1039
+ resourceType: "User",
1040
+ }),
1041
+ name: {
1042
+ formatted: "Juan Perez",
1043
+ },
1044
+ schemas: expect.arrayContaining([
1045
+ "urn:ietf:params:scim:schemas:core:2.0:User",
1046
+ ]),
1047
+ userName: "the-username",
1048
+ });
1049
+ });
1050
+
1051
+ it("should create a new user with a primary email", async () => {
1052
+ const { auth, getSCIMToken } = createTestInstance();
1053
+ const scimToken = await getSCIMToken();
1054
+
1055
+ const user = await auth.api.createSCIMUser({
1056
+ body: {
1057
+ userName: "the-username",
1058
+ name: {
1059
+ formatted: "Juan Perez",
1060
+ },
1061
+ emails: [
1062
+ { value: "secondary-email@test.com" },
1063
+ { value: "primary-email@test.com", primary: true },
1064
+ ],
1065
+ },
1066
+ headers: {
1067
+ authorization: `Bearer ${scimToken}`,
1068
+ },
1069
+ });
1070
+
1071
+ expect(user).toMatchObject({
1072
+ active: true,
1073
+ displayName: "Juan Perez",
1074
+ emails: [
1075
+ {
1076
+ primary: true,
1077
+ value: "primary-email@test.com",
1078
+ },
1079
+ ],
1080
+ externalId: "the-username",
1081
+ id: expect.any(String),
1082
+ meta: expect.objectContaining({
1083
+ created: expect.any(Date),
1084
+ lastModified: expect.any(Date),
1085
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
1086
+ resourceType: "User",
1087
+ }),
1088
+ name: {
1089
+ formatted: "Juan Perez",
1090
+ },
1091
+ schemas: expect.arrayContaining([
1092
+ "urn:ietf:params:scim:schemas:core:2.0:User",
1093
+ ]),
1094
+ userName: "primary-email@test.com",
1095
+ });
1096
+ });
1097
+
1098
+ it("should create a new user with the first non-primary email", async () => {
1099
+ const { auth, getSCIMToken } = createTestInstance();
1100
+ const scimToken = await getSCIMToken();
1101
+
1102
+ const user = await auth.api.createSCIMUser({
1103
+ body: {
1104
+ userName: "the-username",
1105
+ name: {
1106
+ formatted: "Juan Perez",
1107
+ },
1108
+ emails: [
1109
+ { value: "secondary-email@test.com" },
1110
+ { value: "primary-email@test.com" },
1111
+ ],
1112
+ },
1113
+ headers: {
1114
+ authorization: `Bearer ${scimToken}`,
1115
+ },
1116
+ });
1117
+
1118
+ expect(user).toMatchObject({
1119
+ active: true,
1120
+ displayName: "Juan Perez",
1121
+ emails: [
1122
+ {
1123
+ primary: true,
1124
+ value: "secondary-email@test.com",
1125
+ },
1126
+ ],
1127
+ externalId: "the-username",
1128
+ id: expect.any(String),
1129
+ meta: expect.objectContaining({
1130
+ created: expect.any(Date),
1131
+ lastModified: expect.any(Date),
1132
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
1133
+ resourceType: "User",
1134
+ }),
1135
+ name: {
1136
+ formatted: "Juan Perez",
1137
+ },
1138
+ schemas: expect.arrayContaining([
1139
+ "urn:ietf:params:scim:schemas:core:2.0:User",
1140
+ ]),
1141
+ userName: "secondary-email@test.com",
1142
+ });
1143
+ });
1144
+
1145
+ it("should not allow users with the same computed username", async () => {
1146
+ const { auth, getSCIMToken } = createTestInstance();
1147
+ const scimToken = await getSCIMToken();
1148
+
1149
+ const createUser = async () => {
1150
+ await auth.api.createSCIMUser({
1151
+ body: {
1152
+ userName: "the-username",
1153
+ },
1154
+ headers: {
1155
+ authorization: `Bearer ${scimToken}`,
1156
+ },
1157
+ });
1158
+ };
1159
+
1160
+ await createUser();
1161
+ await expect(createUser()).rejects.toThrow(/User already exists/);
1162
+ });
1163
+
1164
+ it("should not allow anonymous access", async () => {
1165
+ const { auth } = createTestInstance();
1166
+
1167
+ const createUser = async () => {
1168
+ await auth.api.createSCIMUser({
1169
+ body: {
1170
+ userName: "the-username",
1171
+ },
1172
+ });
1173
+ };
1174
+
1175
+ await expect(createUser()).rejects.toThrowError(
1176
+ expect.objectContaining({
1177
+ message: "SCIM token is required",
1178
+ body: {
1179
+ detail: "SCIM token is required",
1180
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
1181
+ status: "401",
1182
+ },
1183
+ }),
1184
+ );
1185
+ });
1186
+ });
1187
+
1188
+ describe("PUT /scim/v2/Users", () => {
1189
+ it("should update an existing resource", async () => {
1190
+ const { auth, getSCIMToken } = createTestInstance();
1191
+ const scimToken = await getSCIMToken();
1192
+
1193
+ const user = await auth.api.createSCIMUser({
1194
+ body: {
1195
+ userName: "the-username",
1196
+ name: {
1197
+ formatted: "Juan Perez",
1198
+ },
1199
+ emails: [{ value: "primary-email@test.com", primary: true }],
1200
+ },
1201
+ headers: {
1202
+ authorization: `Bearer ${scimToken}`,
1203
+ },
1204
+ });
1205
+
1206
+ expect(user).toBeTruthy();
1207
+ expect(user.externalId).toBe("the-username");
1208
+ expect(user.userName).toBe("primary-email@test.com");
1209
+ expect(user.name.formatted).toBe("Juan Perez");
1210
+ expect(user.emails[0]?.value).toBe("primary-email@test.com");
1211
+
1212
+ const updatedUser = await auth.api.updateSCIMUser({
1213
+ params: {
1214
+ userId: user.id,
1215
+ },
1216
+ body: {
1217
+ userName: "other-username",
1218
+ externalId: "external-username",
1219
+ name: {
1220
+ formatted: "Daniel Lopez",
1221
+ },
1222
+ emails: [{ value: "other-email@test.com" }],
1223
+ },
1224
+ headers: {
1225
+ authorization: `Bearer ${scimToken}`,
1226
+ },
1227
+ });
1228
+
1229
+ expect(updatedUser).toMatchObject({
1230
+ active: true,
1231
+ displayName: "Daniel Lopez",
1232
+ emails: [
1233
+ {
1234
+ primary: true,
1235
+ value: "other-email@test.com",
1236
+ },
1237
+ ],
1238
+ externalId: "external-username",
1239
+ id: expect.any(String),
1240
+ meta: expect.objectContaining({
1241
+ created: expect.any(Date),
1242
+ lastModified: expect.any(Date),
1243
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
1244
+ resourceType: "User",
1245
+ }),
1246
+ name: {
1247
+ formatted: "Daniel Lopez",
1248
+ },
1249
+ schemas: expect.arrayContaining([
1250
+ "urn:ietf:params:scim:schemas:core:2.0:User",
1251
+ ]),
1252
+ userName: "other-email@test.com",
1253
+ });
1254
+ });
1255
+
1256
+ it("should not allow anonymous access", async () => {
1257
+ const { auth } = createTestInstance();
1258
+
1259
+ const updateUser = async () => {
1260
+ await auth.api.updateSCIMUser({
1261
+ params: {
1262
+ userId: "whatever",
1263
+ },
1264
+ body: {
1265
+ userName: "the-username",
1266
+ },
1267
+ });
1268
+ };
1269
+
1270
+ await expect(updateUser()).rejects.toThrowError(
1271
+ expect.objectContaining({
1272
+ message: "SCIM token is required",
1273
+ body: {
1274
+ detail: "SCIM token is required",
1275
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
1276
+ status: "401",
1277
+ },
1278
+ }),
1279
+ );
1280
+ });
1281
+
1282
+ it("should return not found for missing resources", async () => {
1283
+ const { auth, getSCIMToken } = createTestInstance();
1284
+ const scimToken = await getSCIMToken();
1285
+
1286
+ const updateUser = () =>
1287
+ auth.api.updateSCIMUser({
1288
+ params: {
1289
+ userId: "missing",
1290
+ },
1291
+ body: {
1292
+ userName: "other-username",
1293
+ },
1294
+ headers: {
1295
+ authorization: `Bearer ${scimToken}`,
1296
+ },
1297
+ });
1298
+
1299
+ await expect(updateUser()).rejects.toThrowError(
1300
+ expect.objectContaining({
1301
+ message: "User not found",
1302
+ body: {
1303
+ detail: "User not found",
1304
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
1305
+ status: "404",
1306
+ },
1307
+ }),
1308
+ );
1309
+ });
1310
+ });
1311
+
1312
+ describe("PATCH /scim/v2/users", () => {
1313
+ it.each([
1314
+ "replace",
1315
+ "add",
1316
+ ])("should partially update a user resource with %s", async (op) => {
1317
+ const { auth, getSCIMToken } = createTestInstance();
1318
+ const scimToken = await getSCIMToken();
1319
+
1320
+ const user = await auth.api.createSCIMUser({
1321
+ body: {
1322
+ userName: "the-username",
1323
+ name: {
1324
+ formatted: "Juan Perez",
1325
+ },
1326
+ emails: [{ value: "primary-email@test.com", primary: true }],
1327
+ },
1328
+ headers: {
1329
+ authorization: `Bearer ${scimToken}`,
1330
+ },
1331
+ });
1332
+
1333
+ expect(user).toBeTruthy();
1334
+ expect(user.externalId).toBe("the-username");
1335
+ expect(user.userName).toBe("primary-email@test.com");
1336
+ expect(user.name.formatted).toBe("Juan Perez");
1337
+ expect(user.emails[0]?.value).toBe("primary-email@test.com");
1338
+
1339
+ await auth.api.patchSCIMUser({
1340
+ params: {
1341
+ userId: user.id,
1342
+ },
1343
+ body: {
1344
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1345
+ Operations: [
1346
+ { op: op, path: "/externalId", value: "external-username" },
1347
+ { op: op, path: "/userName", value: "other-username" },
1348
+ { op: op, path: "/name/givenName", value: "Daniel" },
1349
+ ],
1350
+ },
1351
+ headers: {
1352
+ authorization: `Bearer ${scimToken}`,
1353
+ },
1354
+ });
1355
+
1356
+ const updatedUser = await auth.api.getSCIMUser({
1357
+ params: {
1358
+ userId: user.id,
1359
+ },
1360
+ headers: {
1361
+ authorization: `Bearer ${scimToken}`,
1362
+ },
1363
+ });
1364
+
1365
+ expect(updatedUser).toMatchObject({
1366
+ active: true,
1367
+ displayName: "Daniel Perez",
1368
+ emails: [
1369
+ {
1370
+ primary: true,
1371
+ value: "other-username",
1372
+ },
1373
+ ],
1374
+ externalId: "external-username",
1375
+ id: expect.any(String),
1376
+ meta: expect.objectContaining({
1377
+ created: expect.any(Date),
1378
+ lastModified: expect.any(Date),
1379
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
1380
+ resourceType: "User",
1381
+ }),
1382
+ name: {
1383
+ formatted: "Daniel Perez",
1384
+ },
1385
+ schemas: expect.arrayContaining([
1386
+ "urn:ietf:params:scim:schemas:core:2.0:User",
1387
+ ]),
1388
+ userName: "other-username",
1389
+ });
1390
+ });
1391
+
1392
+ it("should partially update a user resource with mixed operations", async () => {
1393
+ const { auth, getSCIMToken } = createTestInstance();
1394
+ const scimToken = await getSCIMToken();
1395
+
1396
+ const user = await auth.api.createSCIMUser({
1397
+ body: {
1398
+ userName: "the-username",
1399
+ name: {
1400
+ formatted: "Juan Perez",
1401
+ },
1402
+ emails: [{ value: "primary-email@test.com", primary: true }],
1403
+ },
1404
+ headers: {
1405
+ authorization: `Bearer ${scimToken}`,
1406
+ },
1407
+ });
1408
+
1409
+ expect(user).toBeTruthy();
1410
+ expect(user.externalId).toBe("the-username");
1411
+ expect(user.userName).toBe("primary-email@test.com");
1412
+ expect(user.name.formatted).toBe("Juan Perez");
1413
+ expect(user.emails[0]?.value).toBe("primary-email@test.com");
1414
+
1415
+ await auth.api.patchSCIMUser({
1416
+ params: {
1417
+ userId: user.id,
1418
+ },
1419
+ body: {
1420
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1421
+ Operations: [
1422
+ { op: "add", path: "/externalId", value: "external-username" },
1423
+ { op: "replace", path: "/userName", value: "other-username" },
1424
+ { op: "add", path: "/name/formatted", value: "Daniel Lopez" },
1425
+ ],
1426
+ },
1427
+ headers: {
1428
+ authorization: `Bearer ${scimToken}`,
1429
+ },
1430
+ });
1431
+
1432
+ const updatedUser = await auth.api.getSCIMUser({
1433
+ params: {
1434
+ userId: user.id,
1435
+ },
1436
+ headers: {
1437
+ authorization: `Bearer ${scimToken}`,
1438
+ },
1439
+ });
1440
+
1441
+ expect(updatedUser).toMatchObject({
1442
+ active: true,
1443
+ displayName: "Daniel Lopez",
1444
+ emails: [
1445
+ {
1446
+ primary: true,
1447
+ value: "other-username",
1448
+ },
1449
+ ],
1450
+ externalId: "external-username",
1451
+ id: expect.any(String),
1452
+ meta: expect.objectContaining({
1453
+ created: expect.any(Date),
1454
+ lastModified: expect.any(Date),
1455
+ location: expect.stringContaining("/api/auth/scim/v2/Users/"),
1456
+ resourceType: "User",
1457
+ }),
1458
+ name: {
1459
+ formatted: "Daniel Lopez",
1460
+ },
1461
+ schemas: expect.arrayContaining([
1462
+ "urn:ietf:params:scim:schemas:core:2.0:User",
1463
+ ]),
1464
+ userName: "other-username",
1465
+ });
1466
+ });
1467
+
1468
+ it.each([
1469
+ "replace",
1470
+ "add",
1471
+ ])("should partially update multiple name sub-attributes with %s", async (op) => {
1472
+ const { auth, getSCIMToken } = createTestInstance();
1473
+ const scimToken = await getSCIMToken();
1474
+
1475
+ const user = await auth.api.createSCIMUser({
1476
+ body: {
1477
+ userName: "sub-attribute-test-user",
1478
+ name: {
1479
+ formatted: "Original Name",
1480
+ },
1481
+ },
1482
+ headers: {
1483
+ authorization: `Bearer ${scimToken}`,
1484
+ },
1485
+ });
1486
+
1487
+ await auth.api.patchSCIMUser({
1488
+ params: { userId: user.id },
1489
+ body: {
1490
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1491
+ Operations: [
1492
+ { op: op, path: "/name/givenName", value: "Updated" },
1493
+ { op: op, path: "/name/familyName", value: "Value" },
1494
+ ],
1495
+ },
1496
+ headers: {
1497
+ authorization: `Bearer ${scimToken}`,
1498
+ },
1499
+ });
1500
+
1501
+ const updatedUser = await auth.api.getSCIMUser({
1502
+ params: { userId: user.id },
1503
+ headers: {
1504
+ authorization: `Bearer ${scimToken}`,
1505
+ },
1506
+ });
1507
+
1508
+ expect(updatedUser.name.formatted).toBe("Updated Value");
1509
+ });
1510
+
1511
+ it.each([
1512
+ "replace",
1513
+ "add",
1514
+ ])("should %s nested object values with path prefix", async (op) => {
1515
+ const { auth, getSCIMToken } = createTestInstance();
1516
+ const scimToken = await getSCIMToken();
1517
+
1518
+ const user = await auth.api.createSCIMUser({
1519
+ body: {
1520
+ userName: "nested-test-user",
1521
+ name: { formatted: "Original Name" },
1522
+ },
1523
+ headers: {
1524
+ authorization: `Bearer ${scimToken}`,
1525
+ },
1526
+ });
1527
+
1528
+ await auth.api.patchSCIMUser({
1529
+ params: { userId: user.id },
1530
+ body: {
1531
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1532
+ Operations: [
1533
+ {
1534
+ op: op,
1535
+ path: "name",
1536
+ value: { givenName: "Nested" },
1537
+ },
1538
+ {
1539
+ op: op,
1540
+ path: "name",
1541
+ value: { familyName: "User" },
1542
+ },
1543
+ {
1544
+ op: op,
1545
+ path: "userName",
1546
+ value: "nested-test-user-updated",
1547
+ },
1548
+ ],
1549
+ },
1550
+ headers: {
1551
+ authorization: `Bearer ${scimToken}`,
1552
+ },
1553
+ });
1554
+
1555
+ const updatedUser = await auth.api.getSCIMUser({
1556
+ params: { userId: user.id },
1557
+ headers: {
1558
+ authorization: `Bearer ${scimToken}`,
1559
+ },
1560
+ });
1561
+
1562
+ expect(updatedUser.name.formatted).toBe("Nested User");
1563
+ expect(updatedUser.displayName).toBe("Nested User");
1564
+ expect(updatedUser.userName).toBe("nested-test-user-updated");
1565
+ });
1566
+
1567
+ it.each([
1568
+ "replace",
1569
+ "add",
1570
+ ])("should support operations without explicit path with %s", async (op) => {
1571
+ const { auth, getSCIMToken } = createTestInstance();
1572
+ const scimToken = await getSCIMToken();
1573
+
1574
+ const user = await auth.api.createSCIMUser({
1575
+ body: {
1576
+ userName: "no-path-test-user",
1577
+ },
1578
+ headers: {
1579
+ authorization: `Bearer ${scimToken}`,
1580
+ },
1581
+ });
1582
+
1583
+ await auth.api.patchSCIMUser({
1584
+ params: { userId: user.id },
1585
+ body: {
1586
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1587
+ Operations: [
1588
+ {
1589
+ op: op,
1590
+ value: {
1591
+ name: { formatted: "No Path Name" },
1592
+ userName: "Username",
1593
+ },
1594
+ },
1595
+ ],
1596
+ },
1597
+ headers: {
1598
+ authorization: `Bearer ${scimToken}`,
1599
+ },
1600
+ });
1601
+
1602
+ const updatedUser = await auth.api.getSCIMUser({
1603
+ params: { userId: user.id },
1604
+ headers: {
1605
+ authorization: `Bearer ${scimToken}`,
1606
+ },
1607
+ });
1608
+
1609
+ expect(updatedUser.name.formatted).toBe("No Path Name");
1610
+ expect(updatedUser.userName).toBe("username");
1611
+ });
1612
+
1613
+ it("should support dot notation in paths", async () => {
1614
+ const { auth, getSCIMToken } = createTestInstance();
1615
+ const scimToken = await getSCIMToken();
1616
+
1617
+ const user = await auth.api.createSCIMUser({
1618
+ body: {
1619
+ userName: "dot-notation-user",
1620
+ name: { formatted: "Original Name" },
1621
+ },
1622
+ headers: {
1623
+ authorization: `Bearer ${scimToken}`,
1624
+ },
1625
+ });
1626
+
1627
+ await auth.api.patchSCIMUser({
1628
+ params: { userId: user.id },
1629
+ body: {
1630
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1631
+ Operations: [
1632
+ { op: "replace", path: "name.familyName", value: "Dot" },
1633
+ { op: "add", path: "name.givenName", value: "User" },
1634
+ { op: "add", path: "userName", value: "Username" },
1635
+ ],
1636
+ },
1637
+ headers: {
1638
+ authorization: `Bearer ${scimToken}`,
1639
+ },
1640
+ });
1641
+
1642
+ const updatedUser = await auth.api.getSCIMUser({
1643
+ params: { userId: user.id },
1644
+ headers: {
1645
+ authorization: `Bearer ${scimToken}`,
1646
+ },
1647
+ });
1648
+
1649
+ expect(updatedUser.name.formatted).toBe("User Dot");
1650
+ expect(updatedUser.userName).toBe("username");
1651
+ });
1652
+
1653
+ it.each([
1654
+ "replace",
1655
+ "add",
1656
+ ])("should handle %s operation case-insensitively", async (op) => {
1657
+ const { auth, getSCIMToken } = createTestInstance();
1658
+ const scimToken = await getSCIMToken();
1659
+
1660
+ const user = await auth.api.createSCIMUser({
1661
+ body: {
1662
+ userName: "user-case-insensitive",
1663
+ name: { formatted: "Original" },
1664
+ },
1665
+ headers: {
1666
+ authorization: `Bearer ${scimToken}`,
1667
+ },
1668
+ });
1669
+
1670
+ await auth.api.patchSCIMUser({
1671
+ params: { userId: user.id },
1672
+ body: {
1673
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1674
+ Operations: [
1675
+ {
1676
+ op: op.toUpperCase(),
1677
+ path: "name.formatted",
1678
+ value: "user-case",
1679
+ },
1680
+ ],
1681
+ },
1682
+ headers: {
1683
+ authorization: `Bearer ${scimToken}`,
1684
+ },
1685
+ });
1686
+
1687
+ const updatedUser = await auth.api.getSCIMUser({
1688
+ params: { userId: user.id },
1689
+ headers: {
1690
+ authorization: `Bearer ${scimToken}`,
1691
+ },
1692
+ });
1693
+
1694
+ expect(updatedUser.name.formatted).toBe("user-case");
1695
+ });
1696
+
1697
+ it("should skip add operation when value already exists", async () => {
1698
+ const { auth, getSCIMToken } = createTestInstance();
1699
+ const scimToken = await getSCIMToken();
1700
+
1701
+ const user = await auth.api.createSCIMUser({
1702
+ body: {
1703
+ userName: "add-same-info-user",
1704
+ name: { formatted: "Existing Name" },
1705
+ },
1706
+ headers: {
1707
+ authorization: `Bearer ${scimToken}`,
1708
+ },
1709
+ });
1710
+
1711
+ const patchUser = () =>
1712
+ auth.api.patchSCIMUser({
1713
+ params: { userId: user.id },
1714
+ body: {
1715
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1716
+ Operations: [
1717
+ { op: "add", path: "/name/formatted", value: "Existing Name" },
1718
+ ],
1719
+ },
1720
+ headers: {
1721
+ authorization: `Bearer ${scimToken}`,
1722
+ },
1723
+ });
1724
+
1725
+ await expect(patchUser()).rejects.toThrowError(
1726
+ expect.objectContaining({
1727
+ message: "No valid fields to update",
1728
+ body: {
1729
+ detail: "No valid fields to update",
1730
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
1731
+ status: "400",
1732
+ },
1733
+ }),
1734
+ );
1735
+ });
1736
+
1737
+ it.each([
1738
+ "replace",
1739
+ "add",
1740
+ ])("should ignore %s on non-existing path", async (op) => {
1741
+ const { auth, getSCIMToken } = createTestInstance();
1742
+ const scimToken = await getSCIMToken();
1743
+
1744
+ const user = await auth.api.createSCIMUser({
1745
+ body: {
1746
+ userName: "non-existing-path",
1747
+ name: { formatted: "Original Name" },
1748
+ },
1749
+ headers: {
1750
+ authorization: `Bearer ${scimToken}`,
1751
+ },
1752
+ });
1753
+
1754
+ const patchUser = () =>
1755
+ auth.api.patchSCIMUser({
1756
+ params: { userId: user.id },
1757
+ body: {
1758
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1759
+ Operations: [
1760
+ { op: op, path: "/nonExistentField", value: "Some Value" },
1761
+ ],
1762
+ },
1763
+ headers: {
1764
+ authorization: `Bearer ${scimToken}`,
1765
+ },
1766
+ });
1767
+
1768
+ await expect(patchUser()).rejects.toThrowError(
1769
+ expect.objectContaining({
1770
+ message: "No valid fields to update",
1771
+ body: {
1772
+ detail: "No valid fields to update",
1773
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
1774
+ status: "400",
1775
+ },
1776
+ }),
1777
+ );
1778
+ });
1779
+
1780
+ it("should ignore non-existing operation", async () => {
1781
+ const { auth, getSCIMToken } = createTestInstance();
1782
+ const scimToken = await getSCIMToken();
1783
+
1784
+ const user = await auth.api.createSCIMUser({
1785
+ body: {
1786
+ userName: "non-existing-operation",
1787
+ },
1788
+ headers: {
1789
+ authorization: `Bearer ${scimToken}`,
1790
+ },
1791
+ });
1792
+
1793
+ const patchUser = () =>
1794
+ auth.api.patchSCIMUser({
1795
+ params: { userId: user.id },
1796
+ body: {
1797
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1798
+ Operations: [
1799
+ { op: "update", path: "userName", value: "Some Value" },
1800
+ ],
1801
+ },
1802
+ headers: {
1803
+ authorization: `Bearer ${scimToken}`,
1804
+ },
1805
+ });
1806
+
1807
+ await expect(patchUser()).rejects.toThrowError(
1808
+ expect.objectContaining({
1809
+ body: {
1810
+ code: "VALIDATION_ERROR",
1811
+ message:
1812
+ '[body.Operations.0.op] Invalid option: expected one of "replace"|"add"|"remove"',
1813
+ },
1814
+ }),
1815
+ );
1816
+ });
1817
+
1818
+ it("should return not found for missing users", async () => {
1819
+ const { auth, getSCIMToken } = createTestInstance();
1820
+ const scimToken = await getSCIMToken();
1821
+
1822
+ const patchUser = () =>
1823
+ auth.api.patchSCIMUser({
1824
+ params: {
1825
+ userId: "missing",
1826
+ },
1827
+ body: {
1828
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1829
+ Operations: [
1830
+ {
1831
+ op: "replace",
1832
+ path: "/externalId",
1833
+ value: "external-username",
1834
+ },
1835
+ ],
1836
+ },
1837
+ headers: {
1838
+ authorization: `Bearer ${scimToken}`,
1839
+ },
1840
+ });
1841
+
1842
+ await expect(patchUser()).rejects.toThrowError(
1843
+ expect.objectContaining({
1844
+ message: "User not found",
1845
+ body: {
1846
+ detail: "User not found",
1847
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
1848
+ status: "404",
1849
+ },
1850
+ }),
1851
+ );
1852
+ });
1853
+
1854
+ it("should fail on invalid updates", async () => {
1855
+ const { auth, getSCIMToken } = createTestInstance();
1856
+ const scimToken = await getSCIMToken();
1857
+
1858
+ const user = await auth.api.createSCIMUser({
1859
+ body: {
1860
+ userName: "the-username",
1861
+ },
1862
+ headers: {
1863
+ authorization: `Bearer ${scimToken}`,
1864
+ },
1865
+ });
1866
+
1867
+ const patchUser = () =>
1868
+ auth.api.patchSCIMUser({
1869
+ params: {
1870
+ userId: user.id,
1871
+ },
1872
+ body: {
1873
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1874
+ Operations: [],
1875
+ },
1876
+ headers: {
1877
+ authorization: `Bearer ${scimToken}`,
1878
+ },
1879
+ });
1880
+
1881
+ await expect(patchUser()).rejects.toThrowError(
1882
+ expect.objectContaining({
1883
+ message: "No valid fields to update",
1884
+ body: {
1885
+ detail: "No valid fields to update",
1886
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
1887
+ status: "400",
1888
+ },
1889
+ }),
1890
+ );
1891
+ });
1892
+
1893
+ it("should not allow anonymous access", async () => {
1894
+ const { auth } = createTestInstance();
1895
+
1896
+ const patchUser = async () => {
1897
+ await auth.api.patchSCIMUser({
1898
+ params: {
1899
+ userId: "missing",
1900
+ },
1901
+ body: {
1902
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1903
+ Operations: [
1904
+ {
1905
+ op: "replace",
1906
+ path: "/externalId",
1907
+ value: "external-username",
1908
+ },
1909
+ ],
1910
+ },
1911
+ });
1912
+ };
1913
+
1914
+ await expect(patchUser()).rejects.toThrowError(
1915
+ expect.objectContaining({
1916
+ message: "SCIM token is required",
1917
+ body: {
1918
+ detail: "SCIM token is required",
1919
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
1920
+ status: "401",
1921
+ },
1922
+ }),
1923
+ );
1924
+ });
1925
+ });
1926
+
1927
+ describe("GET /scim/v2/Users", () => {
1928
+ it("should return the list of users", async () => {
1929
+ const { auth, getSCIMToken } = createTestInstance();
1930
+ const scimToken = await getSCIMToken();
1931
+
1932
+ const createUser = (userName: string) => {
1933
+ return auth.api.createSCIMUser({
1934
+ body: {
1935
+ userName,
1936
+ },
1937
+ headers: {
1938
+ authorization: `Bearer ${scimToken}`,
1939
+ },
1940
+ });
1941
+ };
1942
+
1943
+ const [userA, userB] = await Promise.all([
1944
+ createUser("user-a"),
1945
+ createUser("user-b"),
1946
+ ]);
1947
+
1948
+ const users = await auth.api.listSCIMUsers({
1949
+ headers: {
1950
+ authorization: `Bearer ${scimToken}`,
1951
+ },
1952
+ });
1953
+
1954
+ expect(users).toMatchObject({
1955
+ itemsPerPage: 2,
1956
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
1957
+ startIndex: 1,
1958
+ totalResults: 2,
1959
+ Resources: [userA, userB],
1960
+ });
1961
+ });
1962
+
1963
+ it("should only allow access to users that belong to the same provider", async () => {
1964
+ const { auth, getSCIMToken } = createTestInstance();
1965
+ const [scimTokenProviderA, scimTokenProviderB] = await Promise.all([
1966
+ getSCIMToken("provider-a"),
1967
+ getSCIMToken("provider-b"),
1968
+ ]);
1969
+
1970
+ const createUser = (userName: string, scimToken: string) => {
1971
+ return auth.api.createSCIMUser({
1972
+ body: {
1973
+ userName,
1974
+ },
1975
+ headers: {
1976
+ authorization: `Bearer ${scimToken}`,
1977
+ },
1978
+ });
1979
+ };
1980
+
1981
+ const listUsers = (scimToken: string) => {
1982
+ return auth.api.listSCIMUsers({
1983
+ headers: {
1984
+ authorization: `Bearer ${scimToken}`,
1985
+ },
1986
+ });
1987
+ };
1988
+
1989
+ const [userA, userB, userC] = await Promise.all([
1990
+ createUser("user-a", scimTokenProviderB),
1991
+ createUser("user-b", scimTokenProviderA),
1992
+ createUser("user-c", scimTokenProviderB),
1993
+ ]);
1994
+
1995
+ const [usersProviderA, usersProviderB] = await Promise.all([
1996
+ listUsers(scimTokenProviderA),
1997
+ listUsers(scimTokenProviderB),
1998
+ ]);
1999
+
2000
+ expect(usersProviderA).toMatchObject({
2001
+ itemsPerPage: 1,
2002
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
2003
+ startIndex: 1,
2004
+ totalResults: 1,
2005
+ Resources: [userB],
2006
+ });
2007
+
2008
+ expect(usersProviderB).toMatchObject({
2009
+ itemsPerPage: 2,
2010
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
2011
+ startIndex: 1,
2012
+ totalResults: 2,
2013
+ Resources: [userA, userC],
2014
+ });
2015
+ });
2016
+
2017
+ it("should only allow access to users that belong to the same provider and organization", async () => {
2018
+ const { auth, getSCIMToken, registerOrganization } = createTestInstance();
2019
+ const [organizationA, organizationB] = await Promise.all([
2020
+ registerOrganization("org:a"),
2021
+ registerOrganization("org:b"),
2022
+ ]);
2023
+
2024
+ const [scimTokenProviderA, scimTokenProviderB] = await Promise.all([
2025
+ getSCIMToken("provider-a", organizationA?.id),
2026
+ getSCIMToken("provider-b", organizationB?.id),
2027
+ ]);
2028
+
2029
+ const createUser = (userName: string, scimToken: string) => {
2030
+ return auth.api.createSCIMUser({
2031
+ body: {
2032
+ userName,
2033
+ },
2034
+ headers: {
2035
+ authorization: `Bearer ${scimToken}`,
2036
+ },
2037
+ });
2038
+ };
2039
+
2040
+ const listUsers = (scimToken: string) => {
2041
+ return auth.api.listSCIMUsers({
2042
+ headers: {
2043
+ authorization: `Bearer ${scimToken}`,
2044
+ },
2045
+ });
2046
+ };
2047
+
2048
+ const [userA, userB, userC] = await Promise.all([
2049
+ createUser("user-a", scimTokenProviderB),
2050
+ createUser("user-b", scimTokenProviderA),
2051
+ createUser("user-c", scimTokenProviderB),
2052
+ ]);
2053
+
2054
+ const [usersProviderA, usersProviderB] = await Promise.all([
2055
+ listUsers(scimTokenProviderA),
2056
+ listUsers(scimTokenProviderB),
2057
+ ]);
2058
+
2059
+ expect(usersProviderA).toMatchObject({
2060
+ itemsPerPage: 1,
2061
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
2062
+ startIndex: 1,
2063
+ totalResults: 1,
2064
+ Resources: [userB],
2065
+ });
2066
+
2067
+ expect(usersProviderB).toMatchObject({
2068
+ itemsPerPage: 2,
2069
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
2070
+ startIndex: 1,
2071
+ totalResults: 2,
2072
+ Resources: [userA, userC],
2073
+ });
2074
+ }, 10000);
2075
+
2076
+ it("should filter the list of users", async () => {
2077
+ const { auth, getSCIMToken } = createTestInstance();
2078
+ const scimToken = await getSCIMToken();
2079
+
2080
+ const createUser = (userName: string) => {
2081
+ return auth.api.createSCIMUser({
2082
+ body: {
2083
+ userName,
2084
+ },
2085
+ headers: {
2086
+ authorization: `Bearer ${scimToken}`,
2087
+ },
2088
+ });
2089
+ };
2090
+
2091
+ const [userA] = await Promise.all([
2092
+ createUser("user-a"),
2093
+ createUser("user-b"),
2094
+ createUser("user-c"),
2095
+ ]);
2096
+
2097
+ const users = await auth.api.listSCIMUsers({
2098
+ query: {
2099
+ filter: 'userName eq "user-A"',
2100
+ },
2101
+ headers: {
2102
+ authorization: `Bearer ${scimToken}`,
2103
+ },
2104
+ });
2105
+
2106
+ expect(users).toMatchObject({
2107
+ itemsPerPage: 1,
2108
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
2109
+ startIndex: 1,
2110
+ totalResults: 1,
2111
+ Resources: [userA],
2112
+ });
2113
+ });
2114
+
2115
+ it("should not allow anonymous access", async () => {
2116
+ const { auth } = createTestInstance();
2117
+
2118
+ const getUsers = async () => {
2119
+ await auth.api.listSCIMUsers();
2120
+ };
2121
+
2122
+ await expect(getUsers()).rejects.toThrowError(
2123
+ expect.objectContaining({
2124
+ message: "SCIM token is required",
2125
+ body: {
2126
+ detail: "SCIM token is required",
2127
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2128
+ status: "401",
2129
+ },
2130
+ }),
2131
+ );
2132
+ });
2133
+ });
2134
+
2135
+ describe("GET /scim/v2/Users/:userId", () => {
2136
+ it("should return a single user resource", async () => {
2137
+ const { auth, getSCIMToken } = createTestInstance();
2138
+ const scimToken = await getSCIMToken();
2139
+
2140
+ const newUser = await auth.api.createSCIMUser({
2141
+ body: {
2142
+ userName: "the-username",
2143
+ },
2144
+ headers: {
2145
+ authorization: `Bearer ${scimToken}`,
2146
+ },
2147
+ });
2148
+
2149
+ const retrievedUser = await auth.api.getSCIMUser({
2150
+ params: {
2151
+ userId: newUser.id,
2152
+ },
2153
+ headers: {
2154
+ authorization: `Bearer ${scimToken}`,
2155
+ },
2156
+ });
2157
+
2158
+ expect(newUser).toEqual(retrievedUser);
2159
+ });
2160
+
2161
+ it("should only allow access to users that belong to the same provider", async () => {
2162
+ const { auth, getSCIMToken } = createTestInstance();
2163
+
2164
+ const [scimTokenProviderA, scimTokenProviderB] = await Promise.all([
2165
+ getSCIMToken("provider-a"),
2166
+ getSCIMToken("provider-b"),
2167
+ ]);
2168
+
2169
+ const createUser = (userName: string, scimToken: string) => {
2170
+ return auth.api.createSCIMUser({
2171
+ body: {
2172
+ userName,
2173
+ },
2174
+ headers: {
2175
+ authorization: `Bearer ${scimToken}`,
2176
+ },
2177
+ });
2178
+ };
2179
+
2180
+ const getUser = (userId: string, scimToken: string) => {
2181
+ return auth.api.getSCIMUser({
2182
+ params: {
2183
+ userId,
2184
+ },
2185
+ headers: {
2186
+ authorization: `Bearer ${scimToken}`,
2187
+ },
2188
+ });
2189
+ };
2190
+
2191
+ const [userA, userB] = await Promise.all([
2192
+ createUser("user-a", scimTokenProviderB),
2193
+ createUser("user-b", scimTokenProviderA),
2194
+ ]);
2195
+
2196
+ const retrievedUserB = await getUser(userB.id, scimTokenProviderA);
2197
+ expect(retrievedUserB).toEqual(userB);
2198
+
2199
+ await expect(getUser(userB.id, scimTokenProviderB)).rejects.toThrowError(
2200
+ expect.objectContaining({
2201
+ message: "User not found",
2202
+ body: {
2203
+ detail: "User not found",
2204
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2205
+ status: "404",
2206
+ },
2207
+ }),
2208
+ );
2209
+
2210
+ const retrievedUserA = await getUser(userA.id, scimTokenProviderB);
2211
+ expect(retrievedUserA).toEqual(userA);
2212
+
2213
+ await expect(getUser(userA.id, scimTokenProviderA)).rejects.toThrowError(
2214
+ expect.objectContaining({
2215
+ message: "User not found",
2216
+ body: {
2217
+ detail: "User not found",
2218
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2219
+ status: "404",
2220
+ },
2221
+ }),
2222
+ );
2223
+ });
2224
+
2225
+ it("should only allow access to users that belong to the same provider and organization", async () => {
2226
+ const { auth, registerOrganization, getSCIMToken } = createTestInstance();
2227
+ const [organizationA, organizationB] = await Promise.all([
2228
+ registerOrganization("org-a"),
2229
+ registerOrganization("org-b"),
2230
+ ]);
2231
+
2232
+ const [scimTokenProviderA, scimTokenProviderB] = await Promise.all([
2233
+ getSCIMToken("provider-a", organizationA?.id),
2234
+ getSCIMToken("provider-b", organizationB?.id),
2235
+ ]);
2236
+
2237
+ const createUser = (userName: string, scimToken: string) => {
2238
+ return auth.api.createSCIMUser({
2239
+ body: {
2240
+ userName,
2241
+ },
2242
+ headers: {
2243
+ authorization: `Bearer ${scimToken}`,
2244
+ },
2245
+ });
2246
+ };
2247
+
2248
+ const getUser = (userId: string, scimToken: string) => {
2249
+ return auth.api.getSCIMUser({
2250
+ params: {
2251
+ userId,
2252
+ },
2253
+ headers: {
2254
+ authorization: `Bearer ${scimToken}`,
2255
+ },
2256
+ });
2257
+ };
2258
+
2259
+ const [userA, userB] = await Promise.all([
2260
+ createUser("user-a", scimTokenProviderB),
2261
+ createUser("user-b", scimTokenProviderA),
2262
+ ]);
2263
+
2264
+ const retrievedUserB = await getUser(userB.id, scimTokenProviderA);
2265
+ expect(retrievedUserB).toEqual(userB);
2266
+
2267
+ await expect(getUser(userB.id, scimTokenProviderB)).rejects.toThrowError(
2268
+ expect.objectContaining({
2269
+ message: "User not found",
2270
+ body: {
2271
+ detail: "User not found",
2272
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2273
+ status: "404",
2274
+ },
2275
+ }),
2276
+ );
2277
+
2278
+ const retrievedUserA = await getUser(userA.id, scimTokenProviderB);
2279
+ expect(retrievedUserA).toEqual(userA);
2280
+
2281
+ await expect(getUser(userA.id, scimTokenProviderA)).rejects.toThrowError(
2282
+ expect.objectContaining({
2283
+ message: "User not found",
2284
+ body: {
2285
+ detail: "User not found",
2286
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2287
+ status: "404",
2288
+ },
2289
+ }),
2290
+ );
2291
+ });
2292
+
2293
+ it("should return not found for missing users", async () => {
2294
+ const { auth, getSCIMToken } = createTestInstance();
2295
+ const scimToken = await getSCIMToken();
2296
+
2297
+ const getUser = () =>
2298
+ auth.api.getSCIMUser({
2299
+ params: {
2300
+ userId: "missing",
2301
+ },
2302
+ headers: {
2303
+ authorization: `Bearer ${scimToken}`,
2304
+ },
2305
+ });
2306
+
2307
+ await expect(getUser()).rejects.toThrowError(
2308
+ expect.objectContaining({
2309
+ message: "User not found",
2310
+ body: {
2311
+ detail: "User not found",
2312
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2313
+ status: "404",
2314
+ },
2315
+ }),
2316
+ );
2317
+ });
2318
+
2319
+ it("should not allow anonymous access", async () => {
2320
+ const { auth } = createTestInstance();
2321
+
2322
+ const getUser = async () => {
2323
+ await auth.api.getSCIMUser();
2324
+ };
2325
+
2326
+ await expect(getUser()).rejects.toThrow(/SCIM token is required/);
2327
+ });
2328
+ });
2329
+
2330
+ describe("DELETE /scim/v2/Users/:userId", () => {
2331
+ it("should delete an existing user", async () => {
2332
+ const { auth, getSCIMToken } = createTestInstance();
2333
+ const scimToken = await getSCIMToken();
2334
+
2335
+ const newUser = await auth.api.createSCIMUser({
2336
+ body: {
2337
+ userName: "the-username",
2338
+ },
2339
+ headers: {
2340
+ authorization: `Bearer ${scimToken}`,
2341
+ },
2342
+ });
2343
+
2344
+ await auth.api.deleteSCIMUser({
2345
+ params: {
2346
+ userId: newUser.id,
2347
+ },
2348
+ headers: {
2349
+ authorization: `Bearer ${scimToken}`,
2350
+ },
2351
+ });
2352
+
2353
+ const getUser = () =>
2354
+ auth.api.getSCIMUser({
2355
+ params: {
2356
+ userId: newUser.id,
2357
+ },
2358
+ headers: {
2359
+ authorization: `Bearer ${scimToken}`,
2360
+ },
2361
+ });
2362
+
2363
+ await expect(getUser()).rejects.toThrowError(
2364
+ expect.objectContaining({
2365
+ message: "User not found",
2366
+ body: {
2367
+ detail: "User not found",
2368
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2369
+ status: "404",
2370
+ },
2371
+ }),
2372
+ );
2373
+ });
2374
+
2375
+ it("should not allow anonymous access", async () => {
2376
+ const { auth } = createTestInstance();
2377
+
2378
+ const deleteUser = async () => {
2379
+ await auth.api.deleteSCIMUser({
2380
+ params: {
2381
+ userId: "whatever",
2382
+ },
2383
+ });
2384
+ };
2385
+
2386
+ await expect(deleteUser()).rejects.toThrowError(
2387
+ expect.objectContaining({
2388
+ message: "SCIM token is required",
2389
+ body: {
2390
+ detail: "SCIM token is required",
2391
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2392
+ status: "401",
2393
+ },
2394
+ }),
2395
+ );
2396
+ });
2397
+
2398
+ it("should not delete a missing user", async () => {
2399
+ const { auth, getSCIMToken } = createTestInstance();
2400
+ const scimToken = await getSCIMToken();
2401
+
2402
+ const deleteUser = () =>
2403
+ auth.api.deleteSCIMUser({
2404
+ params: {
2405
+ userId: "missing",
2406
+ },
2407
+ headers: {
2408
+ authorization: `Bearer ${scimToken}`,
2409
+ },
2410
+ });
2411
+
2412
+ await expect(deleteUser()).rejects.toThrowError(
2413
+ expect.objectContaining({
2414
+ message: "User not found",
2415
+ body: {
2416
+ detail: "User not found",
2417
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2418
+ status: "404",
2419
+ },
2420
+ }),
2421
+ );
2422
+ });
2423
+ });
2424
+
2425
+ describe("Default SCIM provider", () => {
2426
+ it("should work with a default SCIM provider", async () => {
2427
+ const scimToken = "dGhlLXNjaW0tdG9rZW46dGhlLXNjaW0tcHJvdmlkZXI="; // base64(scimToken:providerId)
2428
+ const { auth } = createTestInstance({
2429
+ defaultSCIM: [
2430
+ {
2431
+ providerId: "the-scim-provider",
2432
+ scimToken: "the-scim-token",
2433
+ },
2434
+ ],
2435
+ });
2436
+
2437
+ const createdUser = await auth.api.createSCIMUser({
2438
+ body: {
2439
+ userName: "the-username",
2440
+ },
2441
+ headers: {
2442
+ authorization: `Bearer ${scimToken}`,
2443
+ },
2444
+ });
2445
+
2446
+ expect(createdUser.id).toBeTruthy();
2447
+
2448
+ const user = await auth.api.getSCIMUser({
2449
+ params: {
2450
+ userId: createdUser.id,
2451
+ },
2452
+ headers: {
2453
+ authorization: `Bearer ${scimToken}`,
2454
+ },
2455
+ });
2456
+
2457
+ expect(user).toEqual(createdUser);
2458
+
2459
+ const users = await auth.api.listSCIMUsers({
2460
+ headers: {
2461
+ authorization: `Bearer ${scimToken}`,
2462
+ },
2463
+ });
2464
+
2465
+ expect(users.Resources).toEqual([createdUser]);
2466
+
2467
+ const updatedUser = await auth.api.updateSCIMUser({
2468
+ params: {
2469
+ userId: user.id,
2470
+ },
2471
+ body: {
2472
+ userName: "new-username",
2473
+ },
2474
+ headers: {
2475
+ authorization: `Bearer ${scimToken}`,
2476
+ },
2477
+ });
2478
+
2479
+ expect(updatedUser.userName).toBe("new-username");
2480
+
2481
+ await expect(
2482
+ auth.api.deleteSCIMUser({
2483
+ params: {
2484
+ userId: user.id,
2485
+ },
2486
+ headers: {
2487
+ authorization: `Bearer ${scimToken}`,
2488
+ },
2489
+ }),
2490
+ ).resolves.toBe(undefined);
2491
+ });
2492
+
2493
+ it("should reject invalid SCIM tokens", async () => {
2494
+ const { auth } = createTestInstance({
2495
+ defaultSCIM: [
2496
+ {
2497
+ providerId: "the-scim-provider",
2498
+ scimToken: "the-scim-token",
2499
+ },
2500
+ ],
2501
+ });
2502
+
2503
+ const createUser = () =>
2504
+ auth.api.createSCIMUser({
2505
+ body: {
2506
+ userName: "the-username",
2507
+ },
2508
+ headers: {
2509
+ authorization: `Bearer invalid-scim-token`,
2510
+ },
2511
+ });
2512
+
2513
+ await expect(createUser()).rejects.toThrow(
2514
+ expect.objectContaining({
2515
+ message: "Invalid SCIM token",
2516
+ body: {
2517
+ detail: "Invalid SCIM token",
2518
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
2519
+ status: "401",
2520
+ },
2521
+ }),
2522
+ );
2523
+ });
2524
+ });
2525
+ });