@better-auth/scim 1.5.0-beta.6 → 1.5.0-beta.7

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.5.0-beta.6 build /home/runner/work/better-auth/better-auth/packages/scim
2
+ > @better-auth/scim@1.5.0-beta.7 build /home/runner/work/better-auth/better-auth/packages/scim
3
3
  > tsdown
4
4
 
5
5
  ℹ tsdown v0.19.0 powered by rolldown v1.0.0-beta.59
@@ -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-CLRYWJJP.d.mts 108.25 kB │ gzip: 4.26 kB
15
- ℹ 5 files, total: 146.68 kB
16
- ✔ Build complete in 8326ms
14
+ ℹ dist/index-C1g0YSP7.d.mts 108.52 kB │ gzip: 4.36 kB
15
+ ℹ 5 files, total: 148.43 kB
16
+ ✔ Build complete in 6946ms
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as scim } from "./index-CLRYWJJP.mjs";
1
+ import { t as scim } from "./index-C1g0YSP7.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
@@ -372,7 +377,7 @@ declare const scim: (options?: SCIMOptions) => {
372
377
  };
373
378
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
374
379
  authSCIMToken: string;
375
- scimProvider: SCIMProvider;
380
+ scimProvider: Omit<SCIMProvider, "id">;
376
381
  }>)[];
377
382
  }, {
378
383
  id: string;
@@ -661,7 +666,7 @@ declare const scim: (options?: SCIMOptions) => {
661
666
  };
662
667
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
663
668
  authSCIMToken: string;
664
- scimProvider: SCIMProvider;
669
+ scimProvider: Omit<SCIMProvider, "id">;
665
670
  }>)[];
666
671
  }, {
667
672
  id: string;
@@ -689,7 +694,7 @@ declare const scim: (options?: SCIMOptions) => {
689
694
  body: zod0.ZodObject<{
690
695
  schemas: zod0.ZodArray<zod0.ZodString>;
691
696
  Operations: zod0.ZodArray<zod0.ZodObject<{
692
- op: zod0.ZodDefault<zod0.ZodEnum<{
697
+ op: zod0.ZodPipe<zod0.ZodDefault<zod0.ZodString>, zod0.ZodEnum<{
693
698
  add: "add";
694
699
  remove: "remove";
695
700
  replace: "replace";
@@ -875,7 +880,7 @@ declare const scim: (options?: SCIMOptions) => {
875
880
  };
876
881
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
877
882
  authSCIMToken: string;
878
- scimProvider: SCIMProvider;
883
+ scimProvider: Omit<SCIMProvider, "id">;
879
884
  }>)[];
880
885
  }, void>;
881
886
  deleteSCIMUser: better_call0.StrictEndpoint<"/scim/v2/Users/:userId", {
@@ -1057,7 +1062,7 @@ declare const scim: (options?: SCIMOptions) => {
1057
1062
  };
1058
1063
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
1059
1064
  authSCIMToken: string;
1060
- scimProvider: SCIMProvider;
1065
+ scimProvider: Omit<SCIMProvider, "id">;
1061
1066
  }>)[];
1062
1067
  }, void>;
1063
1068
  updateSCIMUser: better_call0.StrictEndpoint<"/scim/v2/Users/:userId", {
@@ -1326,7 +1331,7 @@ declare const scim: (options?: SCIMOptions) => {
1326
1331
  };
1327
1332
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
1328
1333
  authSCIMToken: string;
1329
- scimProvider: SCIMProvider;
1334
+ scimProvider: Omit<SCIMProvider, "id">;
1330
1335
  }>)[];
1331
1336
  }, {
1332
1337
  id: string;
@@ -1622,7 +1627,7 @@ declare const scim: (options?: SCIMOptions) => {
1622
1627
  };
1623
1628
  use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
1624
1629
  authSCIMToken: string;
1625
- scimProvider: SCIMProvider;
1630
+ scimProvider: Omit<SCIMProvider, "id">;
1626
1631
  }>)[];
1627
1632
  }, {
1628
1633
  schemas: string[];
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { t as scim } from "./index-CLRYWJJP.mjs";
1
+ import { t as scim } from "./index-C1g0YSP7.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.5.0-beta.6",
4
+ "version": "1.5.0-beta.7",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
7
7
  "types": "dist/index.d.mts",
@@ -49,12 +49,12 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "tsdown": "^0.19.0",
52
- "@better-auth/core": "1.5.0-beta.6",
53
- "@better-auth/sso": "1.5.0-beta.6"
52
+ "@better-auth/core": "1.5.0-beta.7",
53
+ "@better-auth/sso": "1.5.0-beta.7"
54
54
  },
55
55
  "peerDependencies": {
56
- "@better-auth/core": "1.5.0-beta.6",
57
- "better-auth": "1.5.0-beta.6"
56
+ "@better-auth/core": "1.5.0-beta.7",
57
+ "better-auth": "1.5.0-beta.7"
58
58
  },
59
59
  "scripts": {
60
60
  "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