@better-auth/scim 1.5.0-beta.5 → 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.
- package/.turbo/turbo-build.log +5 -5
- package/dist/client.d.mts +1 -1
- package/dist/{index-CLRYWJJP.d.mts → index-C1g0YSP7.d.mts} +12 -7
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +47 -17
- package/package.json +5 -5
- package/src/middlewares.ts +24 -1
- package/src/patch-operations.ts +83 -15
- package/src/routes.ts +7 -3
- package/src/scim.test.ts +609 -79
- package/src/types.ts +5 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/scim@1.5.0-beta.
|
|
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
|
[34mℹ[39m tsdown [2mv0.19.0[22m powered by rolldown [2mv1.0.0-beta.59[22m
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
[34mℹ[39m entry: [34msrc/index.ts, src/client.ts[39m
|
|
8
8
|
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
9
9
|
[34mℹ[39m Build start
|
|
10
|
-
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m
|
|
10
|
+
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m 39.48 kB[22m [2m│ gzip: 8.06 kB[22m
|
|
11
11
|
[34mℹ[39m [2mdist/[22m[1mclient.mjs[22m [2m 0.15 kB[22m [2m│ gzip: 0.14 kB[22m
|
|
12
12
|
[34mℹ[39m [2mdist/[22m[32m[1mclient.d.mts[22m[39m [2m 0.22 kB[22m [2m│ gzip: 0.18 kB[22m
|
|
13
13
|
[34mℹ[39m [2mdist/[22m[32m[1mindex.d.mts[22m[39m [2m 0.07 kB[22m [2m│ gzip: 0.08 kB[22m
|
|
14
|
-
[34mℹ[39m [2mdist/[22m[32mindex-
|
|
15
|
-
[34mℹ[39m 5 files, total:
|
|
16
|
-
[32m✔[39m Build complete in [
|
|
14
|
+
[34mℹ[39m [2mdist/[22m[32mindex-C1g0YSP7.d.mts[39m [2m108.52 kB[22m [2m│ gzip: 4.36 kB[22m
|
|
15
|
+
[34mℹ[39m 5 files, total: 148.43 kB
|
|
16
|
+
[32m✔[39m Build complete in [32m6946ms[39m
|
package/dist/client.d.mts
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
|
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 !== "
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
])
|
|
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.
|
|
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/
|
|
53
|
-
"@better-auth/
|
|
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.
|
|
57
|
-
"better-auth": "1.5.0-beta.
|
|
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",
|
package/src/middlewares.ts
CHANGED
|
@@ -33,7 +33,30 @@ export const authMiddlewareFactory = (opts: SCIMOptions) =>
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
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 },
|
package/src/patch-operations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 !== "
|
|
134
|
+
if (operation.op !== "add" && operation.op !== "replace") {
|
|
69
135
|
continue;
|
|
70
136
|
}
|
|
71
137
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
onSuccess: setCookieToHeader(headers),
|
|
63
|
-
});
|
|
50
|
+
async function getAuthCookieHeaders() {
|
|
51
|
+
const headers = new Headers();
|
|
64
52
|
|
|
65
|
-
|
|
66
|
-
|
|
53
|
+
await authClient.signUp.email({
|
|
54
|
+
email: testUser.email,
|
|
55
|
+
password: testUser.password,
|
|
56
|
+
name: testUser.name,
|
|
57
|
+
});
|
|
67
58
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
80
|
+
return scimToken;
|
|
81
|
+
}
|
|
86
82
|
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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(
|
|
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: "
|
|
1422
|
+
{ op: "add", path: "/externalId", value: "external-username" },
|
|
1344
1423
|
{ op: "replace", path: "/userName", value: "other-username" },
|
|
1345
|
-
{ op: "
|
|
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
|