keycloak-api-manager 5.0.6 → 5.0.7
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 +9 -0
- package/docs/api/configuration.md +2 -0
- package/docs/api-reference.md +3 -0
- package/docs/guides/PKCE-Login-Flow.md +610 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,6 +97,12 @@ Configured handler namespaces:
|
|
|
97
97
|
|
|
98
98
|
All documentation is centralized under `docs/`.
|
|
99
99
|
|
|
100
|
+
### Guides (Practical Implementation)
|
|
101
|
+
|
|
102
|
+
- [PKCE Login Flow Guide](docs/guides/PKCE-Login-Flow.md) - Step-by-step guide for implementing OAuth2 Authorization Code + PKCE authentication
|
|
103
|
+
|
|
104
|
+
### API Reference
|
|
105
|
+
|
|
100
106
|
- [API Reference (Index)](docs/api-reference.md)
|
|
101
107
|
- [API - Configuration](docs/api/configuration.md)
|
|
102
108
|
- [API - Realms](docs/api/realms.md)
|
|
@@ -113,6 +119,9 @@ All documentation is centralized under `docs/`.
|
|
|
113
119
|
- [API - User Profile](docs/api/user-profile.md)
|
|
114
120
|
- [API - Client Policies](docs/api/client-policies.md)
|
|
115
121
|
- [API - Server Info](docs/api/server-info.md)
|
|
122
|
+
|
|
123
|
+
### General Documentation
|
|
124
|
+
|
|
116
125
|
- [Architecture and Runtime](docs/architecture.md)
|
|
117
126
|
- [Keycloak Setup and Feature Flags](docs/keycloak-setup.md)
|
|
118
127
|
- [Testing Guide](docs/testing.md)
|
|
@@ -354,6 +354,8 @@ Perform Authorization Code + PKCE token exchange.
|
|
|
354
354
|
|
|
355
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
356
|
|
|
357
|
+
> **📖 For a complete step-by-step guide on implementing PKCE flow in your application, see [PKCE Login Flow Guide](../guides/PKCE-Login-Flow.md)**
|
|
358
|
+
|
|
357
359
|
**Syntax:**
|
|
358
360
|
```javascript
|
|
359
361
|
await KeycloakManager.loginPKCE(credentials)
|
package/docs/api-reference.md
CHANGED
|
@@ -4,6 +4,9 @@ Complete API documentation for keycloak-api-manager.
|
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
7
|
+
### Guides (Practical Implementation)
|
|
8
|
+
- [PKCE Login Flow Guide](guides/PKCE-Login-Flow.md) - Step-by-step OAuth2 Authorization Code + PKCE implementation guide
|
|
9
|
+
|
|
7
10
|
### Core API
|
|
8
11
|
- [Configuration & Authentication](api/configuration.md) - Setup, authentication, and lifecycle management
|
|
9
12
|
|
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
# PKCE Login Flow Guide
|
|
2
|
+
|
|
3
|
+
This guide walks you through implementing OAuth2 Authorization Code + PKCE flow in your application using Keycloak and the keycloak-api-manager library.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
PKCE (Proof Key for Code Exchange) is the modern, secure way for browser-based and mobile applications to authenticate users through Keycloak. Unlike the legacy resource owner password grant, PKCE:
|
|
8
|
+
|
|
9
|
+
- ✅ Never exposes user passwords to your backend
|
|
10
|
+
- ✅ Works seamlessly with Keycloak's authorization server
|
|
11
|
+
- ✅ Provides built-in CSRF protection via state parameter
|
|
12
|
+
- ✅ Protects against authorization code interception attacks
|
|
13
|
+
- ✅ Is the OAuth2 standard for public clients
|
|
14
|
+
|
|
15
|
+
## Flow Diagram
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
┌─────────────┐ ┌──────────────┐
|
|
19
|
+
│ Browser │ │ Keycloak │
|
|
20
|
+
│ (User) │ │ Server │
|
|
21
|
+
└──────┬──────┘ └──────────────┘
|
|
22
|
+
│ ▲
|
|
23
|
+
│ 1. Click "Login" │
|
|
24
|
+
├─────────────────────────────────────────────────►
|
|
25
|
+
│ │
|
|
26
|
+
│ 2. Verify state, generate PKCE pair │
|
|
27
|
+
│ 3. Redirect to Keycloak /auth with │
|
|
28
|
+
│ code_challenge & state │
|
|
29
|
+
│ │
|
|
30
|
+
│◄─────────────────────────────────────────────────┤
|
|
31
|
+
│ Keycloak login page │
|
|
32
|
+
│ │
|
|
33
|
+
│ 4. User enters credentials │
|
|
34
|
+
├─────────────────────────────────────────────────►
|
|
35
|
+
│ │
|
|
36
|
+
│ 5. Verify credentials │
|
|
37
|
+
│ 6. Redirect to /callback with │
|
|
38
|
+
│ code + state │
|
|
39
|
+
│ │
|
|
40
|
+
│◄─────────────────────────────────────────────────┤
|
|
41
|
+
│ code=abc123&state=xyz789 │
|
|
42
|
+
│ │
|
|
43
|
+
│ 7. Exchange code for token │
|
|
44
|
+
│ (with code_verifier) │
|
|
45
|
+
│──────────────────────────────────────────────────►
|
|
46
|
+
│ POST /auth/callback backend │
|
|
47
|
+
│ │
|
|
48
|
+
│ ┌────────────┐
|
|
49
|
+
│ │ Backend │
|
|
50
|
+
│ │ (Node.js) │
|
|
51
|
+
│ └────────────┘
|
|
52
|
+
│ ▲
|
|
53
|
+
│ │
|
|
54
|
+
│ │
|
|
55
|
+
│ 8. Call loginPKCE()
|
|
56
|
+
│ with code, verifier
|
|
57
|
+
│ │
|
|
58
|
+
│ Keycloak validates
|
|
59
|
+
│ code_challenge vs
|
|
60
|
+
│ code_verifier
|
|
61
|
+
│ │
|
|
62
|
+
│ 9. Return access token
|
|
63
|
+
│ (JWT)
|
|
64
|
+
│ │
|
|
65
|
+
│ 10. Set secure HttpOnly cookie │
|
|
66
|
+
│◄──────────────────────────────────────────┤
|
|
67
|
+
│ (with access_token + refresh_token) │
|
|
68
|
+
│ │
|
|
69
|
+
│ Browser is now authenticated! │
|
|
70
|
+
│ │
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Step-by-Step Implementation
|
|
74
|
+
|
|
75
|
+
### Step 1: Generate PKCE Pair
|
|
76
|
+
|
|
77
|
+
When the user clicks "Login", your backend generates a PKCE pair and stores it securely:
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
const crypto = require('crypto');
|
|
81
|
+
|
|
82
|
+
function base64url(buf) {
|
|
83
|
+
return buf
|
|
84
|
+
.toString('base64')
|
|
85
|
+
.replace(/\+/g, '-')
|
|
86
|
+
.replace(/\//g, '_')
|
|
87
|
+
.replace(/=/g, '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createPkcePair() {
|
|
91
|
+
// Generate code_verifier (random 128-char string)
|
|
92
|
+
const code_verifier = base64url(crypto.randomBytes(96));
|
|
93
|
+
|
|
94
|
+
// Generate code_challenge (SHA256 hash of verifier)
|
|
95
|
+
const code_challenge = base64url(
|
|
96
|
+
crypto.createHash('sha256').update(code_verifier).digest()
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Generate state (CSRF protection)
|
|
100
|
+
const state = base64url(crypto.randomBytes(32));
|
|
101
|
+
|
|
102
|
+
return { code_verifier, code_challenge, state };
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Why this works:**
|
|
107
|
+
- `code_verifier`: A random, unguessable string
|
|
108
|
+
- `code_challenge`: The SHA256 hash of the verifier, sent to Keycloak during login
|
|
109
|
+
- `state`: A random token to prevent CSRF attacks; Keycloak will return it unchanged
|
|
110
|
+
|
|
111
|
+
### Step 2: Redirect to Keycloak Authorization Endpoint
|
|
112
|
+
|
|
113
|
+
Store the PKCE pair in the session, then redirect the user to Keycloak:
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
const express = require('express');
|
|
117
|
+
const session = require('express-session');
|
|
118
|
+
const KeycloakManager = require('keycloak-api-manager');
|
|
119
|
+
|
|
120
|
+
const app = express();
|
|
121
|
+
|
|
122
|
+
// Session configuration (store verifier securely server-side)
|
|
123
|
+
app.use(session({
|
|
124
|
+
secret: 'your-secret-key',
|
|
125
|
+
resave: false,
|
|
126
|
+
saveUninitialized: true,
|
|
127
|
+
cookie: { httpOnly: true, secure: true } // secure: true in production (HTTPS only)
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// 1. User clicks "Login" button
|
|
131
|
+
app.get('/auth/login', (req, res) => {
|
|
132
|
+
const { code_verifier, code_challenge, state } = createPkcePair();
|
|
133
|
+
|
|
134
|
+
// Store verifier and state in session (server-side only!)
|
|
135
|
+
req.session.pkce_verifier = code_verifier;
|
|
136
|
+
req.session.pkce_state = state;
|
|
137
|
+
|
|
138
|
+
// Build Keycloak authorization URL
|
|
139
|
+
const keycloakAuthUrl = `${process.env.KEYCLOAK_URL}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/auth`;
|
|
140
|
+
const authUrl = new URL(keycloakAuthUrl);
|
|
141
|
+
|
|
142
|
+
authUrl.searchParams.append('client_id', process.env.KEYCLOAK_CLIENT_ID);
|
|
143
|
+
authUrl.searchParams.append('redirect_uri', `${process.env.APP_URL}/auth/callback`);
|
|
144
|
+
authUrl.searchParams.append('response_type', 'code');
|
|
145
|
+
authUrl.searchParams.append('scope', 'openid profile email');
|
|
146
|
+
authUrl.searchParams.append('code_challenge', code_challenge);
|
|
147
|
+
authUrl.searchParams.append('code_challenge_method', 'S256'); // S256 = SHA256
|
|
148
|
+
authUrl.searchParams.append('state', state);
|
|
149
|
+
|
|
150
|
+
// Redirect user to Keycloak login page
|
|
151
|
+
res.redirect(authUrl.toString());
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**What happens:**
|
|
156
|
+
- User is redirected to Keycloak
|
|
157
|
+
- They see the login page
|
|
158
|
+
- They enter their username/password
|
|
159
|
+
- On successful auth, Keycloak redirects back to your `/auth/callback` endpoint with `code` and `state`
|
|
160
|
+
|
|
161
|
+
### Step 3: Exchange Authorization Code for Token
|
|
162
|
+
|
|
163
|
+
When Keycloak redirects back with the authorization code, you exchange it for an access token:
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
// 2. Keycloak redirects back with authorization code
|
|
167
|
+
app.get('/auth/callback', async (req, res) => {
|
|
168
|
+
try {
|
|
169
|
+
const { code, state, error } = req.query;
|
|
170
|
+
|
|
171
|
+
// 1. Validate state parameter (CSRF protection)
|
|
172
|
+
if (state !== req.session.pkce_state) {
|
|
173
|
+
return res.status(400).send('Invalid state parameter - CSRF attack detected');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2. Check for authorization errors
|
|
177
|
+
if (error) {
|
|
178
|
+
return res.status(400).send(`Authorization failed: ${error}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 3. Retrieve stored verifier from session
|
|
182
|
+
const code_verifier = req.session.pkce_verifier;
|
|
183
|
+
|
|
184
|
+
if (!code_verifier) {
|
|
185
|
+
return res.status(400).send('PKCE verifier not found in session');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 4. Exchange code for token using loginPKCE()
|
|
189
|
+
const tokenResponse = await KeycloakManager.loginPKCE({
|
|
190
|
+
code,
|
|
191
|
+
redirect_uri: `${process.env.APP_URL}/auth/callback`,
|
|
192
|
+
code_verifier,
|
|
193
|
+
client_id: process.env.KEYCLOAK_CLIENT_ID,
|
|
194
|
+
client_secret: process.env.KEYCLOAK_CLIENT_SECRET
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// 5. Set secure HTTPOnly cookie with access token
|
|
198
|
+
res.cookie('access_token', tokenResponse.access_token, {
|
|
199
|
+
httpOnly: true, // Prevent XSS access
|
|
200
|
+
secure: true, // HTTPS only
|
|
201
|
+
sameSite: 'strict', // CSRF protection
|
|
202
|
+
maxAge: tokenResponse.expires_in * 1000
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Also store refresh token separately
|
|
206
|
+
res.cookie('refresh_token', tokenResponse.refresh_token, {
|
|
207
|
+
httpOnly: true,
|
|
208
|
+
secure: true,
|
|
209
|
+
sameSite: 'strict',
|
|
210
|
+
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// 6. Clear sensitive session data
|
|
214
|
+
delete req.session.pkce_verifier;
|
|
215
|
+
delete req.session.pkce_state;
|
|
216
|
+
|
|
217
|
+
// Redirect to application home
|
|
218
|
+
res.redirect('/dashboard');
|
|
219
|
+
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error('Token exchange failed:', error);
|
|
222
|
+
res.status(500).send('Authentication failed');
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Security checks in this step:**
|
|
228
|
+
- ✅ Validate `state` matches what we stored (CSRF protection)
|
|
229
|
+
- ✅ Check for authorization errors
|
|
230
|
+
- ✅ Verify `code_verifier` exists in session
|
|
231
|
+
- ✅ Exchange code with verifier (proves we initiated the flow)
|
|
232
|
+
- ✅ Store token in HttpOnly cookie (prevents XSS theft)
|
|
233
|
+
- ✅ Clear sensitive session data
|
|
234
|
+
|
|
235
|
+
### Step 4: Use the Access Token
|
|
236
|
+
|
|
237
|
+
Now the user has an access token in a secure cookie. Use it to access protected resources:
|
|
238
|
+
|
|
239
|
+
```javascript
|
|
240
|
+
// Middleware to verify access token
|
|
241
|
+
app.use((req, res, next) => {
|
|
242
|
+
const token = req.cookies.access_token;
|
|
243
|
+
|
|
244
|
+
if (!token) {
|
|
245
|
+
return res.status(401).send('Not authenticated');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Verify and decode the token
|
|
249
|
+
try {
|
|
250
|
+
const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY);
|
|
251
|
+
req.user = decoded; // User data available in request
|
|
252
|
+
next();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return res.status(401).send('Invalid token');
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Protected route
|
|
259
|
+
app.get('/dashboard', (req, res) => {
|
|
260
|
+
res.send(`Welcome, ${req.user.preferred_username}!`);
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Complete Working Example
|
|
265
|
+
|
|
266
|
+
Here's a complete Express.js application with PKCE flow:
|
|
267
|
+
|
|
268
|
+
```javascript
|
|
269
|
+
const express = require('express');
|
|
270
|
+
const session = require('express-session');
|
|
271
|
+
const crypto = require('crypto');
|
|
272
|
+
const jwt = require('jsonwebtoken');
|
|
273
|
+
const cookieParser = require('cookie-parser');
|
|
274
|
+
const KeycloakManager = require('keycloak-api-manager');
|
|
275
|
+
|
|
276
|
+
const app = express();
|
|
277
|
+
|
|
278
|
+
// Middleware
|
|
279
|
+
app.use(express.json());
|
|
280
|
+
app.use(cookieParser());
|
|
281
|
+
app.use(session({
|
|
282
|
+
secret: process.env.SESSION_SECRET || 'dev-secret',
|
|
283
|
+
resave: false,
|
|
284
|
+
saveUninitialized: true,
|
|
285
|
+
cookie: { httpOnly: true, secure: false } // Set secure: true in production
|
|
286
|
+
}));
|
|
287
|
+
|
|
288
|
+
// Initialize Keycloak Manager
|
|
289
|
+
KeycloakManager.configure({
|
|
290
|
+
realmName: process.env.KEYCLOAK_REALM,
|
|
291
|
+
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
|
292
|
+
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
|
293
|
+
keycloakUrl: process.env.KEYCLOAK_URL
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Helper functions
|
|
297
|
+
function base64url(buf) {
|
|
298
|
+
return buf
|
|
299
|
+
.toString('base64')
|
|
300
|
+
.replace(/\+/g, '-')
|
|
301
|
+
.replace(/\//g, '_')
|
|
302
|
+
.replace(/=/g, '');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function createPkcePair() {
|
|
306
|
+
const code_verifier = base64url(crypto.randomBytes(96));
|
|
307
|
+
const code_challenge = base64url(
|
|
308
|
+
crypto.createHash('sha256').update(code_verifier).digest()
|
|
309
|
+
);
|
|
310
|
+
const state = base64url(crypto.randomBytes(32));
|
|
311
|
+
return { code_verifier, code_challenge, state };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Routes
|
|
315
|
+
app.get('/auth/login', (req, res) => {
|
|
316
|
+
const { code_verifier, code_challenge, state } = createPkcePair();
|
|
317
|
+
|
|
318
|
+
req.session.pkce_verifier = code_verifier;
|
|
319
|
+
req.session.pkce_state = state;
|
|
320
|
+
|
|
321
|
+
const keycloakAuthUrl = `${process.env.KEYCLOAK_URL}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/auth`;
|
|
322
|
+
const authUrl = new URL(keycloakAuthUrl);
|
|
323
|
+
|
|
324
|
+
authUrl.searchParams.append('client_id', process.env.KEYCLOAK_CLIENT_ID);
|
|
325
|
+
authUrl.searchParams.append('redirect_uri', `${process.env.APP_URL}/auth/callback`);
|
|
326
|
+
authUrl.searchParams.append('response_type', 'code');
|
|
327
|
+
authUrl.searchParams.append('scope', 'openid profile email');
|
|
328
|
+
authUrl.searchParams.append('code_challenge', code_challenge);
|
|
329
|
+
authUrl.searchParams.append('code_challenge_method', 'S256');
|
|
330
|
+
authUrl.searchParams.append('state', state);
|
|
331
|
+
|
|
332
|
+
res.redirect(authUrl.toString());
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
app.get('/auth/callback', async (req, res) => {
|
|
336
|
+
try {
|
|
337
|
+
const { code, state, error } = req.query;
|
|
338
|
+
|
|
339
|
+
if (state !== req.session.pkce_state) {
|
|
340
|
+
return res.status(400).send('Invalid state parameter');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (error) {
|
|
344
|
+
return res.status(400).send(`Authorization failed: ${error}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const code_verifier = req.session.pkce_verifier;
|
|
348
|
+
if (!code_verifier) {
|
|
349
|
+
return res.status(400).send('PKCE verifier not found');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Exchange code for token
|
|
353
|
+
const tokenResponse = await KeycloakManager.loginPKCE({
|
|
354
|
+
code,
|
|
355
|
+
redirect_uri: `${process.env.APP_URL}/auth/callback`,
|
|
356
|
+
code_verifier,
|
|
357
|
+
client_id: process.env.KEYCLOAK_CLIENT_ID,
|
|
358
|
+
client_secret: process.env.KEYCLOAK_CLIENT_SECRET
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Set secure cookies
|
|
362
|
+
res.cookie('access_token', tokenResponse.access_token, {
|
|
363
|
+
httpOnly: true,
|
|
364
|
+
secure: true,
|
|
365
|
+
sameSite: 'strict',
|
|
366
|
+
maxAge: tokenResponse.expires_in * 1000
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
res.cookie('refresh_token', tokenResponse.refresh_token, {
|
|
370
|
+
httpOnly: true,
|
|
371
|
+
secure: true,
|
|
372
|
+
sameSite: 'strict',
|
|
373
|
+
maxAge: 7 * 24 * 60 * 60 * 1000
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
delete req.session.pkce_verifier;
|
|
377
|
+
delete req.session.pkce_state;
|
|
378
|
+
|
|
379
|
+
res.redirect('/dashboard');
|
|
380
|
+
|
|
381
|
+
} catch (error) {
|
|
382
|
+
console.error('Token exchange failed:', error);
|
|
383
|
+
res.status(500).send('Authentication failed');
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
app.get('/dashboard', (req, res) => {
|
|
388
|
+
const token = req.cookies.access_token;
|
|
389
|
+
if (!token) {
|
|
390
|
+
return res.redirect('/auth/login');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
const decoded = jwt.decode(token); // Or verify with public key in production
|
|
395
|
+
res.send(`Welcome, ${decoded.preferred_username}!`);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
res.redirect('/auth/login');
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
app.get('/logout', (req, res) => {
|
|
402
|
+
res.clearCookie('access_token');
|
|
403
|
+
res.clearCookie('refresh_token');
|
|
404
|
+
req.session.destroy();
|
|
405
|
+
res.redirect('/');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Start server
|
|
409
|
+
app.listen(3000, () => {
|
|
410
|
+
console.log('Server running on http://localhost:3000');
|
|
411
|
+
console.log('Login at http://localhost:3000/auth/login');
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## Environment Variables
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
KEYCLOAK_URL=http://localhost:8080
|
|
419
|
+
KEYCLOAK_REALM=master
|
|
420
|
+
KEYCLOAK_CLIENT_ID=my-app
|
|
421
|
+
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
|
422
|
+
APP_URL=http://localhost:3000
|
|
423
|
+
SESSION_SECRET=your-session-secret
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
## Security Best Practices
|
|
427
|
+
|
|
428
|
+
### ✅ DO
|
|
429
|
+
|
|
430
|
+
- ✅ Store PKCE verifier in **secure server-side session**, never in cookies
|
|
431
|
+
- ✅ Validate state parameter every time (CSRF protection)
|
|
432
|
+
- ✅ Use **HttpOnly cookies** for tokens (prevents XSS theft)
|
|
433
|
+
- ✅ Use **Secure flag** on cookies (HTTPS only in production)
|
|
434
|
+
- ✅ Use **SameSite=strict** on cookies (CSRF protection)
|
|
435
|
+
- ✅ Clear sensitive data from session after token exchange
|
|
436
|
+
- ✅ Use SHA256 for code_challenge (`S256` method)
|
|
437
|
+
- ✅ Generate cryptographically secure random values (use `crypto` module)
|
|
438
|
+
|
|
439
|
+
### ❌ DON'T
|
|
440
|
+
|
|
441
|
+
- ❌ Store code_verifier in browser (localStorage, sessionStorage, etc.)
|
|
442
|
+
- ❌ Skip state parameter validation
|
|
443
|
+
- ❌ Use weak random generators
|
|
444
|
+
- ❌ Expose tokens in URL parameters
|
|
445
|
+
- ❌ Store tokens in browser storage (vulnerable to XSS)
|
|
446
|
+
- ❌ Use plain HTTP (always HTTPS in production)
|
|
447
|
+
- ❌ Reuse PKCE pairs or state values
|
|
448
|
+
|
|
449
|
+
## Common Issues & Troubleshooting
|
|
450
|
+
|
|
451
|
+
### "Invalid state parameter - CSRF attack detected"
|
|
452
|
+
|
|
453
|
+
**Cause:** Browser tabs have separate sessions, or cookies are not being persisted.
|
|
454
|
+
|
|
455
|
+
**Solution:**
|
|
456
|
+
```javascript
|
|
457
|
+
// Ensure session cookies are sent with redirects
|
|
458
|
+
app.use(session({
|
|
459
|
+
cookie: {
|
|
460
|
+
httpOnly: true,
|
|
461
|
+
secure: true,
|
|
462
|
+
sameSite: 'lax' // Allow cross-site for redirects
|
|
463
|
+
}
|
|
464
|
+
}));
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### "PKCE verifier not found in session"
|
|
468
|
+
|
|
469
|
+
**Cause:** Session was lost between `/auth/login` and `/auth/callback`.
|
|
470
|
+
|
|
471
|
+
**Debug:**
|
|
472
|
+
```javascript
|
|
473
|
+
app.get('/auth/callback', (req, res) => {
|
|
474
|
+
console.log('Session ID:', req.sessionID);
|
|
475
|
+
console.log('Session data:', req.session);
|
|
476
|
+
// ... rest of callback
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### "Invalid code_challenge"
|
|
481
|
+
|
|
482
|
+
**Cause:** The code_challenge calculated by Keycloak doesn't match our SHA256 hash.
|
|
483
|
+
|
|
484
|
+
**Verify:** The `createPkcePair()` function uses S256 (SHA256). Ensure code_challenge is:
|
|
485
|
+
1. Lowercase base64url-encoded
|
|
486
|
+
2. Derived from code_verifier with SHA256, not any other hash
|
|
487
|
+
|
|
488
|
+
### Access token not being set
|
|
489
|
+
|
|
490
|
+
**Cause:** `loginPKCE()` threw an error that wasn't caught, or response format is different.
|
|
491
|
+
|
|
492
|
+
**Debug:**
|
|
493
|
+
```javascript
|
|
494
|
+
const tokenResponse = await KeycloakManager.loginPKCE({...});
|
|
495
|
+
console.log('Token response:', JSON.stringify(tokenResponse, null, 2));
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Expected response:
|
|
499
|
+
```json
|
|
500
|
+
{
|
|
501
|
+
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
502
|
+
"expires_in": 300,
|
|
503
|
+
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
504
|
+
"token_type": "Bearer",
|
|
505
|
+
"scope": "openid profile email"
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
## Production Considerations
|
|
510
|
+
|
|
511
|
+
### 1. Session Storage
|
|
512
|
+
|
|
513
|
+
For production, use Redis instead of in-memory sessions:
|
|
514
|
+
|
|
515
|
+
```javascript
|
|
516
|
+
const RedisStore = require('connect-redis').default;
|
|
517
|
+
const { createClient } = require('redis');
|
|
518
|
+
|
|
519
|
+
const redisClient = createClient();
|
|
520
|
+
redisClient.connect();
|
|
521
|
+
|
|
522
|
+
app.use(session({
|
|
523
|
+
store: new RedisStore({ client: redisClient }),
|
|
524
|
+
secret: process.env.SESSION_SECRET,
|
|
525
|
+
resave: false,
|
|
526
|
+
saveUninitialized: false,
|
|
527
|
+
cookie: {
|
|
528
|
+
httpOnly: true,
|
|
529
|
+
secure: true,
|
|
530
|
+
sameSite: 'strict',
|
|
531
|
+
maxAge: 1000 * 60 * 60 * 24 // 24 hours
|
|
532
|
+
}
|
|
533
|
+
}));
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
### 2. Token Refresh
|
|
537
|
+
|
|
538
|
+
Access tokens expire. Implement refresh token logic:
|
|
539
|
+
|
|
540
|
+
```javascript
|
|
541
|
+
function isTokenExpiring(token) {
|
|
542
|
+
const decoded = jwt.decode(token);
|
|
543
|
+
const now = Math.floor(Date.now() / 1000);
|
|
544
|
+
return decoded.exp - now < 60; // Refresh if less than 60 seconds left
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
app.use(async (req, res, next) => {
|
|
548
|
+
const token = req.cookies.access_token;
|
|
549
|
+
const refreshToken = req.cookies.refresh_token;
|
|
550
|
+
|
|
551
|
+
if (!token) return next();
|
|
552
|
+
|
|
553
|
+
if (isTokenExpiring(token) && refreshToken) {
|
|
554
|
+
try {
|
|
555
|
+
const newTokens = await KeycloakManager.login({
|
|
556
|
+
grant_type: 'refresh_token',
|
|
557
|
+
refresh_token: refreshToken,
|
|
558
|
+
client_id: process.env.KEYCLOAK_CLIENT_ID,
|
|
559
|
+
client_secret: process.env.KEYCLOAK_CLIENT_SECRET
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
res.cookie('access_token', newTokens.access_token, { httpOnly: true, secure: true });
|
|
563
|
+
res.cookie('refresh_token', newTokens.refresh_token, { httpOnly: true, secure: true });
|
|
564
|
+
} catch (error) {
|
|
565
|
+
res.clearCookie('access_token');
|
|
566
|
+
res.clearCookie('refresh_token');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
next();
|
|
571
|
+
});
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
### 3. Token Verification
|
|
575
|
+
|
|
576
|
+
Always verify tokens using Keycloak's public key:
|
|
577
|
+
|
|
578
|
+
```javascript
|
|
579
|
+
const jwksClient = require('jwks-rsa');
|
|
580
|
+
|
|
581
|
+
const client = jwksClient({
|
|
582
|
+
jwksUri: `${process.env.KEYCLOAK_URL}/auth/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
function getKey(header, callback) {
|
|
586
|
+
client.getSigningKey(header.kid, (err, key) => {
|
|
587
|
+
if (err) return callback(err);
|
|
588
|
+
callback(null, key.getPublicKey());
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function verifyToken(token) {
|
|
593
|
+
return new Promise((resolve, reject) => {
|
|
594
|
+
jwt.verify(token, getKey, {
|
|
595
|
+
algorithms: ['RS256'],
|
|
596
|
+
issuer: `${process.env.KEYCLOAK_URL}/auth/realms/${process.env.KEYCLOAK_REALM}`,
|
|
597
|
+
audience: process.env.KEYCLOAK_CLIENT_ID
|
|
598
|
+
}, (err, decoded) => {
|
|
599
|
+
err ? reject(err) : resolve(decoded);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
## Related Documentation
|
|
606
|
+
|
|
607
|
+
- [loginPKCE() API Reference](../api/configuration.md#loginpkce)
|
|
608
|
+
- [login() API Reference](../api/configuration.md#login)
|
|
609
|
+
- [OAuth2 PKCE Specification](https://tools.ietf.org/html/rfc7636)
|
|
610
|
+
- [Keycloak Authorization Code Flow](https://www.keycloak.org/docs/latest/server_admin/#_oidc)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keycloak-api-manager",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.7",
|
|
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": {
|