strapi-plugin-keycloak-realm-users 1.0.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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +485 -0
  3. package/__tests__/constants.test.mjs +207 -0
  4. package/__tests__/mocks/strapi.mjs +182 -0
  5. package/__tests__/services/audit-log.test.mjs +283 -0
  6. package/__tests__/services/keycloak-client.test.mjs +651 -0
  7. package/__tests__/services/permission.test.mjs +374 -0
  8. package/__tests__/services/realm.test.mjs +415 -0
  9. package/__tests__/services/user.test.mjs +487 -0
  10. package/__tests__/utils/errors.test.mjs +109 -0
  11. package/admin/src/components/Initializer.jsx +14 -0
  12. package/admin/src/components/RealmBadge.jsx +17 -0
  13. package/admin/src/constants.js +14 -0
  14. package/admin/src/hooks/useAuditLogs.js +142 -0
  15. package/admin/src/hooks/useKeycloakRoles.js +182 -0
  16. package/admin/src/hooks/useKeycloakUsers.js +477 -0
  17. package/admin/src/hooks/useRealmAdmins.js +249 -0
  18. package/admin/src/hooks/useRealms.js +269 -0
  19. package/admin/src/index.js +46 -0
  20. package/admin/src/pages/App.jsx +21 -0
  21. package/admin/src/pages/AuditPage/index.jsx +213 -0
  22. package/admin/src/pages/RealmsPage/RealmEditPage.jsx +791 -0
  23. package/admin/src/pages/RealmsPage/RealmListPage.jsx +231 -0
  24. package/admin/src/pages/RealmsPage/index.jsx +7 -0
  25. package/admin/src/pages/UsersPage/UserEditPage.jsx +313 -0
  26. package/admin/src/pages/UsersPage/UserListPage.jsx +437 -0
  27. package/admin/src/pages/UsersPage/index.jsx +7 -0
  28. package/admin/src/pluginId.js +2 -0
  29. package/admin/src/translations/en.json +77 -0
  30. package/admin/src/translations/fr.json +77 -0
  31. package/babel.config.cjs +17 -0
  32. package/coverage/clover.xml +422 -0
  33. package/coverage/coverage-final.json +8 -0
  34. package/coverage/lcov-report/base.css +224 -0
  35. package/coverage/lcov-report/block-navigation.js +87 -0
  36. package/coverage/lcov-report/favicon.png +0 -0
  37. package/coverage/lcov-report/index.html +146 -0
  38. package/coverage/lcov-report/prettify.css +1 -0
  39. package/coverage/lcov-report/prettify.js +2 -0
  40. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  41. package/coverage/lcov-report/sorter.js +210 -0
  42. package/coverage/lcov-report/src/bootstrap.js.html +346 -0
  43. package/coverage/lcov-report/src/config/index.html +116 -0
  44. package/coverage/lcov-report/src/config/index.js.html +106 -0
  45. package/coverage/lcov-report/src/constants.js.html +850 -0
  46. package/coverage/lcov-report/src/content-types/audit-log/index.html +116 -0
  47. package/coverage/lcov-report/src/content-types/audit-log/index.js.html +94 -0
  48. package/coverage/lcov-report/src/content-types/index.html +116 -0
  49. package/coverage/lcov-report/src/content-types/index.js.html +112 -0
  50. package/coverage/lcov-report/src/content-types/realm-admin/index.html +116 -0
  51. package/coverage/lcov-report/src/content-types/realm-admin/index.js.html +94 -0
  52. package/coverage/lcov-report/src/content-types/realm-config/index.html +116 -0
  53. package/coverage/lcov-report/src/content-types/realm-config/index.js.html +94 -0
  54. package/coverage/lcov-report/src/controllers/audit.js.html +517 -0
  55. package/coverage/lcov-report/src/controllers/index.html +161 -0
  56. package/coverage/lcov-report/src/controllers/index.js.html +112 -0
  57. package/coverage/lcov-report/src/controllers/realm.js.html +1057 -0
  58. package/coverage/lcov-report/src/controllers/user.js.html +1324 -0
  59. package/coverage/lcov-report/src/destroy.js.html +100 -0
  60. package/coverage/lcov-report/src/index.html +116 -0
  61. package/coverage/lcov-report/src/policies/can-access-realm.js.html +163 -0
  62. package/coverage/lcov-report/src/policies/index.html +146 -0
  63. package/coverage/lcov-report/src/policies/index.js.html +106 -0
  64. package/coverage/lcov-report/src/policies/is-authenticated.js.html +100 -0
  65. package/coverage/lcov-report/src/register.js.html +106 -0
  66. package/coverage/lcov-report/src/routes/admin.js.html +844 -0
  67. package/coverage/lcov-report/src/routes/index.html +131 -0
  68. package/coverage/lcov-report/src/routes/index.js.html +109 -0
  69. package/coverage/lcov-report/src/services/audit-log.js.html +673 -0
  70. package/coverage/lcov-report/src/services/index.html +176 -0
  71. package/coverage/lcov-report/src/services/index.js.html +124 -0
  72. package/coverage/lcov-report/src/services/keycloak-client.js.html +2359 -0
  73. package/coverage/lcov-report/src/services/permission.js.html +955 -0
  74. package/coverage/lcov-report/src/services/realm.js.html +1207 -0
  75. package/coverage/lcov-report/src/services/user.js.html +1924 -0
  76. package/coverage/lcov-report/src/utils/errors.js.html +274 -0
  77. package/coverage/lcov-report/src/utils/index.html +116 -0
  78. package/coverage/lcov-report/src/utils/index.js.html +103 -0
  79. package/coverage/lcov.info +804 -0
  80. package/dist/_chunks/App-BaKrvCeS.mjs +1975 -0
  81. package/dist/_chunks/App-DO6syS77.js +1975 -0
  82. package/dist/_chunks/en-Li-XBDe9.mjs +72 -0
  83. package/dist/_chunks/en-aCyfgNfr.js +72 -0
  84. package/dist/_chunks/fr-Cj33Q8jI.js +72 -0
  85. package/dist/_chunks/fr-vLrXph-Z.mjs +72 -0
  86. package/dist/_chunks/index-DwDO4-0C.js +69 -0
  87. package/dist/_chunks/index-jTVd7LdQ.mjs +70 -0
  88. package/dist/admin/index.js +3 -0
  89. package/dist/admin/index.mjs +4 -0
  90. package/dist/server/index.js +3003 -0
  91. package/dist/server/index.mjs +3004 -0
  92. package/jest.config.cjs +50 -0
  93. package/package.json +55 -0
  94. package/server/src/bootstrap.js +87 -0
  95. package/server/src/config/index.js +7 -0
  96. package/server/src/constants.js +255 -0
  97. package/server/src/content-types/audit-log/index.js +3 -0
  98. package/server/src/content-types/audit-log/schema.json +61 -0
  99. package/server/src/content-types/index.js +9 -0
  100. package/server/src/content-types/realm-admin/index.js +3 -0
  101. package/server/src/content-types/realm-admin/schema.json +45 -0
  102. package/server/src/content-types/realm-config/index.js +3 -0
  103. package/server/src/content-types/realm-config/schema.json +56 -0
  104. package/server/src/controllers/audit.js +144 -0
  105. package/server/src/controllers/index.js +9 -0
  106. package/server/src/controllers/realm.js +324 -0
  107. package/server/src/controllers/user.js +413 -0
  108. package/server/src/destroy.js +5 -0
  109. package/server/src/index.js +21 -0
  110. package/server/src/policies/can-access-realm.js +26 -0
  111. package/server/src/policies/index.js +7 -0
  112. package/server/src/policies/is-authenticated.js +5 -0
  113. package/server/src/register.js +7 -0
  114. package/server/src/routes/admin.js +253 -0
  115. package/server/src/routes/index.js +8 -0
  116. package/server/src/services/audit-log.js +196 -0
  117. package/server/src/services/index.js +13 -0
  118. package/server/src/services/keycloak-client.js +758 -0
  119. package/server/src/services/permission.js +290 -0
  120. package/server/src/services/realm.js +374 -0
  121. package/server/src/services/user.js +613 -0
  122. package/server/src/utils/errors.js +63 -0
  123. package/server/src/utils/index.js +6 -0
@@ -0,0 +1,758 @@
1
+ /**
2
+ * @fileoverview Keycloak Admin REST API client service.
3
+ * Handles all direct communication with Keycloak servers including authentication,
4
+ * user management, role management, and email actions.
5
+ *
6
+ * @module services/keycloak-client
7
+ */
8
+
9
+ import {
10
+ PLUGIN_ID,
11
+ ERROR_MESSAGES,
12
+ TOKEN_EXPIRY_BUFFER_MS,
13
+ EMAIL_ACTION_LIFESPAN_SECONDS,
14
+ HTTP_STATUS,
15
+ HTTP_HEADERS,
16
+ KEYCLOAK_EMAIL_ACTIONS,
17
+ PAGINATION,
18
+ } from '../constants.js';
19
+ import { createSanitizedError } from '../utils/errors.js';
20
+
21
+ /**
22
+ * In-memory cache for Keycloak access tokens.
23
+ * Keys are formatted as `${serverUrl}:${realmName}:${clientId}`.
24
+ * @type {Map<string, {accessToken: string, expiresAt: number}>}
25
+ * @private
26
+ */
27
+ const TOKEN_CACHE = new Map();
28
+
29
+ /**
30
+ * Generates a cache key for token storage.
31
+ *
32
+ * @param {RealmConfig} realmConfig - The realm configuration
33
+ * @returns {string} Cache key in format "serverUrl:realmName:clientId"
34
+ * @private
35
+ */
36
+ const getCacheKey = (realmConfig) =>
37
+ `${realmConfig.serverUrl}:${realmConfig.realmName}:${realmConfig.clientId}`;
38
+
39
+ /**
40
+ * Builds the Keycloak token endpoint URL.
41
+ *
42
+ * @param {string} serverUrl - Keycloak server base URL
43
+ * @param {string} realmName - Target realm name
44
+ * @returns {string} Full token endpoint URL
45
+ * @private
46
+ */
47
+ const buildTokenUrl = (serverUrl, realmName) =>
48
+ `${serverUrl}/realms/${realmName}/protocol/openid-connect/token`;
49
+
50
+ /**
51
+ * Builds a Keycloak Admin API URL.
52
+ *
53
+ * @param {string} serverUrl - Keycloak server base URL
54
+ * @param {string} realmName - Target realm name
55
+ * @param {string} [path=''] - Additional path segments
56
+ * @returns {string} Full Admin API URL
57
+ * @private
58
+ */
59
+ const buildAdminUrl = (serverUrl, realmName, path = '') =>
60
+ `${serverUrl}/admin/realms/${realmName}${path}`;
61
+
62
+ /**
63
+ * @typedef {Object} RealmConfig
64
+ * @property {string} serverUrl - Keycloak server base URL (e.g., "https://keycloak.example.com")
65
+ * @property {string} realmName - Name of the Keycloak realm
66
+ * @property {string} clientId - OAuth client ID for service account
67
+ * @property {string} clientSecret - OAuth client secret
68
+ */
69
+
70
+ /**
71
+ * @typedef {Object} KeycloakUser
72
+ * @property {string} id - Unique user identifier
73
+ * @property {string} username - User's username
74
+ * @property {string} [email] - User's email address
75
+ * @property {string} [firstName] - User's first name
76
+ * @property {string} [lastName] - User's last name
77
+ * @property {boolean} enabled - Whether the account is enabled
78
+ * @property {boolean} emailVerified - Whether email has been verified
79
+ * @property {number} createdTimestamp - Account creation timestamp
80
+ */
81
+
82
+ /**
83
+ * @typedef {Object} KeycloakRole
84
+ * @property {string} id - Unique role identifier
85
+ * @property {string} name - Role name
86
+ * @property {string} [description] - Role description
87
+ * @property {boolean} [composite] - Whether this is a composite role
88
+ */
89
+
90
+ /**
91
+ * @typedef {Object} ConnectionTestResult
92
+ * @property {boolean} success - Whether connection was successful
93
+ * @property {string} [message] - Error message if failed
94
+ * @property {string} [realmDisplayName] - Realm display name if successful
95
+ */
96
+
97
+ /**
98
+ * Creates the Keycloak client service.
99
+ *
100
+ * @param {Object} params - Service parameters
101
+ * @param {Object} params.strapi - Strapi instance for logging
102
+ * @returns {Object} Keycloak client service methods
103
+ */
104
+ const keycloakClientService = ({ strapi }) => ({
105
+ /**
106
+ * Retrieves or refreshes an access token for the specified realm.
107
+ * Tokens are cached and automatically refreshed before expiration.
108
+ *
109
+ * @param {RealmConfig} realmConfig - Realm connection configuration
110
+ * @returns {Promise<string>} Valid access token
111
+ * @throws {Error} If authentication fails
112
+ *
113
+ * @example
114
+ * const token = await keycloakClient.getAccessToken(realmConfig);
115
+ * // Use token for API requests
116
+ */
117
+ async getAccessToken(realmConfig) {
118
+ const cacheKey = getCacheKey(realmConfig);
119
+ const cached = TOKEN_CACHE.get(cacheKey);
120
+
121
+ // Return cached token if still valid
122
+ if (cached && Date.now() < cached.expiresAt - TOKEN_EXPIRY_BUFFER_MS) {
123
+ return cached.accessToken;
124
+ }
125
+
126
+ try {
127
+ const tokenUrl = buildTokenUrl(realmConfig.serverUrl, realmConfig.realmName);
128
+
129
+ const response = await fetch(tokenUrl, {
130
+ method: 'POST',
131
+ headers: {
132
+ 'Content-Type': HTTP_HEADERS.CONTENT_TYPE_FORM,
133
+ },
134
+ body: new URLSearchParams({
135
+ grant_type: 'client_credentials',
136
+ client_id: realmConfig.clientId,
137
+ client_secret: realmConfig.clientSecret,
138
+ }),
139
+ });
140
+
141
+ if (!response.ok) {
142
+ const errorText = await response.text();
143
+ strapi.log.error(`[${PLUGIN_ID}] Token fetch failed: ${errorText}`);
144
+ throw createSanitizedError(
145
+ `Token fetch failed: ${errorText}`,
146
+ ERROR_MESSAGES.KEYCLOAK_AUTH_FAILED
147
+ );
148
+ }
149
+
150
+ const data = await response.json();
151
+
152
+ // Cache the token with expiration timestamp
153
+ TOKEN_CACHE.set(cacheKey, {
154
+ accessToken: data.access_token,
155
+ expiresAt: Date.now() + data.expires_in * 1000,
156
+ });
157
+
158
+ return data.access_token;
159
+ } catch (err) {
160
+ // Re-throw sanitized errors as-is
161
+ if (err.sanitizedMessage) throw err;
162
+ strapi.log.error(`[${PLUGIN_ID}] Token error:`, err);
163
+ throw createSanitizedError(err.message, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
164
+ }
165
+ },
166
+
167
+ /**
168
+ * Tests connection to a Keycloak realm by attempting authentication
169
+ * and fetching realm metadata.
170
+ *
171
+ * @param {RealmConfig} realmConfig - Realm connection configuration
172
+ * @returns {Promise<ConnectionTestResult>} Test result with success status
173
+ *
174
+ * @example
175
+ * const result = await keycloakClient.testConnection(config);
176
+ * if (result.success) {
177
+ * console.log(`Connected to ${result.realmDisplayName}`);
178
+ * }
179
+ */
180
+ async testConnection(realmConfig) {
181
+ try {
182
+ const token = await this.getAccessToken(realmConfig);
183
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName);
184
+
185
+ const response = await fetch(url, {
186
+ headers: { Authorization: `Bearer ${token}` },
187
+ });
188
+
189
+ if (!response.ok) {
190
+ return { success: false, message: `HTTP ${response.status}: ${response.statusText}` };
191
+ }
192
+
193
+ const realm = await response.json();
194
+ return { success: true, realmDisplayName: realm.displayName || realm.realm };
195
+ } catch (err) {
196
+ return { success: false, message: err.sanitizedMessage || err.message };
197
+ }
198
+ },
199
+
200
+ /**
201
+ * Fetches a paginated list of users from Keycloak.
202
+ *
203
+ * @param {RealmConfig} realmConfig - Realm connection configuration
204
+ * @param {Object} [options={}] - Query options
205
+ * @param {string} [options.search=''] - Search query (matches username, email, name)
206
+ * @param {number} [options.first=0] - Starting index for pagination
207
+ * @param {number} [options.max=25] - Maximum results to return
208
+ * @param {boolean} [options.briefRepresentation=true] - Return minimal user data
209
+ * @returns {Promise<KeycloakUser[]>} Array of user objects
210
+ * @throws {Error} If the request fails
211
+ *
212
+ * @example
213
+ * const users = await keycloakClient.getUsers(realm, {
214
+ * search: 'john',
215
+ * first: 0,
216
+ * max: 10
217
+ * });
218
+ */
219
+ async getUsers(realmConfig, { search = '', first = 0, max = PAGINATION.PAGE_SIZE, briefRepresentation = true } = {}) {
220
+ const token = await this.getAccessToken(realmConfig);
221
+ const params = new URLSearchParams({
222
+ first: String(first),
223
+ max: String(max),
224
+ briefRepresentation: String(briefRepresentation),
225
+ });
226
+
227
+ if (search) {
228
+ params.set('search', search);
229
+ }
230
+
231
+ const url = `${buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, '/users')}?${params}`;
232
+
233
+ const response = await fetch(url, {
234
+ headers: { Authorization: `Bearer ${token}` },
235
+ });
236
+
237
+ if (!response.ok) {
238
+ const errorText = await response.text();
239
+ strapi.log.error(`[${PLUGIN_ID}] Failed to fetch users: ${errorText}`);
240
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
241
+ }
242
+
243
+ return response.json();
244
+ },
245
+
246
+ /**
247
+ * Counts total users in a realm, optionally filtered by search query.
248
+ *
249
+ * @param {RealmConfig} realmConfig - Realm connection configuration
250
+ * @param {Object} [options={}] - Query options
251
+ * @param {string} [options.search=''] - Search query to filter count
252
+ * @returns {Promise<number>} Total user count
253
+ *
254
+ * @example
255
+ * const total = await keycloakClient.countUsers(realm, { search: 'admin' });
256
+ */
257
+ async countUsers(realmConfig, { search = '' } = {}) {
258
+ const token = await this.getAccessToken(realmConfig);
259
+ const params = new URLSearchParams();
260
+ if (search) params.set('search', search);
261
+
262
+ const url = `${buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, '/users/count')}?${params}`;
263
+
264
+ const response = await fetch(url, {
265
+ headers: { Authorization: `Bearer ${token}` },
266
+ });
267
+
268
+ if (!response.ok) {
269
+ return 0;
270
+ }
271
+
272
+ return response.json();
273
+ },
274
+
275
+ /**
276
+ * Retrieves a single user by their Keycloak ID.
277
+ *
278
+ * @param {RealmConfig} realmConfig - Realm connection configuration
279
+ * @param {string} userId - Keycloak user ID
280
+ * @returns {Promise<KeycloakUser>} User object with full details
281
+ * @throws {Error} If user not found or request fails
282
+ *
283
+ * @example
284
+ * const user = await keycloakClient.getUserById(realm, 'abc-123');
285
+ */
286
+ async getUserById(realmConfig, userId) {
287
+ const token = await this.getAccessToken(realmConfig);
288
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}`);
289
+
290
+ const response = await fetch(url, {
291
+ headers: { Authorization: `Bearer ${token}` },
292
+ });
293
+
294
+ if (!response.ok) {
295
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
296
+ throw createSanitizedError(`User ${userId} not found`, ERROR_MESSAGES.USER_NOT_FOUND);
297
+ }
298
+ const errorText = await response.text();
299
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
300
+ }
301
+
302
+ return response.json();
303
+ },
304
+
305
+ /**
306
+ * Creates a new user in Keycloak.
307
+ *
308
+ * @param {RealmConfig} realmConfig - Realm connection configuration
309
+ * @param {Object} userData - User data to create
310
+ * @param {string} userData.username - Required username
311
+ * @param {string} [userData.email] - Email address
312
+ * @param {string} [userData.firstName] - First name
313
+ * @param {string} [userData.lastName] - Last name
314
+ * @param {boolean} [userData.enabled=true] - Account enabled status
315
+ * @param {boolean} [userData.emailVerified=false] - Email verification status
316
+ * @returns {Promise<{userId: string|null, location: string|null}>} Created user info
317
+ * @throws {Error} If user creation fails (e.g., duplicate username/email)
318
+ *
319
+ * @example
320
+ * const result = await keycloakClient.createUser(realm, {
321
+ * username: 'newuser',
322
+ * email: 'new@example.com',
323
+ * enabled: true
324
+ * });
325
+ */
326
+ async createUser(realmConfig, userData) {
327
+ const token = await this.getAccessToken(realmConfig);
328
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, '/users');
329
+
330
+ const response = await fetch(url, {
331
+ method: 'POST',
332
+ headers: {
333
+ Authorization: `Bearer ${token}`,
334
+ 'Content-Type': HTTP_HEADERS.CONTENT_TYPE_JSON,
335
+ },
336
+ body: JSON.stringify(userData),
337
+ });
338
+
339
+ if (!response.ok) {
340
+ const errorText = await response.text();
341
+ strapi.log.error(`[${PLUGIN_ID}] Failed to create user: ${errorText}`);
342
+
343
+ if (response.status === HTTP_STATUS.CONFLICT) {
344
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_ALREADY_EXISTS);
345
+ }
346
+ throw createSanitizedError(errorText, ERROR_MESSAGES.INVALID_USER_DATA);
347
+ }
348
+
349
+ // Extract user ID from Location header
350
+ const location = response.headers.get('Location');
351
+ const userId = location ? location.split('/').pop() : null;
352
+
353
+ return { userId, location };
354
+ },
355
+
356
+ /**
357
+ * Updates an existing user's attributes.
358
+ *
359
+ * @param {RealmConfig} realmConfig - Realm connection configuration
360
+ * @param {string} userId - Keycloak user ID
361
+ * @param {Object} userData - Updated user attributes
362
+ * @returns {Promise<{success: boolean}>} Success indicator
363
+ * @throws {Error} If user not found or update fails
364
+ *
365
+ * @example
366
+ * await keycloakClient.updateUser(realm, userId, {
367
+ * firstName: 'Updated',
368
+ * enabled: false
369
+ * });
370
+ */
371
+ async updateUser(realmConfig, userId, userData) {
372
+ const token = await this.getAccessToken(realmConfig);
373
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}`);
374
+
375
+ const response = await fetch(url, {
376
+ method: 'PUT',
377
+ headers: {
378
+ Authorization: `Bearer ${token}`,
379
+ 'Content-Type': HTTP_HEADERS.CONTENT_TYPE_JSON,
380
+ },
381
+ body: JSON.stringify(userData),
382
+ });
383
+
384
+ if (!response.ok) {
385
+ const errorText = await response.text();
386
+ strapi.log.error(`[${PLUGIN_ID}] Failed to update user: ${errorText}`);
387
+
388
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
389
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_NOT_FOUND);
390
+ }
391
+ throw createSanitizedError(errorText, ERROR_MESSAGES.INVALID_USER_DATA);
392
+ }
393
+
394
+ return { success: true };
395
+ },
396
+
397
+ /**
398
+ * Permanently deletes a user from Keycloak.
399
+ *
400
+ * @param {RealmConfig} realmConfig - Realm connection configuration
401
+ * @param {string} userId - Keycloak user ID
402
+ * @returns {Promise<{success: boolean}>} Success indicator
403
+ * @throws {Error} If user not found or deletion fails
404
+ *
405
+ * @example
406
+ * await keycloakClient.deleteUser(realm, 'user-id-123');
407
+ */
408
+ async deleteUser(realmConfig, userId) {
409
+ const token = await this.getAccessToken(realmConfig);
410
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}`);
411
+
412
+ const response = await fetch(url, {
413
+ method: 'DELETE',
414
+ headers: { Authorization: `Bearer ${token}` },
415
+ });
416
+
417
+ if (!response.ok) {
418
+ const errorText = await response.text();
419
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
420
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_NOT_FOUND);
421
+ }
422
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
423
+ }
424
+
425
+ return { success: true };
426
+ },
427
+
428
+ /**
429
+ * Sets a new password for a user.
430
+ *
431
+ * @param {RealmConfig} realmConfig - Realm connection configuration
432
+ * @param {string} userId - Keycloak user ID
433
+ * @param {string} password - New password value
434
+ * @param {boolean} [temporary=true] - If true, user must change password on next login
435
+ * @returns {Promise<{success: boolean}>} Success indicator
436
+ * @throws {Error} If user not found or password reset fails
437
+ *
438
+ * @example
439
+ * // Set temporary password (user must change on login)
440
+ * await keycloakClient.resetPassword(realm, userId, 'TempPass123!', true);
441
+ *
442
+ * // Set permanent password
443
+ * await keycloakClient.resetPassword(realm, userId, 'NewPass123!', false);
444
+ */
445
+ async resetPassword(realmConfig, userId, password, temporary = true) {
446
+ const token = await this.getAccessToken(realmConfig);
447
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}/reset-password`);
448
+
449
+ const response = await fetch(url, {
450
+ method: 'PUT',
451
+ headers: {
452
+ Authorization: `Bearer ${token}`,
453
+ 'Content-Type': HTTP_HEADERS.CONTENT_TYPE_JSON,
454
+ },
455
+ body: JSON.stringify({
456
+ type: 'password',
457
+ value: password,
458
+ temporary,
459
+ }),
460
+ });
461
+
462
+ if (!response.ok) {
463
+ const errorText = await response.text();
464
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
465
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_NOT_FOUND);
466
+ }
467
+ throw createSanitizedError(errorText, ERROR_MESSAGES.PASSWORD_RESET_FAILED);
468
+ }
469
+
470
+ return { success: true };
471
+ },
472
+
473
+ /**
474
+ * Enables a user account.
475
+ *
476
+ * @param {RealmConfig} realmConfig - Realm connection configuration
477
+ * @param {string} userId - Keycloak user ID
478
+ * @returns {Promise<{success: boolean}>} Success indicator
479
+ */
480
+ async enableUser(realmConfig, userId) {
481
+ return this.updateUser(realmConfig, userId, { enabled: true });
482
+ },
483
+
484
+ /**
485
+ * Disables a user account.
486
+ *
487
+ * @param {RealmConfig} realmConfig - Realm connection configuration
488
+ * @param {string} userId - Keycloak user ID
489
+ * @returns {Promise<{success: boolean}>} Success indicator
490
+ */
491
+ async disableUser(realmConfig, userId) {
492
+ return this.updateUser(realmConfig, userId, { enabled: false });
493
+ },
494
+
495
+ /**
496
+ * Triggers email actions for a user (e.g., verify email, update password).
497
+ *
498
+ * @param {RealmConfig} realmConfig - Realm connection configuration
499
+ * @param {string} userId - Keycloak user ID
500
+ * @param {string[]} actions - Array of action types (e.g., ['VERIFY_EMAIL'])
501
+ * @param {number} [lifespan] - Link validity in seconds (default: 12 hours)
502
+ * @returns {Promise<{success: boolean}>} Success indicator
503
+ * @throws {Error} If user not found or email fails
504
+ *
505
+ * @example
506
+ * // Send verification email
507
+ * await keycloakClient.executeEmailActions(realm, userId, ['VERIFY_EMAIL']);
508
+ *
509
+ * // Send password reset with custom lifespan (1 hour)
510
+ * await keycloakClient.executeEmailActions(realm, userId, ['UPDATE_PASSWORD'], 3600);
511
+ */
512
+ async executeEmailActions(realmConfig, userId, actions, lifespan = EMAIL_ACTION_LIFESPAN_SECONDS) {
513
+ const token = await this.getAccessToken(realmConfig);
514
+ const params = new URLSearchParams({ lifespan: String(lifespan) });
515
+ const url = `${buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}/execute-actions-email`)}?${params}`;
516
+
517
+ const response = await fetch(url, {
518
+ method: 'PUT',
519
+ headers: {
520
+ Authorization: `Bearer ${token}`,
521
+ 'Content-Type': HTTP_HEADERS.CONTENT_TYPE_JSON,
522
+ },
523
+ body: JSON.stringify(actions),
524
+ });
525
+
526
+ if (!response.ok) {
527
+ const errorText = await response.text();
528
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
529
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_NOT_FOUND);
530
+ }
531
+ throw createSanitizedError(errorText, ERROR_MESSAGES.EMAIL_SEND_FAILED);
532
+ }
533
+
534
+ return { success: true };
535
+ },
536
+
537
+ /**
538
+ * Sends an email verification link to the user.
539
+ *
540
+ * @param {RealmConfig} realmConfig - Realm connection configuration
541
+ * @param {string} userId - Keycloak user ID
542
+ * @returns {Promise<{success: boolean}>} Success indicator
543
+ */
544
+ async sendVerificationEmail(realmConfig, userId) {
545
+ return this.executeEmailActions(realmConfig, userId, [KEYCLOAK_EMAIL_ACTIONS.VERIFY_EMAIL]);
546
+ },
547
+
548
+ /**
549
+ * Sends a password reset email to the user.
550
+ *
551
+ * @param {RealmConfig} realmConfig - Realm connection configuration
552
+ * @param {string} userId - Keycloak user ID
553
+ * @returns {Promise<{success: boolean}>} Success indicator
554
+ */
555
+ async sendResetPasswordEmail(realmConfig, userId) {
556
+ return this.executeEmailActions(realmConfig, userId, [KEYCLOAK_EMAIL_ACTIONS.UPDATE_PASSWORD]);
557
+ },
558
+
559
+ /**
560
+ * Retrieves all realm-level roles.
561
+ *
562
+ * @param {RealmConfig} realmConfig - Realm connection configuration
563
+ * @returns {Promise<KeycloakRole[]>} Array of realm roles
564
+ *
565
+ * @example
566
+ * const roles = await keycloakClient.getRealmRoles(realm);
567
+ * // [{ id: '...', name: 'admin', ... }, ...]
568
+ */
569
+ async getRealmRoles(realmConfig) {
570
+ const token = await this.getAccessToken(realmConfig);
571
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, '/roles');
572
+
573
+ const response = await fetch(url, {
574
+ headers: { Authorization: `Bearer ${token}` },
575
+ });
576
+
577
+ if (!response.ok) {
578
+ const errorText = await response.text();
579
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
580
+ }
581
+
582
+ return response.json();
583
+ },
584
+
585
+ /**
586
+ * Gets realm roles assigned to a specific user.
587
+ *
588
+ * @param {RealmConfig} realmConfig - Realm connection configuration
589
+ * @param {string} userId - Keycloak user ID
590
+ * @returns {Promise<KeycloakRole[]>} Array of assigned roles
591
+ *
592
+ * @example
593
+ * const userRoles = await keycloakClient.getUserRoles(realm, userId);
594
+ */
595
+ async getUserRoles(realmConfig, userId) {
596
+ const token = await this.getAccessToken(realmConfig);
597
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}/role-mappings/realm`);
598
+
599
+ const response = await fetch(url, {
600
+ headers: { Authorization: `Bearer ${token}` },
601
+ });
602
+
603
+ if (!response.ok) {
604
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
605
+ throw createSanitizedError(`User ${userId} not found`, ERROR_MESSAGES.USER_NOT_FOUND);
606
+ }
607
+ const errorText = await response.text();
608
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
609
+ }
610
+
611
+ return response.json();
612
+ },
613
+
614
+ /**
615
+ * Assigns realm roles to a user.
616
+ *
617
+ * @param {RealmConfig} realmConfig - Realm connection configuration
618
+ * @param {string} userId - Keycloak user ID
619
+ * @param {KeycloakRole[]} roles - Array of role objects with id and name
620
+ * @returns {Promise<{success: boolean}>} Success indicator
621
+ *
622
+ * @example
623
+ * await keycloakClient.assignRoles(realm, userId, [
624
+ * { id: 'role-id-1', name: 'admin' },
625
+ * { id: 'role-id-2', name: 'editor' }
626
+ * ]);
627
+ */
628
+ async assignRoles(realmConfig, userId, roles) {
629
+ const token = await this.getAccessToken(realmConfig);
630
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}/role-mappings/realm`);
631
+
632
+ const response = await fetch(url, {
633
+ method: 'POST',
634
+ headers: {
635
+ Authorization: `Bearer ${token}`,
636
+ 'Content-Type': HTTP_HEADERS.CONTENT_TYPE_JSON,
637
+ },
638
+ body: JSON.stringify(roles),
639
+ });
640
+
641
+ if (!response.ok) {
642
+ const errorText = await response.text();
643
+ throw createSanitizedError(errorText, ERROR_MESSAGES.ROLE_ASSIGNMENT_FAILED);
644
+ }
645
+
646
+ return { success: true };
647
+ },
648
+
649
+ /**
650
+ * Removes realm roles from a user.
651
+ *
652
+ * @param {RealmConfig} realmConfig - Realm connection configuration
653
+ * @param {string} userId - Keycloak user ID
654
+ * @param {KeycloakRole[]} roles - Array of role objects to remove
655
+ * @returns {Promise<{success: boolean}>} Success indicator
656
+ *
657
+ * @example
658
+ * await keycloakClient.removeRoles(realm, userId, [
659
+ * { id: 'role-id-1', name: 'admin' }
660
+ * ]);
661
+ */
662
+ async removeRoles(realmConfig, userId, roles) {
663
+ const token = await this.getAccessToken(realmConfig);
664
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}/role-mappings/realm`);
665
+
666
+ const response = await fetch(url, {
667
+ method: 'DELETE',
668
+ headers: {
669
+ Authorization: `Bearer ${token}`,
670
+ 'Content-Type': HTTP_HEADERS.CONTENT_TYPE_JSON,
671
+ },
672
+ body: JSON.stringify(roles),
673
+ });
674
+
675
+ if (!response.ok) {
676
+ const errorText = await response.text();
677
+ throw createSanitizedError(errorText, ERROR_MESSAGES.ROLE_REMOVAL_FAILED);
678
+ }
679
+
680
+ return { success: true };
681
+ },
682
+
683
+ /**
684
+ * Gets active sessions for a user.
685
+ *
686
+ * @param {RealmConfig} realmConfig - Realm connection configuration
687
+ * @param {string} userId - Keycloak user ID
688
+ * @returns {Promise<Object[]>} Array of session objects
689
+ *
690
+ * @example
691
+ * const sessions = await keycloakClient.getUserSessions(realm, userId);
692
+ */
693
+ async getUserSessions(realmConfig, userId) {
694
+ const token = await this.getAccessToken(realmConfig);
695
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}/sessions`);
696
+
697
+ const response = await fetch(url, {
698
+ headers: { Authorization: `Bearer ${token}` },
699
+ });
700
+
701
+ if (!response.ok) {
702
+ return [];
703
+ }
704
+
705
+ return response.json();
706
+ },
707
+
708
+ /**
709
+ * Terminates all active sessions for a user.
710
+ *
711
+ * @param {RealmConfig} realmConfig - Realm connection configuration
712
+ * @param {string} userId - Keycloak user ID
713
+ * @returns {Promise<{success: boolean}>} Success indicator
714
+ *
715
+ * @example
716
+ * await keycloakClient.logoutUser(realm, userId);
717
+ */
718
+ async logoutUser(realmConfig, userId) {
719
+ const token = await this.getAccessToken(realmConfig);
720
+ const url = buildAdminUrl(realmConfig.serverUrl, realmConfig.realmName, `/users/${userId}/logout`);
721
+
722
+ const response = await fetch(url, {
723
+ method: 'POST',
724
+ headers: { Authorization: `Bearer ${token}` },
725
+ });
726
+
727
+ if (!response.ok && response.status !== HTTP_STATUS.NO_CONTENT) {
728
+ const errorText = await response.text();
729
+ throw createSanitizedError(errorText, ERROR_MESSAGES.LOGOUT_FAILED);
730
+ }
731
+
732
+ return { success: true };
733
+ },
734
+
735
+ /**
736
+ * Clears cached access tokens.
737
+ * Call this when realm configuration changes to force re-authentication.
738
+ *
739
+ * @param {RealmConfig} [realmConfig] - Specific realm to clear, or all if omitted
740
+ *
741
+ * @example
742
+ * // Clear specific realm's token
743
+ * keycloakClient.clearTokenCache(realmConfig);
744
+ *
745
+ * // Clear all cached tokens
746
+ * keycloakClient.clearTokenCache();
747
+ */
748
+ clearTokenCache(realmConfig) {
749
+ if (realmConfig) {
750
+ const cacheKey = getCacheKey(realmConfig);
751
+ TOKEN_CACHE.delete(cacheKey);
752
+ } else {
753
+ TOKEN_CACHE.clear();
754
+ }
755
+ },
756
+ });
757
+
758
+ export default keycloakClientService;