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 +3 -0
- package/docs/api/configuration.md +143 -0
- package/docs/api-reference.md +1 -0
- package/index.js +43 -2
- package/package.json +1 -1
- package/test/specs/clientCredentials.test.js +66 -0
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.
|
package/docs/api-reference.md
CHANGED
|
@@ -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.
|
|
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
|
});
|