@better-auth/scim 1.4.12-beta.2 → 1.4.13

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @better-auth/scim@1.4.12-beta.2 build /home/runner/work/better-auth/better-auth/packages/scim
2
+ > @better-auth/scim@1.4.13 build /home/runner/work/better-auth/better-auth/packages/scim
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.17.2 powered by rolldown v1.0.0-beta.53
@@ -7,10 +7,10 @@
7
7
  ℹ entry: src/index.ts, src/client.ts
8
8
  ℹ tsconfig: tsconfig.json
9
9
  ℹ Build start
10
- ℹ dist/index.mjs  37.99 kB │ gzip: 7.68 kB
10
+ ℹ dist/index.mjs  39.48 kB │ gzip: 8.06 kB
11
11
  ℹ dist/client.mjs  0.15 kB │ gzip: 0.14 kB
12
12
  ℹ dist/client.d.mts  0.22 kB │ gzip: 0.18 kB
13
13
  ℹ dist/index.d.mts  0.07 kB │ gzip: 0.08 kB
14
- ℹ dist/index-aXWBaAAu.d.mts 108.11 kB │ gzip: 4.19 kB
15
- ℹ 5 files, total: 146.54 kB
16
- ✔ Build complete in 6096ms
14
+ ℹ dist/index-BGaCP2s8.d.mts 108.38 kB │ gzip: 4.29 kB
15
+ ℹ 5 files, total: 148.29 kB
16
+ ✔ Build complete in 6065ms
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as scim } from "./index-aXWBaAAu.mjs";
1
+ import { t as scim } from "./index-BGaCP2s8.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  declare const scimClient: () => {
@@ -12,6 +12,11 @@ interface SCIMProvider {
12
12
  organizationId?: string;
13
13
  }
14
14
  type SCIMOptions = {
15
+ /**
16
+ * Default list of SCIM providers for testing
17
+ * These will take precedence over the database when present
18
+ */
19
+ defaultSCIM?: Omit<SCIMProvider, "id">[];
15
20
  /**
16
21
  * A callback that runs before a new SCIM token is generated.
17
22
  * @returns
@@ -365,7 +370,7 @@ declare const scim: (options?: SCIMOptions) => {
365
370
  };
366
371
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
367
372
  authSCIMToken: string;
368
- scimProvider: SCIMProvider;
373
+ scimProvider: Omit<SCIMProvider, "id">;
369
374
  }>)[];
370
375
  }, {
371
376
  id: string;
@@ -654,7 +659,7 @@ declare const scim: (options?: SCIMOptions) => {
654
659
  };
655
660
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
656
661
  authSCIMToken: string;
657
- scimProvider: SCIMProvider;
662
+ scimProvider: Omit<SCIMProvider, "id">;
658
663
  }>)[];
659
664
  }, {
660
665
  id: string;
@@ -682,7 +687,7 @@ declare const scim: (options?: SCIMOptions) => {
682
687
  body: zod0.ZodObject<{
683
688
  schemas: zod0.ZodArray<zod0.ZodString>;
684
689
  Operations: zod0.ZodArray<zod0.ZodObject<{
685
- op: zod0.ZodDefault<zod0.ZodEnum<{
690
+ op: zod0.ZodPipe<zod0.ZodDefault<zod0.ZodString>, zod0.ZodEnum<{
686
691
  add: "add";
687
692
  remove: "remove";
688
693
  replace: "replace";
@@ -868,7 +873,7 @@ declare const scim: (options?: SCIMOptions) => {
868
873
  };
869
874
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
870
875
  authSCIMToken: string;
871
- scimProvider: SCIMProvider;
876
+ scimProvider: Omit<SCIMProvider, "id">;
872
877
  }>)[];
873
878
  }, void>;
874
879
  deleteSCIMUser: better_call0.StrictEndpoint<"/scim/v2/Users/:userId", {
@@ -1050,7 +1055,7 @@ declare const scim: (options?: SCIMOptions) => {
1050
1055
  };
1051
1056
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
1052
1057
  authSCIMToken: string;
1053
- scimProvider: SCIMProvider;
1058
+ scimProvider: Omit<SCIMProvider, "id">;
1054
1059
  }>)[];
1055
1060
  }, void>;
1056
1061
  updateSCIMUser: better_call0.StrictEndpoint<"/scim/v2/Users/:userId", {
@@ -1319,7 +1324,7 @@ declare const scim: (options?: SCIMOptions) => {
1319
1324
  };
1320
1325
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
1321
1326
  authSCIMToken: string;
1322
- scimProvider: SCIMProvider;
1327
+ scimProvider: Omit<SCIMProvider, "id">;
1323
1328
  }>)[];
1324
1329
  }, {
1325
1330
  id: string;
@@ -1615,7 +1620,7 @@ declare const scim: (options?: SCIMOptions) => {
1615
1620
  };
1616
1621
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
1617
1622
  authSCIMToken: string;
1618
- scimProvider: SCIMProvider;
1623
+ scimProvider: Omit<SCIMProvider, "id">;
1619
1624
  }>)[];
1620
1625
  }, {
1621
1626
  schemas: string[];
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { t as scim } from "./index-aXWBaAAu.mjs";
1
+ import { t as scim } from "./index-BGaCP2s8.mjs";
2
2
  export { scim };
package/dist/index.mjs CHANGED
@@ -97,7 +97,16 @@ const authMiddlewareFactory = (opts) => createAuthMiddleware(async (ctx) => {
97
97
  const [scimToken, providerId] = baseScimTokenParts;
98
98
  const organizationId = baseScimTokenParts.slice(2).join(":");
99
99
  if (!scimToken || !providerId) throw new SCIMAPIError("UNAUTHORIZED", { detail: "Invalid SCIM token" });
100
- const scimProvider = await ctx.context.adapter.findOne({
100
+ let scimProvider = opts.defaultSCIM?.find((p) => {
101
+ if (p.providerId === providerId && !organizationId) return true;
102
+ return !!(p.providerId === providerId && organizationId && p.organizationId === organizationId);
103
+ }) ?? null;
104
+ if (scimProvider) if (scimProvider.scimToken === scimToken) return {
105
+ authSCIMToken: scimProvider.scimToken,
106
+ scimProvider
107
+ };
108
+ else throw new SCIMAPIError("UNAUTHORIZED", { detail: "Invalid SCIM token" });
109
+ scimProvider = await ctx.context.adapter.findOne({
101
110
  model: "scimProvider",
102
111
  where: [{
103
112
  field: "providerId",
@@ -139,22 +148,23 @@ const getUserPrimaryEmail = (userName, emails) => {
139
148
 
140
149
  //#endregion
141
150
  //#region src/patch-operations.ts
142
- const identity = (user, op) => {
151
+ const identity = (user, op, resources) => {
143
152
  return op.value;
144
153
  };
145
- const lowerCase = (user, op) => {
154
+ const lowerCase = (user, op, resources) => {
146
155
  return op.value.toLowerCase();
147
156
  };
148
- const givenName = (user, op) => {
149
- const familyName$1 = user.name.split(" ").slice(1).join(" ").trim();
157
+ const givenName = (user, op, resources) => {
158
+ const familyName$1 = (resources.user.name ?? user.name).split(" ").slice(1).join(" ").trim();
150
159
  const givenName$1 = op.value;
151
160
  return getUserFullName(user.email, {
152
161
  givenName: givenName$1,
153
162
  familyName: familyName$1
154
163
  });
155
164
  };
156
- const familyName = (user, op) => {
157
- const givenName$1 = (user.name.split(" ").slice(0, -1).join(" ") || user.name).trim();
165
+ const familyName = (user, op, resources) => {
166
+ const currentName = resources.user.name ?? user.name;
167
+ const givenName$1 = (currentName.split(" ").slice(0, -1).join(" ") || currentName).trim();
158
168
  const familyName$1 = op.value;
159
169
  return getUserFullName(user.email, {
160
170
  givenName: givenName$1,
@@ -188,18 +198,38 @@ const userPatchMappings = {
188
198
  map: lowerCase
189
199
  }
190
200
  };
201
+ const normalizePath = (path) => {
202
+ return `/${(path.startsWith("/") ? path.slice(1) : path).replaceAll(".", "/")}`;
203
+ };
204
+ const isNestedObject = (value) => {
205
+ return typeof value === "object" && value !== null && !Array.isArray(value);
206
+ };
207
+ const applyMapping = (user, resources, path, value, op) => {
208
+ const normalizedPath = normalizePath(path);
209
+ const mapping = userPatchMappings[normalizedPath];
210
+ if (!mapping) return;
211
+ const newValue = mapping.map(user, {
212
+ op,
213
+ value,
214
+ path: normalizedPath
215
+ }, resources);
216
+ if (op === "add" && mapping.resource === "user") {
217
+ if (user[mapping.target] === newValue) return;
218
+ }
219
+ resources[mapping.resource][mapping.target] = newValue;
220
+ };
221
+ const applyPatchValue = (user, resources, value, op, path) => {
222
+ if (isNestedObject(value)) for (const [key, nestedValue] of Object.entries(value)) applyPatchValue(user, resources, nestedValue, op, path ? `${path}.${key}` : key);
223
+ else if (path) applyMapping(user, resources, path, value, op);
224
+ };
191
225
  const buildUserPatch = (user, operations) => {
192
226
  const resources = {
193
227
  user: {},
194
228
  account: {}
195
229
  };
196
230
  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
- }
231
+ if (operation.op !== "add" && operation.op !== "replace") continue;
232
+ applyPatchValue(user, resources, operation.value, operation.op, operation.path);
203
233
  }
204
234
  return resources;
205
235
  };
@@ -843,7 +873,7 @@ const listSCIMUsers = (authMiddleware) => createAuthEndpoint("/scim/v2/Users", {
843
873
  },
844
874
  use: [authMiddleware]
845
875
  }, async (ctx) => {
846
- let apiFilters = parseSCIMAPIUserFilter(ctx.query?.filter);
876
+ const apiFilters = parseSCIMAPIUserFilter(ctx.query?.filter);
847
877
  ctx.context.logger.info("Querying result with filters: ", apiFilters);
848
878
  const providerId = ctx.context.scimProvider.providerId;
849
879
  const accounts = await ctx.context.adapter.findMany({
@@ -923,11 +953,11 @@ const getSCIMUser = (authMiddleware) => createAuthEndpoint("/scim/v2/Users/:user
923
953
  const patchSCIMUserBodySchema = z.object({
924
954
  schemas: z.array(z.string()).refine((s) => s.includes("urn:ietf:params:scim:api:messages:2.0:PatchOp"), { message: "Invalid schemas for PatchOp" }),
925
955
  Operations: z.array(z.object({
926
- op: z.enum([
956
+ op: z.string().toLowerCase().default("replace").pipe(z.enum([
927
957
  "replace",
928
958
  "add",
929
959
  "remove"
930
- ]).default("replace"),
960
+ ])),
931
961
  path: z.string().optional(),
932
962
  value: z.any()
933
963
  }))
@@ -973,7 +1003,7 @@ const deleteSCIMUser = (authMiddleware) => createAuthEndpoint("/scim/v2/Users/:u
973
1003
  method: "DELETE",
974
1004
  metadata: {
975
1005
  ...HIDE_METADATA,
976
- allowedMediaTypes: supportedMediaTypes,
1006
+ allowedMediaTypes: [...supportedMediaTypes, ""],
977
1007
  openapi: {
978
1008
  summary: "Delete SCIM user",
979
1009
  description: "Deletes (or deactivates) a user within the linked organization.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@better-auth/scim",
3
3
  "author": "Jonathan Samines",
4
- "version": "1.4.12-beta.2",
4
+ "version": "1.4.13",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -49,10 +49,10 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "tsdown": "^0.17.2",
52
- "@better-auth/sso": "1.4.12-beta.2"
52
+ "@better-auth/sso": "1.4.13"
53
53
  },
54
54
  "peerDependencies": {
55
- "better-auth": "1.4.12-beta.2"
55
+ "better-auth": "1.4.13"
56
56
  },
57
57
  "scripts": {
58
58
  "test": "vitest",
@@ -33,7 +33,30 @@ export const authMiddlewareFactory = (opts: SCIMOptions) =>
33
33
  });
34
34
  }
35
35
 
36
- const scimProvider = await ctx.context.adapter.findOne<SCIMProvider>({
36
+ let scimProvider: Omit<SCIMProvider, "id"> | null =
37
+ opts.defaultSCIM?.find((p) => {
38
+ if (p.providerId === providerId && !organizationId) {
39
+ return true;
40
+ }
41
+
42
+ return !!(
43
+ p.providerId === providerId &&
44
+ organizationId &&
45
+ p.organizationId === organizationId
46
+ );
47
+ }) ?? null;
48
+
49
+ if (scimProvider) {
50
+ if (scimProvider.scimToken === scimToken) {
51
+ return { authSCIMToken: scimProvider.scimToken, scimProvider };
52
+ } else {
53
+ throw new SCIMAPIError("UNAUTHORIZED", {
54
+ detail: "Invalid SCIM token",
55
+ });
56
+ }
57
+ }
58
+
59
+ scimProvider = await ctx.context.adapter.findOne<SCIMProvider>({
37
60
  model: "scimProvider",
38
61
  where: [
39
62
  { field: "providerId", value: providerId },
@@ -10,19 +10,25 @@ type Operation = {
10
10
  type Mapping = {
11
11
  target: string;
12
12
  resource: "user" | "account";
13
- map: (user: User, op: Operation) => any;
13
+ map: (user: User, op: Operation, resources: Resources) => any;
14
14
  };
15
15
 
16
- const identity = (user: User, op: Operation) => {
16
+ type Resources = {
17
+ user: Record<string, any>;
18
+ account: Record<string, any>;
19
+ };
20
+
21
+ const identity = (user: User, op: Operation, resources: Resources) => {
17
22
  return op.value;
18
23
  };
19
24
 
20
- const lowerCase = (user: User, op: Operation) => {
25
+ const lowerCase = (user: User, op: Operation, resources: Resources) => {
21
26
  return op.value.toLowerCase();
22
27
  };
23
28
 
24
- const givenName = (user: User, op: Operation) => {
25
- const familyName = user.name.split(" ").slice(1).join(" ").trim();
29
+ const givenName = (user: User, op: Operation, resources: Resources) => {
30
+ const currentName = (resources.user.name as string) ?? user.name;
31
+ const familyName = currentName.split(" ").slice(1).join(" ").trim();
26
32
  const givenName = op.value;
27
33
 
28
34
  return getUserFullName(user.email, {
@@ -31,9 +37,10 @@ const givenName = (user: User, op: Operation) => {
31
37
  });
32
38
  };
33
39
 
34
- const familyName = (user: User, op: Operation) => {
40
+ const familyName = (user: User, op: Operation, resources: Resources) => {
41
+ const currentName = (resources.user.name as string) ?? user.name;
35
42
  const givenName = (
36
- user.name.split(" ").slice(0, -1).join(" ") || user.name
43
+ currentName.split(" ").slice(0, -1).join(" ") || currentName
37
44
  ).trim();
38
45
  const familyName = op.value;
39
46
  return getUserFullName(user.email, {
@@ -58,22 +65,83 @@ const userPatchMappings: Record<string, Mapping> = {
58
65
  "/userName": { resource: "user", target: "email", map: lowerCase },
59
66
  };
60
67
 
68
+ const normalizePath = (path: string): string => {
69
+ const withoutLeadingSlash = path.startsWith("/") ? path.slice(1) : path;
70
+ return `/${withoutLeadingSlash.replaceAll(".", "/")}`;
71
+ };
72
+
73
+ const isNestedObject = (value: unknown): value is Record<string, unknown> => {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ };
76
+
77
+ const applyMapping = (
78
+ user: User,
79
+ resources: Resources,
80
+ path: string,
81
+ value: unknown,
82
+ op: "add" | "replace",
83
+ ) => {
84
+ const normalizedPath = normalizePath(path);
85
+ const mapping = userPatchMappings[normalizedPath];
86
+
87
+ if (!mapping) {
88
+ return;
89
+ }
90
+
91
+ const newValue = mapping.map(
92
+ user,
93
+ {
94
+ op,
95
+ value,
96
+ path: normalizedPath,
97
+ },
98
+ resources,
99
+ );
100
+
101
+ if (op === "add" && mapping.resource === "user") {
102
+ const currentValue = (user as Record<string, unknown>)[mapping.target];
103
+ if (currentValue === newValue) {
104
+ return;
105
+ }
106
+ }
107
+
108
+ resources[mapping.resource][mapping.target] = newValue;
109
+ };
110
+
111
+ const applyPatchValue = (
112
+ user: User,
113
+ resources: Resources,
114
+ value: unknown,
115
+ op: "add" | "replace",
116
+ path?: string | undefined,
117
+ ) => {
118
+ if (isNestedObject(value)) {
119
+ for (const [key, nestedValue] of Object.entries(value)) {
120
+ const nestedPath = path ? `${path}.${key}` : key;
121
+ applyPatchValue(user, resources, nestedValue, op, nestedPath);
122
+ }
123
+ } else if (path) {
124
+ applyMapping(user, resources, path, value, op);
125
+ }
126
+ };
127
+
61
128
  export const buildUserPatch = (user: User, operations: Operation[]) => {
62
129
  const userPatch: Record<string, any> = {};
63
130
  const accountPatch: Record<string, any> = {};
64
-
65
- const resources = { user: userPatch, account: accountPatch };
131
+ const resources: Resources = { user: userPatch, account: accountPatch };
66
132
 
67
133
  for (const operation of operations) {
68
- if (operation.op !== "replace" || !operation.path) {
134
+ if (operation.op !== "add" && operation.op !== "replace") {
69
135
  continue;
70
136
  }
71
137
 
72
- const mapping = userPatchMappings[operation.path];
73
- if (mapping) {
74
- const resource = resources[mapping.resource];
75
- resource[mapping.target] = mapping.map(user, operation);
76
- }
138
+ applyPatchValue(
139
+ user,
140
+ resources,
141
+ operation.value,
142
+ operation.op,
143
+ operation.path,
144
+ );
77
145
  }
78
146
 
79
147
  return resources;
package/src/routes.ts CHANGED
@@ -430,7 +430,7 @@ export const listSCIMUsers = (authMiddleware: AuthMiddleware) =>
430
430
  use: [authMiddleware],
431
431
  },
432
432
  async (ctx) => {
433
- let apiFilters: DBFilter[] = parseSCIMAPIUserFilter(ctx.query?.filter);
433
+ const apiFilters: DBFilter[] = parseSCIMAPIUserFilter(ctx.query?.filter);
434
434
 
435
435
  ctx.context.logger.info("Querying result with filters: ", apiFilters);
436
436
 
@@ -536,7 +536,11 @@ const patchSCIMUserBodySchema = z.object({
536
536
  ),
537
537
  Operations: z.array(
538
538
  z.object({
539
- op: z.enum(["replace", "add", "remove"]).default("replace"),
539
+ op: z
540
+ .string()
541
+ .toLowerCase()
542
+ .default("replace")
543
+ .pipe(z.enum(["replace", "add", "remove"])),
540
544
  path: z.string().optional(),
541
545
  value: z.any(),
542
546
  }),
@@ -623,7 +627,7 @@ export const deleteSCIMUser = (authMiddleware: AuthMiddleware) =>
623
627
  method: "DELETE",
624
628
  metadata: {
625
629
  ...HIDE_METADATA,
626
- allowedMediaTypes: supportedMediaTypes,
630
+ allowedMediaTypes: [...supportedMediaTypes, ""],
627
631
  openapi: {
628
632
  summary: "Delete SCIM user",
629
633
  description:
package/src/scim.test.ts CHANGED
@@ -9,99 +9,99 @@ import { scim } from ".";
9
9
  import { scimClient } from "./client";
10
10
  import type { SCIMOptions } from "./types";
11
11
 
12
- describe("SCIM", () => {
12
+ const createTestInstance = (scimOptions?: SCIMOptions) => {
13
13
  const testUser = {
14
14
  email: "test@email.com",
15
15
  password: "password",
16
16
  name: "Test User",
17
17
  };
18
18
 
19
- const createTestInstance = (scimOptions?: SCIMOptions) => {
20
- const data = {
21
- user: [],
22
- session: [],
23
- verification: [],
24
- account: [],
25
- ssoProvider: [],
26
- scimProvider: [],
27
- organization: [],
28
- member: [],
29
- };
30
- const memory = memoryAdapter(data);
31
-
32
- const auth = betterAuth({
33
- database: memory,
34
- baseURL: "http://localhost:3000",
35
- emailAndPassword: {
36
- enabled: true,
37
- },
38
- plugins: [sso(), scim(scimOptions), organization()],
39
- });
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
+ });
40
39
 
41
- const authClient = createAuthClient({
42
- baseURL: "http://localhost:3000",
43
- plugins: [bearer(), scimClient()],
44
- fetchOptions: {
45
- customFetchImpl: async (url, init) => {
46
- return auth.handler(new Request(url, init));
47
- },
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));
48
46
  },
49
- });
50
-
51
- async function getAuthCookieHeaders() {
52
- const headers = new Headers();
53
-
54
- await authClient.signUp.email({
55
- email: testUser.email,
56
- password: testUser.password,
57
- name: testUser.name,
58
- });
47
+ },
48
+ });
59
49
 
60
- await authClient.signIn.email(testUser, {
61
- throw: true,
62
- onSuccess: setCookieToHeader(headers),
63
- });
50
+ async function getAuthCookieHeaders() {
51
+ const headers = new Headers();
64
52
 
65
- return headers;
66
- }
53
+ await authClient.signUp.email({
54
+ email: testUser.email,
55
+ password: testUser.password,
56
+ name: testUser.name,
57
+ });
67
58
 
68
- async function getSCIMToken(
69
- providerId: string = "the-saml-provider-1",
70
- organizationId?: string,
71
- ) {
72
- const headers = await getAuthCookieHeaders();
73
- const { scimToken } = await auth.api.generateSCIMToken({
74
- body: {
75
- providerId,
76
- organizationId,
77
- },
78
- headers,
79
- });
59
+ await authClient.signIn.email(testUser, {
60
+ throw: true,
61
+ onSuccess: setCookieToHeader(headers),
62
+ });
80
63
 
81
- return scimToken;
82
- }
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
+ });
83
79
 
84
- async function registerOrganization(org: string) {
85
- const headers = await getAuthCookieHeaders();
80
+ return scimToken;
81
+ }
86
82
 
87
- return await auth.api.createOrganization({
88
- body: {
89
- slug: `the-${org}`,
90
- name: `the organization ${org}`,
91
- },
92
- headers,
93
- });
94
- }
83
+ async function registerOrganization(org: string) {
84
+ const headers = await getAuthCookieHeaders();
95
85
 
96
- return {
97
- auth,
98
- authClient,
99
- registerOrganization,
100
- getSCIMToken,
101
- getAuthCookieHeaders,
102
- };
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,
103
101
  };
102
+ };
104
103
 
104
+ describe("SCIM", () => {
105
105
  describe("POST /scim/generate-token", () => {
106
106
  it("should require user session", async () => {
107
107
  const { auth } = createTestInstance();
@@ -1310,7 +1310,86 @@ describe("SCIM", () => {
1310
1310
  });
1311
1311
 
1312
1312
  describe("PATCH /scim/v2/users", () => {
1313
- it("should partially update a user resource", async () => {
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 () => {
1314
1393
  const { auth, getSCIMToken } = createTestInstance();
1315
1394
  const scimToken = await getSCIMToken();
1316
1395
 
@@ -1340,9 +1419,9 @@ describe("SCIM", () => {
1340
1419
  body: {
1341
1420
  schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
1342
1421
  Operations: [
1343
- { op: "replace", path: "/externalId", value: "external-username" },
1422
+ { op: "add", path: "/externalId", value: "external-username" },
1344
1423
  { op: "replace", path: "/userName", value: "other-username" },
1345
- { op: "replace", path: "/name/formatted", value: "Daniel Lopez" },
1424
+ { op: "add", path: "/name/formatted", value: "Daniel Lopez" },
1346
1425
  ],
1347
1426
  },
1348
1427
  headers: {
@@ -1386,6 +1465,356 @@ describe("SCIM", () => {
1386
1465
  });
1387
1466
  });
1388
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
+
1389
1818
  it("should return not found for missing users", async () => {
1390
1819
  const { auth, getSCIMToken } = createTestInstance();
1391
1820
  const scimToken = await getSCIMToken();
@@ -1992,4 +2421,105 @@ describe("SCIM", () => {
1992
2421
  );
1993
2422
  });
1994
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
+ });
1995
2525
  });
package/src/types.ts CHANGED
@@ -17,6 +17,11 @@ export type SCIMName = {
17
17
  export type SCIMEmail = { value?: string; primary?: boolean };
18
18
 
19
19
  export type SCIMOptions = {
20
+ /**
21
+ * Default list of SCIM providers for testing
22
+ * These will take precedence over the database when present
23
+ */
24
+ defaultSCIM?: Omit<SCIMProvider, "id">[];
20
25
  /**
21
26
  * A callback that runs before a new SCIM token is generated.
22
27
  * @returns