@web-ts-toolkit/moo 0.39.0 → 0.40.0
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/README.md +5 -7
- package/llms.txt +13 -3
- package/package.json +3 -3
- package/plugins/keycloak-user-sync.d.mts +5 -3
- package/plugins/keycloak-user-sync.d.ts +5 -3
- package/plugins/keycloak-user-sync.js +31 -101
- package/plugins/keycloak-user-sync.mjs +33 -100
package/README.md
CHANGED
|
@@ -101,16 +101,12 @@ pnpm add @egose/keycloak-fluent
|
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
```ts
|
|
104
|
-
import KeycloakAdminClientFluent from '@egose/keycloak-fluent';
|
|
105
104
|
import { Schema } from 'mongoose';
|
|
106
|
-
import { keycloakUserSyncPlugin } from '@web-ts-toolkit/moo/plugins/keycloak-user-sync';
|
|
105
|
+
import { createManagedKeycloakClient, keycloakUserSyncPlugin } from '@web-ts-toolkit/moo/plugins/keycloak-user-sync';
|
|
107
106
|
|
|
108
|
-
const keycloak =
|
|
107
|
+
const keycloak = createManagedKeycloakClient({
|
|
109
108
|
baseUrl: process.env.KEYCLOAK_URL,
|
|
110
|
-
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
await keycloak.simpleAuth({
|
|
109
|
+
authRealm: 'master',
|
|
114
110
|
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
|
115
111
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
|
116
112
|
});
|
|
@@ -161,6 +157,8 @@ userSchema.plugin(keycloakUserSyncPlugin, {
|
|
|
161
157
|
});
|
|
162
158
|
```
|
|
163
159
|
|
|
160
|
+
`createManagedKeycloakClient` is provided by `@egose/keycloak-fluent` and re-exported here for convenience. It authenticates lazily, shares one authentication attempt across concurrent requests, and creates no timer, so the same client can be declared outside a serverless handler and reused by warm invocations. The default mode is `client_credentials`; pass `authMode: 'user_credentials'` with lazy `username` and `password` resolvers when direct user credentials are required. Service accounts remain recommended for background synchronization.
|
|
161
|
+
|
|
164
162
|
The plugin:
|
|
165
163
|
|
|
166
164
|
- creates or updates a Keycloak user after a document `save()`
|
package/llms.txt
CHANGED
|
@@ -64,10 +64,19 @@ userSchema.plugin(newDocumentPlugin, {
|
|
|
64
64
|
Keycloak user-sync plugin:
|
|
65
65
|
|
|
66
66
|
```ts
|
|
67
|
-
import {
|
|
67
|
+
import {
|
|
68
|
+
createManagedKeycloakClient,
|
|
69
|
+
keycloakUserSyncPlugin,
|
|
70
|
+
} from '@web-ts-toolkit/moo/plugins/keycloak-user-sync';
|
|
71
|
+
|
|
72
|
+
const keycloak = createManagedKeycloakClient({
|
|
73
|
+
baseUrl: process.env.KEYCLOAK_URL!,
|
|
74
|
+
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
|
75
|
+
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
|
76
|
+
});
|
|
68
77
|
|
|
69
78
|
userSchema.plugin(keycloakUserSyncPlugin, {
|
|
70
|
-
client:
|
|
79
|
+
client: keycloak,
|
|
71
80
|
realm: 'application',
|
|
72
81
|
identifyBy: ['providerId', 'username', 'email'],
|
|
73
82
|
managedRoles: ['admin', 'editor', 'viewer'],
|
|
@@ -91,6 +100,7 @@ userSchema.plugin(keycloakUserSyncPlugin, {
|
|
|
91
100
|
- optional peer dependency: `@egose/keycloak-fluent ^0.7` only for `@web-ts-toolkit/moo/plugins/keycloak-user-sync`
|
|
92
101
|
- root entrypoint exports schema helpers, `isObjectId`, and non-Keycloak plugins; dedicated plugin subpaths are also available
|
|
93
102
|
- Keycloak user sync is not re-exported from root or `@web-ts-toolkit/moo/plugins`; import it from the direct subpath
|
|
103
|
+
- `createManagedKeycloakClient` lazily uses `client_credentials`, renews on demand without timers, and deduplicates concurrent authentication; construct a client directly for custom grants
|
|
94
104
|
- `isObjectId(...)` is a strict guard: it rejects strings that `ObjectId.isValid` accepts but that don't round-trip back to the same string
|
|
95
105
|
- `uniqueNullableString`/`uniqueEmptiableString` return partial-index field definitions, not full schemas
|
|
96
106
|
- new-document plugin runs its callback only after the first successful `save()` of a newly inserted document
|
|
@@ -112,7 +122,7 @@ userSchema.plugin(keycloakUserSyncPlugin, {
|
|
|
112
122
|
- `@web-ts-toolkit/moo/plugins/cascade-delete` — `cascadeDeletePlugin`
|
|
113
123
|
- `@web-ts-toolkit/moo/plugins/model-function` — `modelFunctionPlugin`
|
|
114
124
|
- `@web-ts-toolkit/moo/plugins/new-document` — `newDocumentPlugin`
|
|
115
|
-
- `@web-ts-toolkit/moo/plugins/keycloak-user-sync` — `keycloakUserSyncPlugin`
|
|
125
|
+
- `@web-ts-toolkit/moo/plugins/keycloak-user-sync` — `createManagedKeycloakClient`, `keycloakUserSyncPlugin`
|
|
116
126
|
|
|
117
127
|
## Pointers
|
|
118
128
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@web-ts-toolkit/moo",
|
|
3
3
|
"description": "Mongoose helpers for schema fields, ObjectId checks, and document plugins",
|
|
4
4
|
"homepage": "https://web-ts-toolkit.pages.dev/docs/packages/moo",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.40.0",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"keywords": [
|
|
8
8
|
"mongoose",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"node": ">=22"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@egose/keycloak-fluent": "^0.
|
|
77
|
+
"@egose/keycloak-fluent": "^0.12.0",
|
|
78
78
|
"mongoose": ">=8.0.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
}
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@web-ts-toolkit/utils": "0.
|
|
86
|
+
"@web-ts-toolkit/utils": "0.40.0"
|
|
87
87
|
},
|
|
88
88
|
"files": [
|
|
89
89
|
"**/*",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import KeycloakAdminClientFluent from '@egose/keycloak-fluent';
|
|
1
|
+
import KeycloakAdminClientFluent, { ManagedKeycloakCredential } from '@egose/keycloak-fluent';
|
|
2
|
+
export { ManagedKeycloakClientOptions, createManagedKeycloakClient } from '@egose/keycloak-fluent';
|
|
2
3
|
import { Schema } from 'mongoose';
|
|
3
4
|
|
|
5
|
+
type ManagedKeycloakClientSecret = ManagedKeycloakCredential;
|
|
4
6
|
type KeycloakUserIdentityField = 'providerId' | 'username' | 'email';
|
|
5
7
|
type KeycloakUserSyncField = 'username' | 'email' | 'emailVerified' | 'firstName' | 'lastName' | 'enabled' | 'roles' | 'attributes' | 'password';
|
|
6
8
|
interface KeycloakUserSyncPaths {
|
|
@@ -32,7 +34,7 @@ interface KeycloakUserSyncDocument {
|
|
|
32
34
|
get(path: string): unknown;
|
|
33
35
|
}
|
|
34
36
|
interface KeycloakUserSyncPluginOptions {
|
|
35
|
-
/**
|
|
37
|
+
/** A client that can provide admin access tokens. Use createManagedKeycloakClient for lazy managed authentication. */
|
|
36
38
|
client: KeycloakAdminClientFluent;
|
|
37
39
|
realm: string;
|
|
38
40
|
/**
|
|
@@ -85,4 +87,4 @@ interface KeycloakUserSyncPluginOptions {
|
|
|
85
87
|
*/
|
|
86
88
|
declare function keycloakUserSyncPlugin(schema: Schema, rawOptions: KeycloakUserSyncPluginOptions): void;
|
|
87
89
|
|
|
88
|
-
export { type KeycloakUserIdentityField, type KeycloakUserSyncDocument, type KeycloakUserSyncErrorContext, type KeycloakUserSyncField, type KeycloakUserSyncLogger, type KeycloakUserSyncPaths, type KeycloakUserSyncPluginOptions, type KeycloakUserSyncSensitiveErrorContext, keycloakUserSyncPlugin };
|
|
90
|
+
export { type KeycloakUserIdentityField, type KeycloakUserSyncDocument, type KeycloakUserSyncErrorContext, type KeycloakUserSyncField, type KeycloakUserSyncLogger, type KeycloakUserSyncPaths, type KeycloakUserSyncPluginOptions, type KeycloakUserSyncSensitiveErrorContext, type ManagedKeycloakClientSecret, keycloakUserSyncPlugin };
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import KeycloakAdminClientFluent from '@egose/keycloak-fluent';
|
|
1
|
+
import KeycloakAdminClientFluent, { ManagedKeycloakCredential } from '@egose/keycloak-fluent';
|
|
2
|
+
export { ManagedKeycloakClientOptions, createManagedKeycloakClient } from '@egose/keycloak-fluent';
|
|
2
3
|
import { Schema } from 'mongoose';
|
|
3
4
|
|
|
5
|
+
type ManagedKeycloakClientSecret = ManagedKeycloakCredential;
|
|
4
6
|
type KeycloakUserIdentityField = 'providerId' | 'username' | 'email';
|
|
5
7
|
type KeycloakUserSyncField = 'username' | 'email' | 'emailVerified' | 'firstName' | 'lastName' | 'enabled' | 'roles' | 'attributes' | 'password';
|
|
6
8
|
interface KeycloakUserSyncPaths {
|
|
@@ -32,7 +34,7 @@ interface KeycloakUserSyncDocument {
|
|
|
32
34
|
get(path: string): unknown;
|
|
33
35
|
}
|
|
34
36
|
interface KeycloakUserSyncPluginOptions {
|
|
35
|
-
/**
|
|
37
|
+
/** A client that can provide admin access tokens. Use createManagedKeycloakClient for lazy managed authentication. */
|
|
36
38
|
client: KeycloakAdminClientFluent;
|
|
37
39
|
realm: string;
|
|
38
40
|
/**
|
|
@@ -85,4 +87,4 @@ interface KeycloakUserSyncPluginOptions {
|
|
|
85
87
|
*/
|
|
86
88
|
declare function keycloakUserSyncPlugin(schema: Schema, rawOptions: KeycloakUserSyncPluginOptions): void;
|
|
87
89
|
|
|
88
|
-
export { type KeycloakUserIdentityField, type KeycloakUserSyncDocument, type KeycloakUserSyncErrorContext, type KeycloakUserSyncField, type KeycloakUserSyncLogger, type KeycloakUserSyncPaths, type KeycloakUserSyncPluginOptions, type KeycloakUserSyncSensitiveErrorContext, keycloakUserSyncPlugin };
|
|
90
|
+
export { type KeycloakUserIdentityField, type KeycloakUserSyncDocument, type KeycloakUserSyncErrorContext, type KeycloakUserSyncField, type KeycloakUserSyncLogger, type KeycloakUserSyncPaths, type KeycloakUserSyncPluginOptions, type KeycloakUserSyncSensitiveErrorContext, type ManagedKeycloakClientSecret, keycloakUserSyncPlugin };
|
|
@@ -20,60 +20,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/plugins/keycloak-user-sync.ts
|
|
21
21
|
var keycloak_user_sync_exports = {};
|
|
22
22
|
__export(keycloak_user_sync_exports, {
|
|
23
|
+
createManagedKeycloakClient: () => import_keycloak_fluent.createManagedKeycloakClient,
|
|
23
24
|
keycloakUserSyncPlugin: () => keycloakUserSyncPlugin
|
|
24
25
|
});
|
|
25
26
|
module.exports = __toCommonJS(keycloak_user_sync_exports);
|
|
26
|
-
|
|
27
|
-
// src/plugins/keycloak-user-sync/adapter.ts
|
|
28
|
-
var createKeycloakUserSyncAdapter = (client, realm) => {
|
|
29
|
-
const supportedClient = client;
|
|
30
|
-
const realmHandle = () => supportedClient.realm(realm);
|
|
31
|
-
const users = supportedClient.core.users;
|
|
32
|
-
return {
|
|
33
|
-
async getRealm() {
|
|
34
|
-
return realmHandle().get();
|
|
35
|
-
},
|
|
36
|
-
async getUserById(username, id) {
|
|
37
|
-
return realmHandle().user(username).getById(id);
|
|
38
|
-
},
|
|
39
|
-
async getUserByUsername(username) {
|
|
40
|
-
return realmHandle().user(username).get();
|
|
41
|
-
},
|
|
42
|
-
async findUsersByEmail(email) {
|
|
43
|
-
return users.find({ realm, email, exact: true, first: 0, max: 2 });
|
|
44
|
-
},
|
|
45
|
-
async createUser(username, payload) {
|
|
46
|
-
return realmHandle().user(username).create(payload);
|
|
47
|
-
},
|
|
48
|
-
async updateUser(id, payload) {
|
|
49
|
-
await users.update({ realm, id }, payload);
|
|
50
|
-
},
|
|
51
|
-
async deleteUser(id) {
|
|
52
|
-
await users.del({ realm, id });
|
|
53
|
-
},
|
|
54
|
-
async sendVerifyEmail(id) {
|
|
55
|
-
await users.sendVerifyEmail({ realm, id });
|
|
56
|
-
},
|
|
57
|
-
async resetPassword(id, value, temporary) {
|
|
58
|
-
await users.resetPassword({ realm, id, credential: { temporary, type: "password", value } });
|
|
59
|
-
},
|
|
60
|
-
async ensureRole(name) {
|
|
61
|
-
await realmHandle().role(name).ensure({});
|
|
62
|
-
},
|
|
63
|
-
async getRole(name) {
|
|
64
|
-
return realmHandle().role(name).get();
|
|
65
|
-
},
|
|
66
|
-
async listRealmRoleMappings(id) {
|
|
67
|
-
return users.listRealmRoleMappings({ realm, id });
|
|
68
|
-
},
|
|
69
|
-
async addRealmRoleMappings(id, roles) {
|
|
70
|
-
await users.addRealmRoleMappings({ realm, id, roles });
|
|
71
|
-
},
|
|
72
|
-
async delRealmRoleMappings(id, roles) {
|
|
73
|
-
await users.delRealmRoleMappings({ realm, id, roles });
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
};
|
|
27
|
+
var import_keycloak_fluent = require("@egose/keycloak-fluent");
|
|
77
28
|
|
|
78
29
|
// src/plugins/keycloak-user-sync/planner.ts
|
|
79
30
|
var dangerousAttributeKeys = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
@@ -222,18 +173,6 @@ var planEmailVerification = ({
|
|
|
222
173
|
initialLinkSameEmail
|
|
223
174
|
};
|
|
224
175
|
};
|
|
225
|
-
var planRoleDiff = (desiredNames, assignedRoles, desiredRoles, managedRoles) => {
|
|
226
|
-
const managedNames = managedRoles ? new Set(managedRoles) : null;
|
|
227
|
-
const effectiveDesiredNames = managedNames ? desiredNames.filter((name) => managedNames.has(name)) : desiredNames;
|
|
228
|
-
const assignedNames = new Set(assignedRoles.map((role) => role.name).filter((name) => Boolean(name)));
|
|
229
|
-
const desiredNameSet = new Set(effectiveDesiredNames);
|
|
230
|
-
return {
|
|
231
|
-
desiredNames,
|
|
232
|
-
effectiveDesiredNames,
|
|
233
|
-
toAdd: desiredRoles.filter((role) => role.name && !assignedNames.has(role.name)),
|
|
234
|
-
toRemove: managedNames ? assignedRoles.filter((role) => role.name && !desiredNameSet.has(role.name) && managedNames.has(role.name)) : []
|
|
235
|
-
};
|
|
236
|
-
};
|
|
237
176
|
|
|
238
177
|
// src/plugins/keycloak-user-sync.ts
|
|
239
178
|
var syncStateKey = "keycloakUserSync";
|
|
@@ -413,7 +352,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
413
352
|
const paths = options.paths;
|
|
414
353
|
const syncFields = options.syncFields;
|
|
415
354
|
const identities = options.identifyBy;
|
|
416
|
-
const
|
|
355
|
+
const realmHandle = options.client.realm(options.realm);
|
|
417
356
|
const trackedPaths = buildTrackedPaths(options);
|
|
418
357
|
const buildSafeErrorContext = (operation, document) => ({
|
|
419
358
|
operation,
|
|
@@ -437,13 +376,13 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
437
376
|
const getPreviousValue = (previous, field) => getPathValue(previous, paths[field]);
|
|
438
377
|
const getUsername = (document) => stringValue(readDocumentValue(document, "username")) ?? stringValue(readDocumentValue(document, "email"));
|
|
439
378
|
const resolveUser = async (document, previous) => {
|
|
440
|
-
const realm = await
|
|
379
|
+
const realm = await realmHandle.get();
|
|
441
380
|
if (!realm) throw new Error(`Keycloak realm "${options.realm}" was not found`);
|
|
442
381
|
const duplicateEmailsAllowed = realm.duplicateEmailsAllowed === true;
|
|
443
382
|
const orderedIdentities = duplicateEmailsAllowed ? ["providerId", "username", "email"].filter((identity) => identities.includes(identity)) : identities;
|
|
444
383
|
const getCompleteUserById = async (user) => {
|
|
445
384
|
if (!user.id) return user;
|
|
446
|
-
return await
|
|
385
|
+
return await realmHandle.userById(user.id).get() ?? user;
|
|
447
386
|
};
|
|
448
387
|
for (const identity of orderedIdentities) {
|
|
449
388
|
if (identity === "providerId") {
|
|
@@ -451,7 +390,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
451
390
|
readDocumentValue(document, "providerId"),
|
|
452
391
|
getPreviousValue(previous, "providerId")
|
|
453
392
|
])) {
|
|
454
|
-
const user = await
|
|
393
|
+
const user = await realmHandle.userById(providerId).get();
|
|
455
394
|
if (user) return { user, duplicateEmailsAllowed };
|
|
456
395
|
}
|
|
457
396
|
}
|
|
@@ -460,7 +399,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
460
399
|
readDocumentValue(document, "username"),
|
|
461
400
|
getPreviousValue(previous, "username")
|
|
462
401
|
])) {
|
|
463
|
-
const user = await
|
|
402
|
+
const user = await realmHandle.user(username).get();
|
|
464
403
|
if (user) return { user, duplicateEmailsAllowed };
|
|
465
404
|
}
|
|
466
405
|
}
|
|
@@ -469,9 +408,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
469
408
|
readDocumentValue(document, "email"),
|
|
470
409
|
getPreviousValue(previous, "email")
|
|
471
410
|
])) {
|
|
472
|
-
const matches = (await
|
|
473
|
-
(user) => normalizeEmail(user.email) === normalizeEmail(email)
|
|
474
|
-
);
|
|
411
|
+
const matches = (await realmHandle.searchUsers(email, { attribute: "email", exact: true, first: 0, max: 2 })).filter((user) => normalizeEmail(user.email) === normalizeEmail(email));
|
|
475
412
|
if (matches.length > 1) {
|
|
476
413
|
throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
|
|
477
414
|
}
|
|
@@ -482,17 +419,15 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
482
419
|
return { user: null, duplicateEmailsAllowed };
|
|
483
420
|
};
|
|
484
421
|
const resolveCreatedUser = async (document, username) => {
|
|
485
|
-
const userByUsername = await
|
|
422
|
+
const userByUsername = await realmHandle.user(username).get();
|
|
486
423
|
if (userByUsername) return userByUsername;
|
|
487
424
|
const email = stringValue(readDocumentValue(document, "email"));
|
|
488
425
|
if (!email) return null;
|
|
489
|
-
const matches = (await
|
|
490
|
-
(user) => normalizeEmail(user.email) === normalizeEmail(email)
|
|
491
|
-
);
|
|
426
|
+
const matches = (await realmHandle.searchUsers(email, { attribute: "email", exact: true, first: 0, max: 2 })).filter((user) => normalizeEmail(user.email) === normalizeEmail(email));
|
|
492
427
|
if (matches.length > 1) throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
|
|
493
428
|
const match = matches[0];
|
|
494
429
|
if (!match?.id) return match ?? null;
|
|
495
|
-
return await
|
|
430
|
+
return await realmHandle.userById(match.id).get() ?? match;
|
|
496
431
|
};
|
|
497
432
|
const syncRoles = async (user, document) => {
|
|
498
433
|
if (!syncFields.roles || !user.id) return;
|
|
@@ -502,24 +437,12 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
502
437
|
if (desiredNames.length > maxRolesPerSync) {
|
|
503
438
|
throw new Error(`keycloakUserSyncPlugin supports at most ${maxRolesPerSync} desired roles per sync operation`);
|
|
504
439
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const role = await adapter.getRole(roleName);
|
|
512
|
-
if (!role?.id) throw new Error(`Keycloak realm role "${roleName}" was not found`);
|
|
513
|
-
desiredRoles.push(role);
|
|
514
|
-
}
|
|
515
|
-
const assignedRoles = await adapter.listRealmRoleMappings(user.id);
|
|
516
|
-
const { toAdd, toRemove } = planRoleDiff(desiredNames, assignedRoles, desiredRoles, options.managedRoles);
|
|
517
|
-
if (toAdd.length) {
|
|
518
|
-
await adapter.addRealmRoleMappings(user.id, toAdd);
|
|
519
|
-
}
|
|
520
|
-
if (toRemove.length) {
|
|
521
|
-
await adapter.delRealmRoleMappings(user.id, toRemove);
|
|
522
|
-
}
|
|
440
|
+
if (desiredNames.length === 0 && !options.managedRoles) return;
|
|
441
|
+
await realmHandle.userById(user.id).reconcileRealmRoles(desiredNames, {
|
|
442
|
+
ensureMissing: options.ensureRoles !== false,
|
|
443
|
+
managedRoleNames: options.managedRoles,
|
|
444
|
+
maxRoles: maxRolesPerSync
|
|
445
|
+
});
|
|
523
446
|
};
|
|
524
447
|
const persistProviderId = async (document, providerId) => {
|
|
525
448
|
if (options.persistProviderId === false || readDocumentValue(document, "providerId") === providerId) return;
|
|
@@ -546,14 +469,20 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
546
469
|
if (!username) throw new Error("Cannot create a Keycloak user without a username or email");
|
|
547
470
|
payload.attributes = mergeAttributes(null, document, options);
|
|
548
471
|
try {
|
|
549
|
-
user = await
|
|
472
|
+
user = await realmHandle.user(username).create({
|
|
473
|
+
...payload,
|
|
474
|
+
...desiredPassword && {
|
|
475
|
+
password: desiredPassword,
|
|
476
|
+
passwordTemporary: options.passwordTemporary ?? false
|
|
477
|
+
}
|
|
478
|
+
}) ?? null;
|
|
479
|
+
created = true;
|
|
550
480
|
} catch (error) {
|
|
551
481
|
if (identities.length === 0) throw error;
|
|
552
482
|
const recoveredUser = await resolveCreatedUser(document, username);
|
|
553
483
|
if (!recoveredUser) throw error;
|
|
554
484
|
user = recoveredUser;
|
|
555
485
|
}
|
|
556
|
-
created = true;
|
|
557
486
|
}
|
|
558
487
|
if (!user?.id) throw new Error("Keycloak did not return a user ID");
|
|
559
488
|
const userId = user.id;
|
|
@@ -576,21 +505,21 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
576
505
|
}
|
|
577
506
|
if (shouldSyncAttributes) payload.attributes = mergeAttributes(user, document, options);
|
|
578
507
|
if (Object.keys(payload).length > 0) {
|
|
579
|
-
await
|
|
508
|
+
await realmHandle.userById(userId).update(payload);
|
|
580
509
|
user = { ...user, ...payload };
|
|
581
510
|
}
|
|
582
511
|
}
|
|
583
|
-
if (
|
|
584
|
-
await
|
|
512
|
+
if (!created && state.passwordChanged && desiredPassword) {
|
|
513
|
+
await realmHandle.userById(userId).resetPassword(desiredPassword, { temporary: options.passwordTemporary ?? false });
|
|
585
514
|
}
|
|
586
515
|
if (created || state.changedFields.has("roles")) await syncRoles(user, document);
|
|
587
516
|
if (emailVerificationPlan.changed && options.sendVerificationEmailOnChange !== false) {
|
|
588
|
-
await
|
|
517
|
+
await realmHandle.userById(userId).sendVerifyEmail();
|
|
589
518
|
}
|
|
590
519
|
};
|
|
591
520
|
const deleteDocument = async (document) => {
|
|
592
521
|
const { user } = await resolveUser(document);
|
|
593
|
-
if (user?.id) await
|
|
522
|
+
if (user?.id) await realmHandle.userById(user.id).delete();
|
|
594
523
|
};
|
|
595
524
|
schema.pre("save", async function keycloakUserSyncPreSave() {
|
|
596
525
|
assertNoDocumentSession(this, "save");
|
|
@@ -640,5 +569,6 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
640
569
|
}
|
|
641
570
|
// Annotate the CommonJS export names for ESM import in node:
|
|
642
571
|
0 && (module.exports = {
|
|
572
|
+
createManagedKeycloakClient,
|
|
643
573
|
keycloakUserSyncPlugin
|
|
644
574
|
});
|
|
@@ -1,53 +1,7 @@
|
|
|
1
|
-
// src/plugins/keycloak-user-sync
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const users = supportedClient.core.users;
|
|
6
|
-
return {
|
|
7
|
-
async getRealm() {
|
|
8
|
-
return realmHandle().get();
|
|
9
|
-
},
|
|
10
|
-
async getUserById(username, id) {
|
|
11
|
-
return realmHandle().user(username).getById(id);
|
|
12
|
-
},
|
|
13
|
-
async getUserByUsername(username) {
|
|
14
|
-
return realmHandle().user(username).get();
|
|
15
|
-
},
|
|
16
|
-
async findUsersByEmail(email) {
|
|
17
|
-
return users.find({ realm, email, exact: true, first: 0, max: 2 });
|
|
18
|
-
},
|
|
19
|
-
async createUser(username, payload) {
|
|
20
|
-
return realmHandle().user(username).create(payload);
|
|
21
|
-
},
|
|
22
|
-
async updateUser(id, payload) {
|
|
23
|
-
await users.update({ realm, id }, payload);
|
|
24
|
-
},
|
|
25
|
-
async deleteUser(id) {
|
|
26
|
-
await users.del({ realm, id });
|
|
27
|
-
},
|
|
28
|
-
async sendVerifyEmail(id) {
|
|
29
|
-
await users.sendVerifyEmail({ realm, id });
|
|
30
|
-
},
|
|
31
|
-
async resetPassword(id, value, temporary) {
|
|
32
|
-
await users.resetPassword({ realm, id, credential: { temporary, type: "password", value } });
|
|
33
|
-
},
|
|
34
|
-
async ensureRole(name) {
|
|
35
|
-
await realmHandle().role(name).ensure({});
|
|
36
|
-
},
|
|
37
|
-
async getRole(name) {
|
|
38
|
-
return realmHandle().role(name).get();
|
|
39
|
-
},
|
|
40
|
-
async listRealmRoleMappings(id) {
|
|
41
|
-
return users.listRealmRoleMappings({ realm, id });
|
|
42
|
-
},
|
|
43
|
-
async addRealmRoleMappings(id, roles) {
|
|
44
|
-
await users.addRealmRoleMappings({ realm, id, roles });
|
|
45
|
-
},
|
|
46
|
-
async delRealmRoleMappings(id, roles) {
|
|
47
|
-
await users.delRealmRoleMappings({ realm, id, roles });
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
};
|
|
1
|
+
// src/plugins/keycloak-user-sync.ts
|
|
2
|
+
import {
|
|
3
|
+
createManagedKeycloakClient
|
|
4
|
+
} from "@egose/keycloak-fluent";
|
|
51
5
|
|
|
52
6
|
// src/plugins/keycloak-user-sync/planner.ts
|
|
53
7
|
var dangerousAttributeKeys = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
@@ -196,18 +150,6 @@ var planEmailVerification = ({
|
|
|
196
150
|
initialLinkSameEmail
|
|
197
151
|
};
|
|
198
152
|
};
|
|
199
|
-
var planRoleDiff = (desiredNames, assignedRoles, desiredRoles, managedRoles) => {
|
|
200
|
-
const managedNames = managedRoles ? new Set(managedRoles) : null;
|
|
201
|
-
const effectiveDesiredNames = managedNames ? desiredNames.filter((name) => managedNames.has(name)) : desiredNames;
|
|
202
|
-
const assignedNames = new Set(assignedRoles.map((role) => role.name).filter((name) => Boolean(name)));
|
|
203
|
-
const desiredNameSet = new Set(effectiveDesiredNames);
|
|
204
|
-
return {
|
|
205
|
-
desiredNames,
|
|
206
|
-
effectiveDesiredNames,
|
|
207
|
-
toAdd: desiredRoles.filter((role) => role.name && !assignedNames.has(role.name)),
|
|
208
|
-
toRemove: managedNames ? assignedRoles.filter((role) => role.name && !desiredNameSet.has(role.name) && managedNames.has(role.name)) : []
|
|
209
|
-
};
|
|
210
|
-
};
|
|
211
153
|
|
|
212
154
|
// src/plugins/keycloak-user-sync.ts
|
|
213
155
|
var syncStateKey = "keycloakUserSync";
|
|
@@ -387,7 +329,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
387
329
|
const paths = options.paths;
|
|
388
330
|
const syncFields = options.syncFields;
|
|
389
331
|
const identities = options.identifyBy;
|
|
390
|
-
const
|
|
332
|
+
const realmHandle = options.client.realm(options.realm);
|
|
391
333
|
const trackedPaths = buildTrackedPaths(options);
|
|
392
334
|
const buildSafeErrorContext = (operation, document) => ({
|
|
393
335
|
operation,
|
|
@@ -411,13 +353,13 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
411
353
|
const getPreviousValue = (previous, field) => getPathValue(previous, paths[field]);
|
|
412
354
|
const getUsername = (document) => stringValue(readDocumentValue(document, "username")) ?? stringValue(readDocumentValue(document, "email"));
|
|
413
355
|
const resolveUser = async (document, previous) => {
|
|
414
|
-
const realm = await
|
|
356
|
+
const realm = await realmHandle.get();
|
|
415
357
|
if (!realm) throw new Error(`Keycloak realm "${options.realm}" was not found`);
|
|
416
358
|
const duplicateEmailsAllowed = realm.duplicateEmailsAllowed === true;
|
|
417
359
|
const orderedIdentities = duplicateEmailsAllowed ? ["providerId", "username", "email"].filter((identity) => identities.includes(identity)) : identities;
|
|
418
360
|
const getCompleteUserById = async (user) => {
|
|
419
361
|
if (!user.id) return user;
|
|
420
|
-
return await
|
|
362
|
+
return await realmHandle.userById(user.id).get() ?? user;
|
|
421
363
|
};
|
|
422
364
|
for (const identity of orderedIdentities) {
|
|
423
365
|
if (identity === "providerId") {
|
|
@@ -425,7 +367,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
425
367
|
readDocumentValue(document, "providerId"),
|
|
426
368
|
getPreviousValue(previous, "providerId")
|
|
427
369
|
])) {
|
|
428
|
-
const user = await
|
|
370
|
+
const user = await realmHandle.userById(providerId).get();
|
|
429
371
|
if (user) return { user, duplicateEmailsAllowed };
|
|
430
372
|
}
|
|
431
373
|
}
|
|
@@ -434,7 +376,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
434
376
|
readDocumentValue(document, "username"),
|
|
435
377
|
getPreviousValue(previous, "username")
|
|
436
378
|
])) {
|
|
437
|
-
const user = await
|
|
379
|
+
const user = await realmHandle.user(username).get();
|
|
438
380
|
if (user) return { user, duplicateEmailsAllowed };
|
|
439
381
|
}
|
|
440
382
|
}
|
|
@@ -443,9 +385,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
443
385
|
readDocumentValue(document, "email"),
|
|
444
386
|
getPreviousValue(previous, "email")
|
|
445
387
|
])) {
|
|
446
|
-
const matches = (await
|
|
447
|
-
(user) => normalizeEmail(user.email) === normalizeEmail(email)
|
|
448
|
-
);
|
|
388
|
+
const matches = (await realmHandle.searchUsers(email, { attribute: "email", exact: true, first: 0, max: 2 })).filter((user) => normalizeEmail(user.email) === normalizeEmail(email));
|
|
449
389
|
if (matches.length > 1) {
|
|
450
390
|
throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
|
|
451
391
|
}
|
|
@@ -456,17 +396,15 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
456
396
|
return { user: null, duplicateEmailsAllowed };
|
|
457
397
|
};
|
|
458
398
|
const resolveCreatedUser = async (document, username) => {
|
|
459
|
-
const userByUsername = await
|
|
399
|
+
const userByUsername = await realmHandle.user(username).get();
|
|
460
400
|
if (userByUsername) return userByUsername;
|
|
461
401
|
const email = stringValue(readDocumentValue(document, "email"));
|
|
462
402
|
if (!email) return null;
|
|
463
|
-
const matches = (await
|
|
464
|
-
(user) => normalizeEmail(user.email) === normalizeEmail(email)
|
|
465
|
-
);
|
|
403
|
+
const matches = (await realmHandle.searchUsers(email, { attribute: "email", exact: true, first: 0, max: 2 })).filter((user) => normalizeEmail(user.email) === normalizeEmail(email));
|
|
466
404
|
if (matches.length > 1) throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
|
|
467
405
|
const match = matches[0];
|
|
468
406
|
if (!match?.id) return match ?? null;
|
|
469
|
-
return await
|
|
407
|
+
return await realmHandle.userById(match.id).get() ?? match;
|
|
470
408
|
};
|
|
471
409
|
const syncRoles = async (user, document) => {
|
|
472
410
|
if (!syncFields.roles || !user.id) return;
|
|
@@ -476,24 +414,12 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
476
414
|
if (desiredNames.length > maxRolesPerSync) {
|
|
477
415
|
throw new Error(`keycloakUserSyncPlugin supports at most ${maxRolesPerSync} desired roles per sync operation`);
|
|
478
416
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const role = await adapter.getRole(roleName);
|
|
486
|
-
if (!role?.id) throw new Error(`Keycloak realm role "${roleName}" was not found`);
|
|
487
|
-
desiredRoles.push(role);
|
|
488
|
-
}
|
|
489
|
-
const assignedRoles = await adapter.listRealmRoleMappings(user.id);
|
|
490
|
-
const { toAdd, toRemove } = planRoleDiff(desiredNames, assignedRoles, desiredRoles, options.managedRoles);
|
|
491
|
-
if (toAdd.length) {
|
|
492
|
-
await adapter.addRealmRoleMappings(user.id, toAdd);
|
|
493
|
-
}
|
|
494
|
-
if (toRemove.length) {
|
|
495
|
-
await adapter.delRealmRoleMappings(user.id, toRemove);
|
|
496
|
-
}
|
|
417
|
+
if (desiredNames.length === 0 && !options.managedRoles) return;
|
|
418
|
+
await realmHandle.userById(user.id).reconcileRealmRoles(desiredNames, {
|
|
419
|
+
ensureMissing: options.ensureRoles !== false,
|
|
420
|
+
managedRoleNames: options.managedRoles,
|
|
421
|
+
maxRoles: maxRolesPerSync
|
|
422
|
+
});
|
|
497
423
|
};
|
|
498
424
|
const persistProviderId = async (document, providerId) => {
|
|
499
425
|
if (options.persistProviderId === false || readDocumentValue(document, "providerId") === providerId) return;
|
|
@@ -520,14 +446,20 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
520
446
|
if (!username) throw new Error("Cannot create a Keycloak user without a username or email");
|
|
521
447
|
payload.attributes = mergeAttributes(null, document, options);
|
|
522
448
|
try {
|
|
523
|
-
user = await
|
|
449
|
+
user = await realmHandle.user(username).create({
|
|
450
|
+
...payload,
|
|
451
|
+
...desiredPassword && {
|
|
452
|
+
password: desiredPassword,
|
|
453
|
+
passwordTemporary: options.passwordTemporary ?? false
|
|
454
|
+
}
|
|
455
|
+
}) ?? null;
|
|
456
|
+
created = true;
|
|
524
457
|
} catch (error) {
|
|
525
458
|
if (identities.length === 0) throw error;
|
|
526
459
|
const recoveredUser = await resolveCreatedUser(document, username);
|
|
527
460
|
if (!recoveredUser) throw error;
|
|
528
461
|
user = recoveredUser;
|
|
529
462
|
}
|
|
530
|
-
created = true;
|
|
531
463
|
}
|
|
532
464
|
if (!user?.id) throw new Error("Keycloak did not return a user ID");
|
|
533
465
|
const userId = user.id;
|
|
@@ -550,21 +482,21 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
550
482
|
}
|
|
551
483
|
if (shouldSyncAttributes) payload.attributes = mergeAttributes(user, document, options);
|
|
552
484
|
if (Object.keys(payload).length > 0) {
|
|
553
|
-
await
|
|
485
|
+
await realmHandle.userById(userId).update(payload);
|
|
554
486
|
user = { ...user, ...payload };
|
|
555
487
|
}
|
|
556
488
|
}
|
|
557
|
-
if (
|
|
558
|
-
await
|
|
489
|
+
if (!created && state.passwordChanged && desiredPassword) {
|
|
490
|
+
await realmHandle.userById(userId).resetPassword(desiredPassword, { temporary: options.passwordTemporary ?? false });
|
|
559
491
|
}
|
|
560
492
|
if (created || state.changedFields.has("roles")) await syncRoles(user, document);
|
|
561
493
|
if (emailVerificationPlan.changed && options.sendVerificationEmailOnChange !== false) {
|
|
562
|
-
await
|
|
494
|
+
await realmHandle.userById(userId).sendVerifyEmail();
|
|
563
495
|
}
|
|
564
496
|
};
|
|
565
497
|
const deleteDocument = async (document) => {
|
|
566
498
|
const { user } = await resolveUser(document);
|
|
567
|
-
if (user?.id) await
|
|
499
|
+
if (user?.id) await realmHandle.userById(user.id).delete();
|
|
568
500
|
};
|
|
569
501
|
schema.pre("save", async function keycloakUserSyncPreSave() {
|
|
570
502
|
assertNoDocumentSession(this, "save");
|
|
@@ -613,5 +545,6 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
|
|
|
613
545
|
);
|
|
614
546
|
}
|
|
615
547
|
export {
|
|
548
|
+
createManagedKeycloakClient,
|
|
616
549
|
keycloakUserSyncPlugin
|
|
617
550
|
};
|