@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.
- package/.turbo/turbo-build.log +5 -5
- package/dist/client.d.mts +1 -1
- package/dist/{index-aXWBaAAu.d.mts → index-BGaCP2s8.d.mts} +12 -7
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +47 -17
- package/package.json +3 -3
- 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.4.
|
|
2
|
+
> @better-auth/scim@1.4.13 build /home/runner/work/better-auth/better-auth/packages/scim
|
|
3
3
|
> tsdown
|
|
4
4
|
|
|
5
5
|
[34mℹ[39m tsdown [2mv0.17.2[22m powered by rolldown [2mv1.0.0-beta.53[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-BGaCP2s8.d.mts[39m [2m108.38 kB[22m [2m│ gzip: 4.29 kB[22m
|
|
15
|
+
[34mℹ[39m 5 files, total: 148.29 kB
|
|
16
|
+
[32m✔[39m Build complete in [32m6065ms[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
|
|
@@ -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-
|
|
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
|
-
|
|
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.4.
|
|
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.
|
|
52
|
+
"@better-auth/sso": "1.4.13"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"better-auth": "1.4.
|
|
55
|
+
"better-auth": "1.4.13"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"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
|