keycloak-api-manager 6.0.0 → 6.0.2

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/index.js CHANGED
@@ -1,5 +1,22 @@
1
+
2
+ /**
3
+ * **************************************************************************************************
4
+ * Keycloak API Manager - Main Entry Point
5
+ *
6
+ * This module provides a centralized interface for managing Keycloak administrative resources via the REST Admin API.
7
+ * It exposes methods for client configuration, token handling, and wiring all resource handlers.
8
+ *
9
+ * Each handler in Handlers/ encapsulates logic for a specific Keycloak resource (users, roles, groups, etc.).
10
+ *
11
+ * NOTE: OIDC authentication functions are deprecated and have been moved to keycloak-express-middleware.
12
+ * **************************************************************************************************
13
+ */
1
14
  const KeycloakAdminClient = require('@keycloak/keycloak-admin-client').default;
2
15
 
16
+
17
+ /**
18
+ * Resource handler registry. Each key represents a Keycloak resource and maps to its handler module.
19
+ */
3
20
  const handlerRegistry = {
4
21
  realms: require('./Handlers/realmsHandler'),
5
22
  users: require('./Handlers/usersHandler'),
@@ -17,17 +34,33 @@ const handlerRegistry = {
17
34
  serverInfo: require('./Handlers/serverInfoHandler')
18
35
  };
19
36
 
37
+
38
+ // Keycloak Admin client instance
20
39
  let kcAdminClient = null;
40
+ // Interval ID for automatic token refresh
21
41
  let tokenRefreshInterval = null;
42
+ // Current runtime configuration
22
43
  let runtimeConfig = null;
44
+ // Authentication payload used for token refresh
23
45
  let authPayload = null;
24
46
 
47
+
48
+ /**
49
+ * Ensures the client is configured before executing operations.
50
+ * Throws an error when configuration is missing.
51
+ */
25
52
  function assertConfigured() {
26
53
  if (!kcAdminClient || !runtimeConfig) {
27
54
  throw new Error('Keycloak Admin Client is not configured. Call configure() first.');
28
55
  }
29
56
  }
30
57
 
58
+
59
+ /**
60
+ * Normalizes the baseUrl by removing a trailing slash, if present.
61
+ * @param {string} baseUrl
62
+ * @returns {string}
63
+ */
31
64
  function toBaseUrl(baseUrl) {
32
65
  if (!baseUrl || typeof baseUrl !== 'string') {
33
66
  throw new Error('Invalid baseUrl. It must be a non-empty string.');
@@ -35,6 +68,10 @@ function toBaseUrl(baseUrl) {
35
68
  return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
36
69
  }
37
70
 
71
+
72
+ /**
73
+ * Binds all resource handlers by injecting the configured Keycloak client and exporting the modules.
74
+ */
38
75
  function bindHandlers() {
39
76
  Object.entries(handlerRegistry).forEach(([name, handler]) => {
40
77
  handler.setKcAdminClient(kcAdminClient);
@@ -42,6 +79,10 @@ function bindHandlers() {
42
79
  });
43
80
  }
44
81
 
82
+
83
+ /**
84
+ * Stops and resets the token refresh timer.
85
+ */
45
86
  function clearRefreshTimer() {
46
87
  if (tokenRefreshInterval) {
47
88
  clearInterval(tokenRefreshInterval);
@@ -49,6 +90,11 @@ function clearRefreshTimer() {
49
90
  }
50
91
  }
51
92
 
93
+
94
+ /**
95
+ * Starts the automatic access token refresh timer.
96
+ * @param {number} intervalMs - interval in milliseconds
97
+ */
52
98
  function startRefreshTimer(intervalMs) {
53
99
  clearRefreshTimer();
54
100
 
@@ -65,6 +111,25 @@ function startRefreshTimer(intervalMs) {
65
111
  }
66
112
  }
67
113
 
114
+ /**
115
+ * Initializes and authenticates the Keycloak Admin Client. Must be called before using any handler.
116
+ * Supports password and client_credentials grant types.
117
+ * Starts automatic token refresh and propagates the configured client to all handlers.
118
+ *
119
+ * @param {Object} adminClientCredentials - Credentials and configuration object:
120
+ * - baseUrl {string} Keycloak server base URL
121
+ * - realmName {string} Realm name
122
+ * - clientId {string} Client ID
123
+ * - clientSecret {string} (optional) Secret for client_credentials
124
+ * - username {string} (optional) Admin username
125
+ * - password {string} (optional) Admin password
126
+ * - grantType {string} ("password" | "client_credentials")
127
+ * - tokenLifeSpan {number} (optional) Token refresh interval (seconds)
128
+ * - ... Other OAuth2 parameters
129
+ * @returns {Promise<void>} Promise resolved when configuration is complete
130
+ * @example
131
+ * await KeycloakManager.configure({ baseUrl, realmName, clientId, clientSecret, grantType: 'client_credentials' })
132
+ */
68
133
  exports.configure = async function configure(adminClientCredentials = {}) {
69
134
  const {
70
135
  baseUrl,
@@ -89,6 +154,8 @@ exports.configure = async function configure(adminClientCredentials = {}) {
89
154
  realmName
90
155
  });
91
156
 
157
+ // Guard against null/empty refresh token updates from upstream client internals.
158
+ // Without this, some refresh paths can keep a stale token value in memory.
92
159
  const originalSetRefreshToken = kcAdminClient.setRefreshToken?.bind(kcAdminClient);
93
160
  if (originalSetRefreshToken) {
94
161
  kcAdminClient.setRefreshToken = (token) => {
@@ -100,6 +167,7 @@ exports.configure = async function configure(adminClientCredentials = {}) {
100
167
  };
101
168
  }
102
169
 
170
+ // Keep a canonical auth payload so the refresh timer can reuse the same grant context.
103
171
  authPayload = {
104
172
  clientId,
105
173
  ...(clientSecret ? { clientSecret } : {}),
@@ -107,6 +175,8 @@ exports.configure = async function configure(adminClientCredentials = {}) {
107
175
  };
108
176
  await kcAdminClient.auth(authPayload);
109
177
 
178
+ // Refresh midway through the configured lifespan to reduce expiration race conditions.
179
+ // Fallback to 30s when tokenLifeSpan is omitted/invalid.
110
180
  const intervalMs = Number.isFinite(Number(tokenLifeSpan)) && Number(tokenLifeSpan) > 0
111
181
  ? (Number(tokenLifeSpan) * 1000) / 2
112
182
  : 30000;
@@ -115,10 +185,40 @@ exports.configure = async function configure(adminClientCredentials = {}) {
115
185
  bindHandlers();
116
186
  };
117
187
 
188
+
189
+ /**
190
+ * Updates Keycloak client runtime configuration without re-initializing the session or re-authenticating.
191
+ * Allows switching realm, baseUrl, or HTTP request options (requestConfig) at runtime.
192
+ *
193
+ * @param {Object} overrides - Configuration overrides object.
194
+ * @param {string} [overrides.realmName] - Changes the target realm for all subsequent calls.
195
+ * @param {string} [overrides.baseUrl] - Changes the Keycloak server base URL.
196
+ * @param {object} [overrides.requestConfig] - HTTP request option overrides (e.g., timeout, custom headers). These options are passed internally to the HTTP client used by keycloak-admin-client (typically Axios).
197
+ * @returns {void}
198
+ *
199
+ * @example
200
+ * // Change only the realm
201
+ * KeycloakManager.setConfig({ realmName: 'my-app' });
202
+ *
203
+ * // Change realm and add custom request headers
204
+ * KeycloakManager.setConfig({
205
+ * realmName: 'my-realm',
206
+ * requestConfig: {
207
+ * timeout: 10000,
208
+ * headers: { 'X-Custom-Header': 'value' }
209
+ * }
210
+ * });
211
+ *
212
+ * // Change baseUrl (e.g., for different environments)
213
+ * KeycloakManager.setConfig({ baseUrl: 'https://keycloak-alt.example.com' });
214
+ *
215
+ * @note Does not perform login/token refresh. It only updates runtime context. The token remains valid if compatible with the new realm.
216
+ */
118
217
  exports.setConfig = function setConfig(configToOverride = {}) {
119
218
  assertConfigured();
120
219
  kcAdminClient.setConfig(configToOverride);
121
220
 
221
+ // Keep local runtimeConfig aligned with client overrides used by helper methods.
122
222
  runtimeConfig = {
123
223
  ...runtimeConfig,
124
224
  ...(configToOverride.baseUrl ? { baseUrl: toBaseUrl(configToOverride.baseUrl) } : {}),
@@ -126,6 +226,16 @@ exports.setConfig = function setConfig(configToOverride = {}) {
126
226
  };
127
227
  };
128
228
 
229
+
230
+ /**
231
+ * Returns the current access and refresh tokens.
232
+ * Useful for debugging or passing the token to other services.
233
+ * The token is automatically refreshed by the internal timer.
234
+ *
235
+ * @returns {{ accessToken: string, refreshToken: string }}
236
+ * @example
237
+ * const { accessToken } = KeycloakManager.getToken();
238
+ */
129
239
  exports.getToken = function getToken() {
130
240
  assertConfigured();
131
241
  return {
@@ -134,20 +244,40 @@ exports.getToken = function getToken() {
134
244
  };
135
245
  };
136
246
 
247
+
248
+ /**
249
+ * Stops the automatic token refresh timer and releases resources.
250
+ * Call this before shutting down the application to avoid dangling processes.
251
+ *
252
+ * @returns {void}
253
+ * @example
254
+ * KeycloakManager.stop();
255
+ */
137
256
  exports.stop = function stop() {
138
257
  clearRefreshTimer();
139
258
  };
140
259
 
260
+
261
+ /**
262
+ * Executes a direct request to the Keycloak OIDC token endpoint.
263
+ * Used internally for password, client_credentials, authorization_code (PKCE), and other grant types.
264
+ *
265
+ * @param {Object} credentials - OAuth2 parameters (grant_type, username, password, code, etc.)
266
+ * @returns {Promise<Object>} Token endpoint response (access_token, refresh_token, etc.)
267
+ * @throws {Error} If the request fails
268
+ */
141
269
  async function requestOidcToken(credentials = {}) {
142
270
  assertConfigured();
143
271
 
144
272
  const body = new URLSearchParams();
273
+ // Serialize only defined values to avoid sending "undefined"/"null" to Keycloak.
145
274
  Object.entries(credentials).forEach(([key, value]) => {
146
275
  if (value !== undefined && value !== null) {
147
276
  body.append(key, String(value));
148
277
  }
149
278
  });
150
279
 
280
+ // Apply runtime client credentials as defaults unless explicitly overridden.
151
281
  if (runtimeConfig.clientId && !body.has('client_id')) {
152
282
  body.append('client_id', runtimeConfig.clientId);
153
283
  }
@@ -167,6 +297,7 @@ async function requestOidcToken(credentials = {}) {
167
297
  );
168
298
 
169
299
  const responseText = await response.text();
300
+ // Keycloak usually returns JSON; keep empty-body responses safe.
170
301
  const payload = responseText ? JSON.parse(responseText) : {};
171
302
 
172
303
  if (!response.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keycloak-api-manager",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
4
4
  "description": "Enhanced Node.js wrapper for Keycloak Admin REST API. Professional alternative to @keycloak/keycloak-admin-client with advanced features, bug fixes, automatic token refresh, Organizations API support, fine-grained permissions, and comprehensive resource management. Battle-tested with 113+ integration tests.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -79,8 +79,8 @@ function loadConfig() {
79
79
  }
80
80
 
81
81
  /**
82
- * Inizializza il client Keycloak admin
83
- * Aspetta che Keycloak sia pronto prima di connettersi
82
+ * Initializes the Keycloak admin client.
83
+ * Waits until Keycloak is ready before connecting.
84
84
  */
85
85
  async function initializeAdminClient() {
86
86
  if (adminClient) {
@@ -90,6 +90,7 @@ async function initializeAdminClient() {
90
90
  // Load configuration (after local.json has been created from Docker)
91
91
  const config = loadConfig();
92
92
 
93
+ // Retry loop is intentionally generous to tolerate container cold-start time.
93
94
  let retries = 30;
94
95
  let lastError;
95
96
 
@@ -113,9 +114,11 @@ async function initializeAdminClient() {
113
114
  lastError = err;
114
115
  retries--;
115
116
  if (retries > 0) {
117
+ // Keycloak may still be booting; wait and retry.
116
118
  console.log(`Waiting for Keycloak... (${retries} retries left)`);
117
119
  await delay(2000);
118
120
  } else {
121
+ // Last attempt: print as much OAuth2 context as possible for diagnostics.
119
122
  console.error('OAuth2 Error Details:', {
120
123
  message: err.message,
121
124
  response: err.response?.data,
@@ -130,21 +133,22 @@ async function initializeAdminClient() {
130
133
  }
131
134
 
132
135
  /**
133
- * Crea il realm di test
136
+ * Creates the test realm.
134
137
  */
135
138
  async function setupTestRealm() {
136
139
  const client = await initializeAdminClient();
137
140
  const config = loadConfig();
138
141
 
139
- // Switcha a master realm per creare il test realm
142
+ // Switch to the master realm to create the test realm
140
143
  client.realmName = 'master';
141
144
 
142
145
  try {
143
- // Controlla se il realm esiste già
146
+ // Check if the realm already exists
144
147
  const realms = await client.realms.find();
145
148
  const realmExists = realms.some((r) => r.realm === config.realmName);
146
149
 
147
150
  if (!realmExists) {
151
+ // Keep realm defaults explicit so tests behave consistently across environments.
148
152
  await client.realms.create({
149
153
  realm: config.realmName,
150
154
  displayName: 'Test Realm',
@@ -159,6 +163,7 @@ async function setupTestRealm() {
159
163
  console.log(`✓ Test realm '${config.realmName}' already exists`);
160
164
  }
161
165
  } catch (err) {
166
+ // 409 means the realm already exists, which is acceptable for idempotent setup.
162
167
  if (err.response?.status === 409) {
163
168
  console.log(`✓ Test realm '${config.realmName}' already exists`);
164
169
  } else {
@@ -166,12 +171,12 @@ async function setupTestRealm() {
166
171
  }
167
172
  }
168
173
 
169
- // Switcha back al test realm
174
+ // Switch back to the test realm
170
175
  client.realmName = config.realmName;
171
176
  }
172
177
 
173
178
  /**
174
- * Pulisce il realm di test
179
+ * Cleans up the test realm.
175
180
  */
176
181
  async function cleanupTestRealm() {
177
182
  if (!adminClient) return;
@@ -183,6 +188,7 @@ async function cleanupTestRealm() {
183
188
  await adminClient.realms.del({ realm: config.realmName });
184
189
  console.log(`✓ Test realm '${config.realmName}' deleted`);
185
190
  } catch (err) {
191
+ // Ignore not-found during cleanup to keep teardown idempotent.
186
192
  if (err.response?.status !== 404) {
187
193
  console.warn(`Warning: Failed to delete test realm: ${err.message}`);
188
194
  }
@@ -190,7 +196,7 @@ async function cleanupTestRealm() {
190
196
  }
191
197
 
192
198
  /**
193
- * Ritorna il client admin configurato e autenticato
199
+ * Returns the configured and authenticated admin client.
194
200
  */
195
201
  function getAdminClient() {
196
202
  if (!adminClient) {
@@ -200,7 +206,7 @@ function getAdminClient() {
200
206
  }
201
207
 
202
208
  /**
203
- * Reset del client (principalmente per i test)
209
+ * Resets the admin client (mainly for tests).
204
210
  */
205
211
  function resetAdminClient() {
206
212
  adminClient = null;
package/test-output.log DELETED
@@ -1,72 +0,0 @@
1
-
2
- > keycloak-api-manager@4.1.0 test
3
- > npm --prefix test install && npm --prefix test test
4
-
5
-
6
- up to date, audited 260 packages in 2s
7
-
8
- 48 packages are looking for funding
9
- run `npm fund` for details
10
-
11
- 12 vulnerabilities (3 low, 3 moderate, 4 high, 2 critical)
12
-
13
- To address all issues possible (including breaking changes), run:
14
- npm audit fix --force
15
-
16
- Some issues need review, and may require choosing
17
- a different dependency.
18
-
19
- Run `npm audit` for details.
20
-
21
- > keycloak-api-manager-tests@1.0.0 test
22
- > NODE_ENV=test NODE_PATH=./node_modules mocha --exit
23
-
24
-
25
- Exception during run: /Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/Handlers/userProfileHandler.js:79
26
- body: JSON.strasync function(filter) {
27
- ^^^^^^^^
28
-
29
- SyntaxError: Unexpected token 'function'
30
- at wrapSafe (node:internal/modules/cjs/loader:1515:18)
31
- at Module._compile (node:internal/modules/cjs/loader:1537:20)
32
- at Object..js (node:internal/modules/cjs/loader:1708:10)
33
- at Module.load (node:internal/modules/cjs/loader:1318:32)
34
- at Function._load (node:internal/modules/cjs/loader:1128:12)
35
- at TracingChannel.traceSync (node:diagnostics_channel:322:14)
36
- at wrapModuleLoad (node:internal/modules/cjs/loader:219:24)
37
- at Module.require (node:internal/modules/cjs/loader:1340:12)
38
- at require (node:internal/modules/helpers:138:16)
39
- at Object.<anonymous> (/Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/index.js:15:24)
40
- at Module._compile (node:internal/modules/cjs/loader:1565:14)
41
- at Object..js (node:internal/modules/cjs/loader:1708:10)
42
- at Module.load (node:internal/modules/cjs/loader:1318:32)
43
- at Function._load (node:internal/modules/cjs/loader:1128:12)
44
- at TracingChannel.traceSync (node:diagnostics_channel:322:14)
45
- at wrapModuleLoad (node:internal/modules/cjs/loader:219:24)
46
- at Module.require (node:internal/modules/cjs/loader:1340:12)
47
- at require (node:internal/modules/helpers:138:16)
48
- at Object.<anonymous> (/Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/test/enableServerFeatures.js:47:25)
49
- at Module._compile (node:internal/modules/cjs/loader:1565:14)
50
- at Object..js (node:internal/modules/cjs/loader:1708:10)
51
- at Module.load (node:internal/modules/cjs/loader:1318:32)
52
- at Function._load (node:internal/modules/cjs/loader:1128:12)
53
- at TracingChannel.traceSync (node:diagnostics_channel:322:14)
54
- at wrapModuleLoad (node:internal/modules/cjs/loader:219:24)
55
- at Module.require (node:internal/modules/cjs/loader:1340:12)
56
- at require (node:internal/modules/helpers:138:16)
57
- at Object.<anonymous> (/Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/test/setup.js:29:30)
58
- at Module._compile (node:internal/modules/cjs/loader:1565:14)
59
- at Object..js (node:internal/modules/cjs/loader:1708:10)
60
- at Module.load (node:internal/modules/cjs/loader:1318:32)
61
- at Function._load (node:internal/modules/cjs/loader:1128:12)
62
- at TracingChannel.traceSync (node:diagnostics_channel:322:14)
63
- at wrapModuleLoad (node:internal/modules/cjs/loader:219:24)
64
- at cjsLoader (node:internal/modules/esm/translators:263:5)
65
- at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:196:7)
66
- at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
67
- at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
68
- at async formattedImport (/Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/test/node_modules/mocha/lib/nodejs/esm-utils.js:9:14)
69
- at async exports.requireOrImport (/Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/test/node_modules/mocha/lib/nodejs/esm-utils.js:42:28)
70
- at async exports.loadFilesAsync (/Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/test/node_modules/mocha/lib/nodejs/esm-utils.js:100:20)
71
- at async singleRun (/Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/test/node_modules/mocha/lib/cli/run-helpers.js:162:3)
72
- at async exports.handler (/Users/Alessandro/Src/WorkSpace/WorkspaceDemo/Idealia/Keyclock/keycloak-api-manager/test/node_modules/mocha/lib/cli/run.js:375:5)