@hammadj/better-auth-scim 1.5.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +84 -0
- package/LICENSE.md +20 -0
- package/dist/client.d.mts +5 -0
- package/dist/client.mjs +11 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +77 -0
- package/dist/index.mjs +1308 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +68 -0
- package/src/client.ts +9 -0
- package/src/index.ts +75 -0
- package/src/mappings.ts +38 -0
- package/src/middlewares.ts +89 -0
- package/src/patch-operations.ts +148 -0
- package/src/routes.ts +980 -0
- package/src/scim-error.ts +99 -0
- package/src/scim-filters.ts +69 -0
- package/src/scim-metadata.ts +128 -0
- package/src/scim-resources.ts +35 -0
- package/src/scim-tokens.ts +71 -0
- package/src/scim.test.ts +2525 -0
- package/src/types.ts +70 -0
- package/src/user-schemas.ts +213 -0
- package/src/utils.ts +5 -0
- package/tsconfig.json +11 -0
- package/tsdown.config.ts +8 -0
- package/vitest.config.ts +3 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
import { base64Url } from "@better-auth/utils/base64";
|
|
2
|
+
import type { Account, DBAdapter, User } from "better-auth";
|
|
3
|
+
import { HIDE_METADATA } from "better-auth";
|
|
4
|
+
import { APIError, sessionMiddleware } from "better-auth/api";
|
|
5
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
6
|
+
import type { Member } from "better-auth/plugins";
|
|
7
|
+
import { createAuthEndpoint } from "better-auth/plugins";
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import { getAccountId, getUserFullName, getUserPrimaryEmail } from "./mappings";
|
|
10
|
+
import type { AuthMiddleware } from "./middlewares";
|
|
11
|
+
import { buildUserPatch } from "./patch-operations";
|
|
12
|
+
import { SCIMAPIError, SCIMErrorOpenAPISchemas } from "./scim-error";
|
|
13
|
+
import type { DBFilter } from "./scim-filters";
|
|
14
|
+
import { parseSCIMUserFilter, SCIMParseError } from "./scim-filters";
|
|
15
|
+
import {
|
|
16
|
+
ResourceTypeOpenAPISchema,
|
|
17
|
+
SCIMSchemaOpenAPISchema,
|
|
18
|
+
ServiceProviderOpenAPISchema,
|
|
19
|
+
} from "./scim-metadata";
|
|
20
|
+
import { createUserResource } from "./scim-resources";
|
|
21
|
+
import { storeSCIMToken } from "./scim-tokens";
|
|
22
|
+
import type { SCIMOptions, SCIMProvider } from "./types";
|
|
23
|
+
import {
|
|
24
|
+
APIUserSchema,
|
|
25
|
+
OpenAPIUserResourceSchema,
|
|
26
|
+
SCIMUserResourceSchema,
|
|
27
|
+
SCIMUserResourceType,
|
|
28
|
+
} from "./user-schemas";
|
|
29
|
+
import { getResourceURL } from "./utils";
|
|
30
|
+
|
|
31
|
+
const supportedSCIMSchemas = [SCIMUserResourceSchema];
|
|
32
|
+
const supportedSCIMResourceTypes = [SCIMUserResourceType];
|
|
33
|
+
const supportedMediaTypes = ["application/json", "application/scim+json"];
|
|
34
|
+
|
|
35
|
+
const generateSCIMTokenBodySchema = z.object({
|
|
36
|
+
providerId: z.string().meta({ description: "Unique provider identifier" }),
|
|
37
|
+
organizationId: z
|
|
38
|
+
.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.meta({ description: "Optional organization id" }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const generateSCIMToken = (opts: SCIMOptions) =>
|
|
44
|
+
createAuthEndpoint(
|
|
45
|
+
"/scim/generate-token",
|
|
46
|
+
{
|
|
47
|
+
method: "POST",
|
|
48
|
+
body: generateSCIMTokenBodySchema,
|
|
49
|
+
metadata: {
|
|
50
|
+
openapi: {
|
|
51
|
+
summary: "Generates a new SCIM token for the given provider",
|
|
52
|
+
description:
|
|
53
|
+
"Generates a new SCIM token to be used for SCIM operations",
|
|
54
|
+
responses: {
|
|
55
|
+
"201": {
|
|
56
|
+
description: "SCIM token response",
|
|
57
|
+
content: {
|
|
58
|
+
"application/json": {
|
|
59
|
+
schema: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
scimToken: {
|
|
63
|
+
description: "SCIM token",
|
|
64
|
+
type: "string",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
use: [sessionMiddleware],
|
|
75
|
+
},
|
|
76
|
+
async (ctx) => {
|
|
77
|
+
const { providerId, organizationId } = ctx.body;
|
|
78
|
+
const user = ctx.context.session.user;
|
|
79
|
+
|
|
80
|
+
if (providerId.includes(":")) {
|
|
81
|
+
throw new APIError("BAD_REQUEST", {
|
|
82
|
+
message: "Provider id contains forbidden characters",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (organizationId && !ctx.context.hasPlugin("organization")) {
|
|
87
|
+
throw new APIError("BAD_REQUEST", {
|
|
88
|
+
message:
|
|
89
|
+
"Restricting a token to an organization requires the organization plugin",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let member: Member | null = null;
|
|
94
|
+
if (organizationId) {
|
|
95
|
+
member = await ctx.context.adapter.findOne<Member>({
|
|
96
|
+
model: "member",
|
|
97
|
+
where: [
|
|
98
|
+
{
|
|
99
|
+
field: "userId",
|
|
100
|
+
value: user.id,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
field: "organizationId",
|
|
104
|
+
value: organizationId,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!member) {
|
|
110
|
+
throw new APIError("FORBIDDEN", {
|
|
111
|
+
message: "You are not a member of the organization",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const scimProvider = await ctx.context.adapter.findOne<SCIMProvider>({
|
|
117
|
+
model: "scimProvider",
|
|
118
|
+
where: [
|
|
119
|
+
{ field: "providerId", value: providerId },
|
|
120
|
+
...(organizationId
|
|
121
|
+
? [{ field: "organizationId", value: organizationId }]
|
|
122
|
+
: []),
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (scimProvider) {
|
|
127
|
+
await ctx.context.adapter.delete<SCIMProvider>({
|
|
128
|
+
model: "scimProvider",
|
|
129
|
+
where: [{ field: "id", value: scimProvider.id }],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const baseToken = generateRandomString(24);
|
|
134
|
+
const scimToken = base64Url.encode(
|
|
135
|
+
`${baseToken}:${providerId}${organizationId ? `:${organizationId}` : ""}`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (opts.beforeSCIMTokenGenerated) {
|
|
139
|
+
await opts.beforeSCIMTokenGenerated({
|
|
140
|
+
user,
|
|
141
|
+
member,
|
|
142
|
+
scimToken,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const newSCIMProvider = await ctx.context.adapter.create<SCIMProvider>({
|
|
147
|
+
model: "scimProvider",
|
|
148
|
+
data: {
|
|
149
|
+
providerId: providerId,
|
|
150
|
+
organizationId: organizationId,
|
|
151
|
+
scimToken: await storeSCIMToken(ctx, opts, baseToken),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (opts.afterSCIMTokenGenerated) {
|
|
156
|
+
await opts.afterSCIMTokenGenerated({
|
|
157
|
+
user,
|
|
158
|
+
member,
|
|
159
|
+
scimToken,
|
|
160
|
+
scimProvider: newSCIMProvider,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ctx.setStatus(201);
|
|
165
|
+
|
|
166
|
+
return ctx.json({
|
|
167
|
+
scimToken,
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
export const createSCIMUser = (authMiddleware: AuthMiddleware) =>
|
|
173
|
+
createAuthEndpoint(
|
|
174
|
+
"/scim/v2/Users",
|
|
175
|
+
{
|
|
176
|
+
method: "POST",
|
|
177
|
+
body: APIUserSchema,
|
|
178
|
+
metadata: {
|
|
179
|
+
...HIDE_METADATA,
|
|
180
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
181
|
+
openapi: {
|
|
182
|
+
summary: "Create SCIM user.",
|
|
183
|
+
description:
|
|
184
|
+
"Provision a new user into the linked organization via SCIM. See https://datatracker.ietf.org/doc/html/rfc7644#section-3.3",
|
|
185
|
+
responses: {
|
|
186
|
+
"201": {
|
|
187
|
+
description: "SCIM user resource",
|
|
188
|
+
content: {
|
|
189
|
+
"application/json": {
|
|
190
|
+
schema: OpenAPIUserResourceSchema,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
...SCIMErrorOpenAPISchemas,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
use: [authMiddleware],
|
|
199
|
+
},
|
|
200
|
+
async (ctx) => {
|
|
201
|
+
const body = ctx.body;
|
|
202
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
203
|
+
const accountId = getAccountId(body.userName, body.externalId);
|
|
204
|
+
|
|
205
|
+
const existingAccount = await ctx.context.adapter.findOne<Account>({
|
|
206
|
+
model: "account",
|
|
207
|
+
where: [
|
|
208
|
+
{ field: "accountId", value: accountId },
|
|
209
|
+
{ field: "providerId", value: providerId },
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (existingAccount) {
|
|
214
|
+
throw new SCIMAPIError("CONFLICT", {
|
|
215
|
+
detail: "User already exists",
|
|
216
|
+
scimType: "uniqueness",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const email = getUserPrimaryEmail(body.userName, body.emails);
|
|
221
|
+
const name = getUserFullName(email, body.name);
|
|
222
|
+
|
|
223
|
+
const existingUser = await ctx.context.adapter.findOne<User>({
|
|
224
|
+
model: "user",
|
|
225
|
+
where: [{ field: "email", value: email }],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const createAccount = (userId: string) =>
|
|
229
|
+
ctx.context.internalAdapter.createAccount({
|
|
230
|
+
userId: userId,
|
|
231
|
+
providerId: providerId,
|
|
232
|
+
accountId: accountId,
|
|
233
|
+
accessToken: "",
|
|
234
|
+
refreshToken: "",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const createUser = () =>
|
|
238
|
+
ctx.context.internalAdapter.createUser({
|
|
239
|
+
email,
|
|
240
|
+
name,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const createOrgMembership = async (userId: string) => {
|
|
244
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
245
|
+
|
|
246
|
+
if (organizationId) {
|
|
247
|
+
const isOrgMember = await ctx.context.adapter.findOne({
|
|
248
|
+
model: "member",
|
|
249
|
+
where: [
|
|
250
|
+
{ field: "organizationId", value: organizationId },
|
|
251
|
+
{ field: "userId", value: userId },
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (!isOrgMember) {
|
|
256
|
+
return await ctx.context.adapter.create<Member>({
|
|
257
|
+
model: "member",
|
|
258
|
+
data: {
|
|
259
|
+
userId: userId,
|
|
260
|
+
role: "member",
|
|
261
|
+
createdAt: new Date(),
|
|
262
|
+
organizationId,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
let user: User;
|
|
270
|
+
let account: Account;
|
|
271
|
+
|
|
272
|
+
if (existingUser) {
|
|
273
|
+
user = existingUser;
|
|
274
|
+
account = await ctx.context.adapter.transaction<Account>(async () => {
|
|
275
|
+
const account = await createAccount(user.id);
|
|
276
|
+
await createOrgMembership(user.id);
|
|
277
|
+
return account;
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
[user, account] = await ctx.context.adapter.transaction<
|
|
281
|
+
[User, Account]
|
|
282
|
+
>(async () => {
|
|
283
|
+
const user = await createUser();
|
|
284
|
+
const account = await createAccount(user.id);
|
|
285
|
+
await createOrgMembership(user.id);
|
|
286
|
+
return [user, account];
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const userResource = createUserResource(
|
|
291
|
+
ctx.context.baseURL,
|
|
292
|
+
user,
|
|
293
|
+
account,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
ctx.setStatus(201);
|
|
297
|
+
ctx.setHeader("location", userResource.meta.location);
|
|
298
|
+
return ctx.json(userResource);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
export const updateSCIMUser = (authMiddleware: AuthMiddleware) =>
|
|
303
|
+
createAuthEndpoint(
|
|
304
|
+
"/scim/v2/Users/:userId",
|
|
305
|
+
{
|
|
306
|
+
method: "PUT",
|
|
307
|
+
body: APIUserSchema,
|
|
308
|
+
metadata: {
|
|
309
|
+
...HIDE_METADATA,
|
|
310
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
311
|
+
openapi: {
|
|
312
|
+
summary: "Update SCIM user.",
|
|
313
|
+
description:
|
|
314
|
+
"Updates an existing user into the linked organization via SCIM. See https://datatracker.ietf.org/doc/html/rfc7644#section-3.3",
|
|
315
|
+
responses: {
|
|
316
|
+
"200": {
|
|
317
|
+
description: "SCIM user resource",
|
|
318
|
+
content: {
|
|
319
|
+
"application/json": {
|
|
320
|
+
schema: OpenAPIUserResourceSchema,
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
...SCIMErrorOpenAPISchemas,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
use: [authMiddleware],
|
|
329
|
+
},
|
|
330
|
+
async (ctx) => {
|
|
331
|
+
const body = ctx.body;
|
|
332
|
+
const userId = ctx.params.userId;
|
|
333
|
+
const { organizationId, providerId } = ctx.context.scimProvider;
|
|
334
|
+
const accountId = getAccountId(body.userName, body.externalId);
|
|
335
|
+
|
|
336
|
+
const { user, account } = await findUserById(ctx.context.adapter, {
|
|
337
|
+
userId,
|
|
338
|
+
providerId,
|
|
339
|
+
organizationId,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (!user) {
|
|
343
|
+
throw new SCIMAPIError("NOT_FOUND", {
|
|
344
|
+
detail: "User not found",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const [updatedUser, updatedAccount] =
|
|
349
|
+
await ctx.context.adapter.transaction<[User | null, Account | null]>(
|
|
350
|
+
async () => {
|
|
351
|
+
const email = getUserPrimaryEmail(body.userName, body.emails);
|
|
352
|
+
const name = getUserFullName(email, body.name);
|
|
353
|
+
|
|
354
|
+
const updatedUser = await ctx.context.internalAdapter.updateUser(
|
|
355
|
+
userId,
|
|
356
|
+
{
|
|
357
|
+
email,
|
|
358
|
+
name,
|
|
359
|
+
updatedAt: new Date(),
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const updatedAccount =
|
|
364
|
+
await ctx.context.internalAdapter.updateAccount(account.id, {
|
|
365
|
+
accountId,
|
|
366
|
+
updatedAt: new Date(),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
return [updatedUser, updatedAccount];
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const userResource = createUserResource(
|
|
374
|
+
ctx.context.baseURL,
|
|
375
|
+
updatedUser!,
|
|
376
|
+
updatedAccount,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
return ctx.json(userResource);
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const listSCIMUsersQuerySchema = z
|
|
384
|
+
.object({
|
|
385
|
+
filter: z.string().optional(),
|
|
386
|
+
})
|
|
387
|
+
.optional();
|
|
388
|
+
|
|
389
|
+
export const listSCIMUsers = (authMiddleware: AuthMiddleware) =>
|
|
390
|
+
createAuthEndpoint(
|
|
391
|
+
"/scim/v2/Users",
|
|
392
|
+
{
|
|
393
|
+
method: "GET",
|
|
394
|
+
query: listSCIMUsersQuerySchema,
|
|
395
|
+
metadata: {
|
|
396
|
+
...HIDE_METADATA,
|
|
397
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
398
|
+
openapi: {
|
|
399
|
+
summary: "List SCIM users",
|
|
400
|
+
description:
|
|
401
|
+
"Returns all users provisioned via SCIM for the linked organization. See https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2",
|
|
402
|
+
responses: {
|
|
403
|
+
"200": {
|
|
404
|
+
description: "SCIM user list",
|
|
405
|
+
content: {
|
|
406
|
+
"application/json": {
|
|
407
|
+
schema: {
|
|
408
|
+
type: "object",
|
|
409
|
+
properties: {
|
|
410
|
+
totalResults: { type: "number" },
|
|
411
|
+
itemsPerPage: { type: "number" },
|
|
412
|
+
startIndex: { type: "number" },
|
|
413
|
+
Resources: {
|
|
414
|
+
type: "array",
|
|
415
|
+
items: OpenAPIUserResourceSchema,
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
...SCIMErrorOpenAPISchemas,
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
use: [authMiddleware],
|
|
427
|
+
},
|
|
428
|
+
async (ctx) => {
|
|
429
|
+
const apiFilters: DBFilter[] = parseSCIMAPIUserFilter(ctx.query?.filter);
|
|
430
|
+
|
|
431
|
+
ctx.context.logger.info("Querying result with filters: ", apiFilters);
|
|
432
|
+
|
|
433
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
434
|
+
const accounts = await ctx.context.adapter.findMany<Account>({
|
|
435
|
+
model: "account",
|
|
436
|
+
where: [{ field: "providerId", value: providerId }],
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const accountUserIds = accounts.map((account) => account.userId);
|
|
440
|
+
let userFilters: DBFilter[] = [
|
|
441
|
+
{ field: "id", value: accountUserIds, operator: "in" },
|
|
442
|
+
];
|
|
443
|
+
|
|
444
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
445
|
+
if (organizationId) {
|
|
446
|
+
const members = await ctx.context.adapter.findMany<Member>({
|
|
447
|
+
model: "member",
|
|
448
|
+
where: [
|
|
449
|
+
{ field: "organizationId", value: organizationId },
|
|
450
|
+
{ field: "userId", value: accountUserIds, operator: "in" },
|
|
451
|
+
],
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const memberUserIds = members.map((member) => member.userId);
|
|
455
|
+
userFilters = [{ field: "id", value: memberUserIds, operator: "in" }];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const users = await ctx.context.adapter.findMany<User>({
|
|
459
|
+
model: "user",
|
|
460
|
+
where: [...userFilters, ...apiFilters],
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
return ctx.json({
|
|
464
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
465
|
+
totalResults: users.length,
|
|
466
|
+
startIndex: 1,
|
|
467
|
+
itemsPerPage: users.length,
|
|
468
|
+
Resources: users.map((user) => {
|
|
469
|
+
const account = accounts.find((a) => a.userId === user.id);
|
|
470
|
+
return createUserResource(ctx.context.baseURL, user, account);
|
|
471
|
+
}),
|
|
472
|
+
});
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
export const getSCIMUser = (authMiddleware: AuthMiddleware) =>
|
|
477
|
+
createAuthEndpoint(
|
|
478
|
+
"/scim/v2/Users/:userId",
|
|
479
|
+
{
|
|
480
|
+
method: "GET",
|
|
481
|
+
metadata: {
|
|
482
|
+
...HIDE_METADATA,
|
|
483
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
484
|
+
openapi: {
|
|
485
|
+
summary: "Get SCIM user details",
|
|
486
|
+
description:
|
|
487
|
+
"Returns the provisioned SCIM user details. See https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.1",
|
|
488
|
+
responses: {
|
|
489
|
+
"200": {
|
|
490
|
+
description: "SCIM user resource",
|
|
491
|
+
content: {
|
|
492
|
+
"application/json": {
|
|
493
|
+
schema: OpenAPIUserResourceSchema,
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
...SCIMErrorOpenAPISchemas,
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
use: [authMiddleware],
|
|
502
|
+
},
|
|
503
|
+
async (ctx) => {
|
|
504
|
+
const userId = ctx.params.userId;
|
|
505
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
506
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
507
|
+
|
|
508
|
+
const { user, account } = await findUserById(ctx.context.adapter, {
|
|
509
|
+
userId,
|
|
510
|
+
providerId,
|
|
511
|
+
organizationId,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
if (!user) {
|
|
515
|
+
throw new SCIMAPIError("NOT_FOUND", {
|
|
516
|
+
detail: "User not found",
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return ctx.json(createUserResource(ctx.context.baseURL, user, account));
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const patchSCIMUserBodySchema = z.object({
|
|
525
|
+
schemas: z
|
|
526
|
+
.array(z.string())
|
|
527
|
+
.refine(
|
|
528
|
+
(s) => s.includes("urn:ietf:params:scim:api:messages:2.0:PatchOp"),
|
|
529
|
+
{
|
|
530
|
+
message: "Invalid schemas for PatchOp",
|
|
531
|
+
},
|
|
532
|
+
),
|
|
533
|
+
Operations: z.array(
|
|
534
|
+
z.object({
|
|
535
|
+
op: z
|
|
536
|
+
.string()
|
|
537
|
+
.toLowerCase()
|
|
538
|
+
.default("replace")
|
|
539
|
+
.pipe(z.enum(["replace", "add", "remove"])),
|
|
540
|
+
path: z.string().optional(),
|
|
541
|
+
value: z.any(),
|
|
542
|
+
}),
|
|
543
|
+
),
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
export const patchSCIMUser = (authMiddleware: AuthMiddleware) =>
|
|
547
|
+
createAuthEndpoint(
|
|
548
|
+
"/scim/v2/Users/:userId",
|
|
549
|
+
{
|
|
550
|
+
method: "PATCH",
|
|
551
|
+
body: patchSCIMUserBodySchema,
|
|
552
|
+
metadata: {
|
|
553
|
+
...HIDE_METADATA,
|
|
554
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
555
|
+
openapi: {
|
|
556
|
+
summary: "Patch SCIM user",
|
|
557
|
+
description: "Updates fields on a SCIM user record",
|
|
558
|
+
responses: {
|
|
559
|
+
"204": {
|
|
560
|
+
description: "Patch update applied correctly",
|
|
561
|
+
},
|
|
562
|
+
...SCIMErrorOpenAPISchemas,
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
use: [authMiddleware],
|
|
567
|
+
},
|
|
568
|
+
async (ctx) => {
|
|
569
|
+
const userId = ctx.params.userId;
|
|
570
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
571
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
572
|
+
|
|
573
|
+
const { user, account } = await findUserById(ctx.context.adapter, {
|
|
574
|
+
userId,
|
|
575
|
+
providerId,
|
|
576
|
+
organizationId,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
if (!user) {
|
|
580
|
+
throw new SCIMAPIError("NOT_FOUND", {
|
|
581
|
+
detail: "User not found",
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const { user: userPatch, account: accountPatch } = buildUserPatch(
|
|
586
|
+
user,
|
|
587
|
+
ctx.body.Operations,
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
if (
|
|
591
|
+
Object.keys(userPatch).length === 0 &&
|
|
592
|
+
Object.keys(accountPatch).length === 0
|
|
593
|
+
) {
|
|
594
|
+
throw new SCIMAPIError("BAD_REQUEST", {
|
|
595
|
+
detail: "No valid fields to update",
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
await Promise.all([
|
|
600
|
+
Object.keys(userPatch).length > 0
|
|
601
|
+
? ctx.context.internalAdapter.updateUser(userId, {
|
|
602
|
+
...userPatch,
|
|
603
|
+
updatedAt: new Date(),
|
|
604
|
+
})
|
|
605
|
+
: Promise.resolve(),
|
|
606
|
+
Object.keys(accountPatch).length > 0
|
|
607
|
+
? ctx.context.internalAdapter.updateAccount(account.id, {
|
|
608
|
+
...accountPatch,
|
|
609
|
+
updatedAt: new Date(),
|
|
610
|
+
})
|
|
611
|
+
: Promise.resolve(),
|
|
612
|
+
]);
|
|
613
|
+
|
|
614
|
+
ctx.setStatus(204);
|
|
615
|
+
return;
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
export const deleteSCIMUser = (authMiddleware: AuthMiddleware) =>
|
|
620
|
+
createAuthEndpoint(
|
|
621
|
+
"/scim/v2/Users/:userId",
|
|
622
|
+
{
|
|
623
|
+
method: "DELETE",
|
|
624
|
+
metadata: {
|
|
625
|
+
...HIDE_METADATA,
|
|
626
|
+
allowedMediaTypes: [...supportedMediaTypes, ""],
|
|
627
|
+
openapi: {
|
|
628
|
+
summary: "Delete SCIM user",
|
|
629
|
+
description:
|
|
630
|
+
"Deletes (or deactivates) a user within the linked organization.",
|
|
631
|
+
responses: {
|
|
632
|
+
"204": {
|
|
633
|
+
description: "Delete applied successfully",
|
|
634
|
+
},
|
|
635
|
+
...SCIMErrorOpenAPISchemas,
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
use: [authMiddleware],
|
|
640
|
+
},
|
|
641
|
+
async (ctx) => {
|
|
642
|
+
const userId = ctx.params.userId;
|
|
643
|
+
const providerId = ctx.context.scimProvider.providerId;
|
|
644
|
+
const organizationId = ctx.context.scimProvider.organizationId;
|
|
645
|
+
|
|
646
|
+
const { user } = await findUserById(ctx.context.adapter, {
|
|
647
|
+
userId,
|
|
648
|
+
providerId,
|
|
649
|
+
organizationId,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
if (!user) {
|
|
653
|
+
throw new SCIMAPIError("NOT_FOUND", {
|
|
654
|
+
detail: "User not found",
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
await ctx.context.internalAdapter.deleteUser(userId);
|
|
659
|
+
|
|
660
|
+
ctx.setStatus(204);
|
|
661
|
+
return;
|
|
662
|
+
},
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
export const getSCIMServiceProviderConfig = createAuthEndpoint(
|
|
666
|
+
"/scim/v2/ServiceProviderConfig",
|
|
667
|
+
{
|
|
668
|
+
method: "GET",
|
|
669
|
+
metadata: {
|
|
670
|
+
...HIDE_METADATA,
|
|
671
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
672
|
+
openapi: {
|
|
673
|
+
summary: "SCIM Service Provider Configuration",
|
|
674
|
+
description:
|
|
675
|
+
"Standard SCIM metadata endpoint used by identity providers. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
676
|
+
responses: {
|
|
677
|
+
"200": {
|
|
678
|
+
description: "SCIM metadata object",
|
|
679
|
+
content: {
|
|
680
|
+
"application/json": {
|
|
681
|
+
schema: ServiceProviderOpenAPISchema,
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
...SCIMErrorOpenAPISchemas,
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
async (ctx) => {
|
|
691
|
+
return ctx.json({
|
|
692
|
+
patch: { supported: true },
|
|
693
|
+
bulk: { supported: false },
|
|
694
|
+
filter: { supported: true },
|
|
695
|
+
changePassword: { supported: false },
|
|
696
|
+
sort: { supported: false },
|
|
697
|
+
etag: { supported: false },
|
|
698
|
+
authenticationSchemes: [
|
|
699
|
+
{
|
|
700
|
+
name: "OAuth Bearer Token",
|
|
701
|
+
description:
|
|
702
|
+
"Authentication scheme using the Authorization header with a bearer token tied to an organization.",
|
|
703
|
+
specUri: "http://www.rfc-editor.org/info/rfc6750",
|
|
704
|
+
type: "oauthbearertoken",
|
|
705
|
+
primary: true,
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
709
|
+
meta: {
|
|
710
|
+
resourceType: "ServiceProviderConfig",
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
},
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
export const getSCIMSchemas = createAuthEndpoint(
|
|
717
|
+
"/scim/v2/Schemas",
|
|
718
|
+
{
|
|
719
|
+
method: "GET",
|
|
720
|
+
metadata: {
|
|
721
|
+
...HIDE_METADATA,
|
|
722
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
723
|
+
openapi: {
|
|
724
|
+
summary: "SCIM Service Provider Configuration Schemas",
|
|
725
|
+
description:
|
|
726
|
+
"Standard SCIM metadata endpoint used by identity providers to acquire information about supported schemas. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
727
|
+
responses: {
|
|
728
|
+
"200": {
|
|
729
|
+
description: "SCIM metadata object",
|
|
730
|
+
content: {
|
|
731
|
+
"application/json": {
|
|
732
|
+
schema: {
|
|
733
|
+
type: "array",
|
|
734
|
+
items: SCIMSchemaOpenAPISchema,
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
...SCIMErrorOpenAPISchemas,
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
async (ctx) => {
|
|
745
|
+
return ctx.json({
|
|
746
|
+
totalResults: supportedSCIMSchemas.length,
|
|
747
|
+
itemsPerPage: supportedSCIMSchemas.length,
|
|
748
|
+
startIndex: 1,
|
|
749
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
750
|
+
Resources: supportedSCIMSchemas.map((s) => {
|
|
751
|
+
return {
|
|
752
|
+
...s,
|
|
753
|
+
meta: {
|
|
754
|
+
...s.meta,
|
|
755
|
+
location: getResourceURL(s.meta.location, ctx.context.baseURL),
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
}),
|
|
759
|
+
});
|
|
760
|
+
},
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
export const getSCIMSchema = createAuthEndpoint(
|
|
764
|
+
"/scim/v2/Schemas/:schemaId",
|
|
765
|
+
{
|
|
766
|
+
method: "GET",
|
|
767
|
+
metadata: {
|
|
768
|
+
...HIDE_METADATA,
|
|
769
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
770
|
+
openapi: {
|
|
771
|
+
summary: "SCIM a Service Provider Configuration Schema",
|
|
772
|
+
description:
|
|
773
|
+
"Standard SCIM metadata endpoint used by identity providers to acquire information about a given schema. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
774
|
+
responses: {
|
|
775
|
+
"200": {
|
|
776
|
+
description: "SCIM metadata object",
|
|
777
|
+
content: {
|
|
778
|
+
"application/json": {
|
|
779
|
+
schema: SCIMSchemaOpenAPISchema,
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
...SCIMErrorOpenAPISchemas,
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
async (ctx) => {
|
|
789
|
+
const schema = supportedSCIMSchemas.find(
|
|
790
|
+
(s) => s.id === ctx.params.schemaId,
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
if (!schema) {
|
|
794
|
+
throw new SCIMAPIError("NOT_FOUND", {
|
|
795
|
+
detail: "Schema not found",
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return ctx.json({
|
|
800
|
+
...schema,
|
|
801
|
+
meta: {
|
|
802
|
+
...schema.meta,
|
|
803
|
+
location: getResourceURL(schema.meta.location, ctx.context.baseURL),
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
},
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
export const getSCIMResourceTypes = createAuthEndpoint(
|
|
810
|
+
"/scim/v2/ResourceTypes",
|
|
811
|
+
{
|
|
812
|
+
method: "GET",
|
|
813
|
+
metadata: {
|
|
814
|
+
...HIDE_METADATA,
|
|
815
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
816
|
+
openapi: {
|
|
817
|
+
summary: "SCIM Service Provider Supported Resource Types",
|
|
818
|
+
description:
|
|
819
|
+
"Standard SCIM metadata endpoint used by identity providers to get a list of server supported types. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
820
|
+
responses: {
|
|
821
|
+
"200": {
|
|
822
|
+
description: "SCIM metadata object",
|
|
823
|
+
content: {
|
|
824
|
+
"application/json": {
|
|
825
|
+
schema: {
|
|
826
|
+
type: "object",
|
|
827
|
+
properties: {
|
|
828
|
+
totalResults: { type: "number" },
|
|
829
|
+
itemsPerPage: { type: "number" },
|
|
830
|
+
startIndex: { type: "number" },
|
|
831
|
+
Resources: {
|
|
832
|
+
type: "array",
|
|
833
|
+
items: ResourceTypeOpenAPISchema,
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
...SCIMErrorOpenAPISchemas,
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
},
|
|
845
|
+
async (ctx) => {
|
|
846
|
+
return ctx.json({
|
|
847
|
+
totalResults: supportedSCIMResourceTypes.length,
|
|
848
|
+
itemsPerPage: supportedSCIMResourceTypes.length,
|
|
849
|
+
startIndex: 1,
|
|
850
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
|
851
|
+
Resources: supportedSCIMResourceTypes.map((s) => {
|
|
852
|
+
return {
|
|
853
|
+
...s,
|
|
854
|
+
meta: {
|
|
855
|
+
...s.meta,
|
|
856
|
+
location: getResourceURL(s.meta.location, ctx.context.baseURL),
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
}),
|
|
860
|
+
});
|
|
861
|
+
},
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
export const getSCIMResourceType = createAuthEndpoint(
|
|
865
|
+
"/scim/v2/ResourceTypes/:resourceTypeId",
|
|
866
|
+
{
|
|
867
|
+
method: "GET",
|
|
868
|
+
metadata: {
|
|
869
|
+
...HIDE_METADATA,
|
|
870
|
+
allowedMediaTypes: supportedMediaTypes,
|
|
871
|
+
openapi: {
|
|
872
|
+
summary: "SCIM Service Provider Supported Resource Type",
|
|
873
|
+
description:
|
|
874
|
+
"Standard SCIM metadata endpoint used by identity providers to get a server supported type. See https://datatracker.ietf.org/doc/html/rfc7644#section-4",
|
|
875
|
+
responses: {
|
|
876
|
+
"200": {
|
|
877
|
+
description: "SCIM metadata object",
|
|
878
|
+
content: {
|
|
879
|
+
"application/json": {
|
|
880
|
+
schema: ResourceTypeOpenAPISchema,
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
...SCIMErrorOpenAPISchemas,
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
async (ctx) => {
|
|
890
|
+
const resourceType = supportedSCIMResourceTypes.find(
|
|
891
|
+
(s) => s.id === ctx.params.resourceTypeId,
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
if (!resourceType) {
|
|
895
|
+
throw new SCIMAPIError("NOT_FOUND", {
|
|
896
|
+
detail: "Resource type not found",
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return ctx.json({
|
|
901
|
+
...resourceType,
|
|
902
|
+
meta: {
|
|
903
|
+
...resourceType.meta,
|
|
904
|
+
location: getResourceURL(
|
|
905
|
+
resourceType.meta.location,
|
|
906
|
+
ctx.context.baseURL,
|
|
907
|
+
),
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
},
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
const findUserById = async (
|
|
914
|
+
adapter: DBAdapter,
|
|
915
|
+
{
|
|
916
|
+
userId,
|
|
917
|
+
providerId,
|
|
918
|
+
organizationId,
|
|
919
|
+
}: { userId: string; providerId: string; organizationId?: string },
|
|
920
|
+
) => {
|
|
921
|
+
const account = await adapter.findOne<Account>({
|
|
922
|
+
model: "account",
|
|
923
|
+
where: [
|
|
924
|
+
{ field: "userId", value: userId },
|
|
925
|
+
{ field: "providerId", value: providerId },
|
|
926
|
+
],
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
// Disallows access to the resource
|
|
930
|
+
// Account is not associated to the provider
|
|
931
|
+
|
|
932
|
+
if (!account) {
|
|
933
|
+
return { user: null, account: null };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
let member: Member | null = null;
|
|
937
|
+
if (organizationId) {
|
|
938
|
+
member = await adapter.findOne<Member>({
|
|
939
|
+
model: "member",
|
|
940
|
+
where: [
|
|
941
|
+
{ field: "organizationId", value: organizationId },
|
|
942
|
+
{ field: "userId", value: userId },
|
|
943
|
+
],
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Disallows access to the resource
|
|
948
|
+
// Token is restricted to an org and the member is not part of it
|
|
949
|
+
|
|
950
|
+
if (organizationId && !member) {
|
|
951
|
+
return { user: null, account: null };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const user = await adapter.findOne<User>({
|
|
955
|
+
model: "user",
|
|
956
|
+
where: [{ field: "id", value: userId }],
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
if (!user) {
|
|
960
|
+
return { user: null, account: null };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
return { user, account };
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const parseSCIMAPIUserFilter = (filter?: string) => {
|
|
967
|
+
let filters: DBFilter[] = [];
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
filters = filter ? parseSCIMUserFilter(filter) : [];
|
|
971
|
+
} catch (error) {
|
|
972
|
+
throw new SCIMAPIError("BAD_REQUEST", {
|
|
973
|
+
detail:
|
|
974
|
+
error instanceof SCIMParseError ? error.message : "Invalid SCIM filter",
|
|
975
|
+
scimType: "invalidFilter",
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return filters;
|
|
980
|
+
};
|