keycloak-api-manager 5.0.4 → 5.0.6

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 CHANGED
@@ -66,11 +66,14 @@ In Keycloak 26.x, management-permissions APIs used by group/user fine-grained te
66
66
  - `setConfig(overrides)`
67
67
  - `getToken()`
68
68
  - `login(credentials)`
69
+ - `loginPKCE(credentials)`
69
70
  - `auth(credentials)`
70
71
  - `stop()`
71
72
 
72
73
  `login(credentials)` is the preferred OIDC token endpoint helper for login/token grant flows (user/client).
73
74
 
75
+ `loginPKCE(credentials)` is a specialized helper for Authorization Code + PKCE token exchange.
76
+
74
77
  `auth(credentials)` is kept as backward-compatible alias and does not replace the internal admin session configured by `configure()`.
75
78
 
76
79
  Configured handler namespaces:
@@ -8,6 +8,7 @@ Core API methods for initializing and managing the Keycloak Admin Client connect
8
8
  - [setConfig()](#setconfig)
9
9
  - [getToken()](#gettoken)
10
10
  - [login()](#login)
11
+ - [loginPKCE()](#loginpkce)
11
12
  - [auth()](#auth)
12
13
  - [stop()](#stop)
13
14
 
@@ -347,6 +348,148 @@ console.log(refreshed.access_token);
347
348
 
348
349
  ---
349
350
 
351
+ ## loginPKCE()
352
+
353
+ Perform Authorization Code + PKCE token exchange.
354
+
355
+ This helper is intended for the callback step after user login on Keycloak, where your backend receives an authorization `code` and exchanges it with `code_verifier`.
356
+
357
+ **Syntax:**
358
+ ```javascript
359
+ await KeycloakManager.loginPKCE(credentials)
360
+ ```
361
+
362
+ ### Parameters
363
+
364
+ #### credentials (Object) ⚠️ Required
365
+
366
+ | Property | Type | Required | Description |
367
+ |----------|------|----------|-------------|
368
+ | `code` | string | ⚠️ Yes | Authorization code returned by Keycloak |
369
+ | `redirect_uri` | string | ⚠️ Yes* | Redirect URI used in authorize request |
370
+ | `redirectUri` | string | ⚠️ Yes* | CamelCase alias of `redirect_uri` |
371
+ | `code_verifier` | string | ⚠️ Yes* | PKCE code verifier |
372
+ | `codeVerifier` | string | ⚠️ Yes* | CamelCase alias of `code_verifier` |
373
+ | `client_id` | string | 📋 Optional | Overrides runtime `clientId` |
374
+ | `clientId` | string | 📋 Optional | CamelCase alias of `client_id` |
375
+ | `client_secret` | string | 📋 Optional | Overrides runtime `clientSecret` |
376
+ | `clientSecret` | string | 📋 Optional | CamelCase alias of `client_secret` |
377
+ | `scope` | string | 📋 Optional | Additional scope string |
378
+
379
+ `*` required with either snake_case or camelCase form.
380
+
381
+ ### Returns
382
+
383
+ **Promise\<Object\>** - Token payload returned by Keycloak (`access_token`, `refresh_token`, `id_token`, `expires_in`, ...)
384
+
385
+ ### Complete End-to-End Example (Express)
386
+
387
+ ```javascript
388
+ const crypto = require('crypto');
389
+ const express = require('express');
390
+ const KeycloakManager = require('keycloak-api-manager');
391
+
392
+ const app = express();
393
+
394
+ const KEYCLOAK_BASE_URL = 'https://keycloak.example.com';
395
+ const REALM = 'my-realm';
396
+ const CLIENT_ID = 'my-confidential-client';
397
+ const CLIENT_SECRET = process.env.KC_CLIENT_SECRET;
398
+ const REDIRECT_URI = 'https://my-app.example.com/auth/callback';
399
+
400
+ // Demo in-memory store. In production, use Redis/session storage.
401
+ const pkceStore = new Map();
402
+
403
+ function base64url(buffer) {
404
+ return buffer
405
+ .toString('base64')
406
+ .replace(/\+/g, '-')
407
+ .replace(/\//g, '_')
408
+ .replace(/=+$/, '');
409
+ }
410
+
411
+ function createPkcePair() {
412
+ const codeVerifier = base64url(crypto.randomBytes(32));
413
+ const codeChallenge = base64url(
414
+ crypto.createHash('sha256').update(codeVerifier).digest()
415
+ );
416
+ const state = base64url(crypto.randomBytes(16));
417
+ return { codeVerifier, codeChallenge, state };
418
+ }
419
+
420
+ async function bootstrap() {
421
+ // Configure runtime context so loginPKCE can reuse baseUrl/realm/client credentials
422
+ await KeycloakManager.configure({
423
+ baseUrl: KEYCLOAK_BASE_URL,
424
+ realmName: REALM,
425
+ grantType: 'client_credentials',
426
+ clientId: CLIENT_ID,
427
+ clientSecret: CLIENT_SECRET
428
+ });
429
+ }
430
+
431
+ // 1) Start login flow: generate PKCE and redirect to Keycloak /auth endpoint
432
+ app.get('/auth/login', (req, res) => {
433
+ const { codeVerifier, codeChallenge, state } = createPkcePair();
434
+ pkceStore.set(state, codeVerifier);
435
+
436
+ const authorizeUrl =
437
+ `${KEYCLOAK_BASE_URL}/realms/${REALM}/protocol/openid-connect/auth` +
438
+ `?client_id=${encodeURIComponent(CLIENT_ID)}` +
439
+ `&response_type=code` +
440
+ `&scope=${encodeURIComponent('openid profile email')}` +
441
+ `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
442
+ `&code_challenge=${encodeURIComponent(codeChallenge)}` +
443
+ `&code_challenge_method=S256` +
444
+ `&state=${encodeURIComponent(state)}`;
445
+
446
+ res.redirect(authorizeUrl);
447
+ });
448
+
449
+ // 2) Callback: receive code+state, recover verifier, exchange code for tokens
450
+ app.get('/auth/callback', async (req, res) => {
451
+ const { code, state } = req.query;
452
+ const codeVerifier = pkceStore.get(state);
453
+ pkceStore.delete(state);
454
+
455
+ if (!code || !state || !codeVerifier) {
456
+ return res.status(400).json({ error: 'Invalid PKCE callback parameters' });
457
+ }
458
+
459
+ try {
460
+ const tokenResponse = await KeycloakManager.loginPKCE({
461
+ code,
462
+ redirectUri: REDIRECT_URI,
463
+ codeVerifier
464
+ });
465
+
466
+ // Usually you create your own app session/JWT here, instead of returning raw tokens
467
+ return res.json({
468
+ access_token: tokenResponse.access_token,
469
+ refresh_token: tokenResponse.refresh_token,
470
+ expires_in: tokenResponse.expires_in
471
+ });
472
+ } catch (error) {
473
+ return res.status(401).json({ error: error.message });
474
+ }
475
+ });
476
+
477
+ bootstrap()
478
+ .then(() => app.listen(3000, () => console.log('Listening on :3000')))
479
+ .catch((error) => {
480
+ console.error('Startup error:', error.message);
481
+ process.exit(1);
482
+ });
483
+ ```
484
+
485
+ ### Notes
486
+
487
+ - `loginPKCE()` forces `grant_type=authorization_code`.
488
+ - If `client_id/client_secret` are omitted, runtime values from `configure()` are used.
489
+ - `loginPKCE()` does not generate authorize URLs, `state`, or PKCE challenge; it only performs token exchange.
490
+
491
+ ---
492
+
350
493
  ## stop()
351
494
 
352
495
  Stop the automatic token refresh timer and cleanup resources. Call this when shutting down your application.
@@ -77,6 +77,7 @@ KeycloakManager.stop();
77
77
  | `setConfig()` | Runtime configuration | Core |
78
78
  | `getToken()` | Get current access token | Core |
79
79
  | `login()` | Preferred OIDC token grant/login endpoint wrapper | Core |
80
+ | `loginPKCE()` | Authorization Code + PKCE token exchange helper | Core |
80
81
  | `auth()` | Backward-compatible alias of `login()` | Core |
81
82
  | `stop()` | Stop token refresh timer | Core |
82
83
  | `realms` | Realm management | realmsHandler |
package/index.js CHANGED
@@ -148,10 +148,10 @@ async function requestOidcToken(credentials = {}) {
148
148
  }
149
149
  });
150
150
 
151
- if (runtimeConfig.clientId) {
151
+ if (runtimeConfig.clientId && !body.has('client_id')) {
152
152
  body.append('client_id', runtimeConfig.clientId);
153
153
  }
154
- if (runtimeConfig.clientSecret) {
154
+ if (runtimeConfig.clientSecret && !body.has('client_secret')) {
155
155
  body.append('client_secret', runtimeConfig.clientSecret);
156
156
  }
157
157
 
@@ -184,3 +184,44 @@ exports.auth = async function auth(credentials = {}) {
184
184
  exports.login = async function login(credentials = {}) {
185
185
  return requestOidcToken(credentials);
186
186
  };
187
+
188
+ exports.loginPKCE = async function loginPKCE(credentials = {}) {
189
+ const {
190
+ code,
191
+ redirect_uri,
192
+ redirectUri,
193
+ code_verifier,
194
+ codeVerifier,
195
+ client_id,
196
+ clientId,
197
+ client_secret,
198
+ clientSecret,
199
+ ...rest
200
+ } = credentials;
201
+
202
+ const resolvedCode = code;
203
+ const resolvedRedirectUri = redirect_uri || redirectUri;
204
+ const resolvedCodeVerifier = code_verifier || codeVerifier;
205
+ const resolvedClientId = client_id || clientId;
206
+ const resolvedClientSecret = client_secret || clientSecret;
207
+
208
+ if (!resolvedCode) {
209
+ throw new Error('loginPKCE requires "code".');
210
+ }
211
+ if (!resolvedRedirectUri) {
212
+ throw new Error('loginPKCE requires "redirect_uri" (or "redirectUri").');
213
+ }
214
+ if (!resolvedCodeVerifier) {
215
+ throw new Error('loginPKCE requires "code_verifier" (or "codeVerifier").');
216
+ }
217
+
218
+ return requestOidcToken({
219
+ grant_type: 'authorization_code',
220
+ code: resolvedCode,
221
+ redirect_uri: resolvedRedirectUri,
222
+ code_verifier: resolvedCodeVerifier,
223
+ ...(resolvedClientId ? { client_id: resolvedClientId } : {}),
224
+ ...(resolvedClientSecret ? { client_secret: resolvedClientSecret } : {}),
225
+ ...rest
226
+ });
227
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keycloak-api-manager",
3
- "version": "5.0.4",
3
+ "version": "5.0.6",
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": {
@@ -76,4 +76,70 @@ describe('Authentication - client_credentials grant', function () {
76
76
  expect(token).to.have.property('accessToken');
77
77
  expect(token.accessToken).to.be.a('string').and.to.have.length.greaterThan(0);
78
78
  });
79
+
80
+ it('supports login() for direct token grant', async function () {
81
+ if (!testClientId || !testClientSecret) {
82
+ this.skip();
83
+ return;
84
+ }
85
+
86
+ keycloakManager.setConfig({ realmName: TEST_REALM });
87
+
88
+ const tokenPayload = await keycloakManager.login({
89
+ grant_type: 'client_credentials',
90
+ client_id: testClientId,
91
+ client_secret: testClientSecret,
92
+ });
93
+
94
+ expect(tokenPayload).to.have.property('access_token');
95
+ expect(tokenPayload.access_token).to.be.a('string').and.to.have.length.greaterThan(0);
96
+ expect(tokenPayload).to.have.property('token_type');
97
+ });
98
+
99
+ it('validates loginPKCE required parameters', async function () {
100
+ try {
101
+ await keycloakManager.loginPKCE({});
102
+ throw new Error('Expected loginPKCE to fail when code is missing');
103
+ } catch (error) {
104
+ expect(error.message).to.include('requires "code"');
105
+ }
106
+
107
+ try {
108
+ await keycloakManager.loginPKCE({ code: 'dummy-code' });
109
+ throw new Error('Expected loginPKCE to fail when redirect_uri is missing');
110
+ } catch (error) {
111
+ expect(error.message).to.include('requires "redirect_uri"');
112
+ }
113
+
114
+ try {
115
+ await keycloakManager.loginPKCE({
116
+ code: 'dummy-code',
117
+ redirect_uri: 'https://example.local/callback',
118
+ });
119
+ throw new Error('Expected loginPKCE to fail when code_verifier is missing');
120
+ } catch (error) {
121
+ expect(error.message).to.include('requires "code_verifier"');
122
+ }
123
+ });
124
+
125
+ it('attempts PKCE token exchange and returns OIDC error for invalid code', async function () {
126
+ if (!testClientId || !testClientSecret) {
127
+ this.skip();
128
+ return;
129
+ }
130
+
131
+ try {
132
+ await keycloakManager.loginPKCE({
133
+ code: 'invalid-authorization-code',
134
+ redirect_uri: 'https://example.local/callback',
135
+ code_verifier: 'plain-verifier-for-test-only',
136
+ client_id: testClientId,
137
+ client_secret: testClientSecret,
138
+ });
139
+ throw new Error('Expected loginPKCE to fail with invalid authorization code');
140
+ } catch (error) {
141
+ expect(error).to.be.instanceOf(Error);
142
+ expect(error.message).to.be.a('string').and.to.have.length.greaterThan(0);
143
+ }
144
+ });
79
145
  });