keycloak-express-middleware 6.3.0 → 6.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [6.3.2] - 2026-03-18
6
+
7
+ ### Added
8
+ - Automatic test config bootstrap via `test/helpers/ensure-test-config.js`.
9
+ - `npm run setup-keycloak` now ensures missing test config files are recreated before interactive setup:
10
+ - `test/config/secrets.json`
11
+ - `test/docker-keycloak/.env`
12
+
13
+ ### Changed
14
+ - npm package `files` whitelist keeps `test/` assets available on install while excluding sensitive/generated files (`test/config/secrets.json`, `test/docker-keycloak/.env`, local cert files).
15
+
16
+ ## [6.3.1] - 2026-03-18
17
+
18
+ ### Changed
19
+ - npm package content policy updated to include `test/` files in published tarball.
20
+ - `package.json` `files` whitelist now explicitly includes `test/**`, so test assets are available both via repository clone and `npm install`.
21
+
5
22
  ## [6.3.0] - 2026-03-18
6
23
 
7
24
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keycloak-express-middleware",
3
- "version": "6.3.0",
3
+ "version": "6.3.2",
4
4
  "description": "Adapter API to integrate Node.js (Express) applications with Keycloak. Provides middleware for authentication, authorization, token validation, and route protection via OpenID Connect.",
5
5
  "main": "index.js",
6
6
  "typings": "index.d.ts",
@@ -11,9 +11,35 @@
11
11
  "default": "./index.js"
12
12
  }
13
13
  },
14
+ "files": [
15
+ "index.js",
16
+ "index.d.ts",
17
+ "README.md",
18
+ "LICENSE",
19
+ "CHANGELOG.md",
20
+ "MIGRATION_STATUS.md",
21
+ "OIDC_INTEGRATION_GUIDE.md",
22
+ "config.js",
23
+ "config/default.json",
24
+ "docs/**",
25
+ "test/.mocharc.json",
26
+ "test/*.test.js",
27
+ "test/package.json",
28
+ "test/package-lock.json",
29
+ "test/config/default.json",
30
+ "test/config/secrets.json.example",
31
+ "test/helpers/**",
32
+ "test/support/**",
33
+ "test/docker-keycloak/README.md",
34
+ "test/docker-keycloak/docker-compose.yml",
35
+ "test/docker-keycloak/docker-compose-https.yml",
36
+ "test/docker-keycloak/setup-keycloak.js",
37
+ "test/docker-keycloak/certs/README.md",
38
+ "test/docker-keycloak/certs/.gitkeep"
39
+ ],
14
40
  "scripts": {
15
- "test": "npm --prefix test install && npm --prefix test run setup-keycloak && NODE_PATH=./test/node_modules npm --prefix test test",
16
- "setup-keycloak": "eval \"$(ssh-agent -s)\" && ssh-add ~/.ssh/id_ed25519 && NODE_ENV=test node test/docker-keycloak/setup-keycloak.js"
41
+ "test": "node test/helpers/ensure-test-config.js && npm --prefix test install && npm --prefix test run setup-keycloak && NODE_PATH=./test/node_modules npm --prefix test test",
42
+ "setup-keycloak": "node test/helpers/ensure-test-config.js && eval \"$(ssh-agent -s)\" && ssh-add ~/.ssh/id_ed25519 && NODE_ENV=test node test/docker-keycloak/setup-keycloak.js"
17
43
  },
18
44
  "dependencies": {
19
45
  "express-session": "^1.19.0",
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Ensures local test configuration files exist with valid defaults.
5
+ *
6
+ * Generated files (if missing):
7
+ * - test/config/secrets.json
8
+ * - test/docker-keycloak/.env
9
+ *
10
+ * Usage:
11
+ * node test/helpers/ensure-test-config.js
12
+ * node test/helpers/ensure-test-config.js --regenerate
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const force = process.argv.includes('--regenerate');
19
+
20
+ const repoRoot = path.join(__dirname, '..', '..');
21
+ const secretsPath = path.join(repoRoot, 'test', 'config', 'secrets.json');
22
+ const dockerEnvPath = path.join(repoRoot, 'test', 'docker-keycloak', '.env');
23
+
24
+ function ensureDir(filePath) {
25
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
26
+ }
27
+
28
+ function readJsonSafe(filePath) {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
31
+ } catch (_err) {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function writeJson(filePath, data) {
37
+ ensureDir(filePath);
38
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
39
+ }
40
+
41
+ function ensureSecrets() {
42
+ const defaults = {
43
+ test: {
44
+ keycloak: {
45
+ adminPassword: 'admin',
46
+ clientSecret: 'test-client-secret',
47
+ testPassword: 'test-password'
48
+ }
49
+ }
50
+ };
51
+
52
+ if (!fs.existsSync(secretsPath) || force) {
53
+ writeJson(secretsPath, defaults);
54
+ console.log(`[ensure-test-config] ${force ? 'Regenerated' : 'Created'} test/config/secrets.json`);
55
+ return;
56
+ }
57
+
58
+ const current = readJsonSafe(secretsPath);
59
+ if (!current) {
60
+ writeJson(secretsPath, defaults);
61
+ console.log('[ensure-test-config] Repaired invalid test/config/secrets.json');
62
+ return;
63
+ }
64
+
65
+ const merged = {
66
+ ...current,
67
+ test: {
68
+ ...(current.test || {}),
69
+ keycloak: {
70
+ ...defaults.test.keycloak,
71
+ ...((current.test && current.test.keycloak) || {})
72
+ }
73
+ }
74
+ };
75
+
76
+ // Update only if required keys are missing.
77
+ const currentString = JSON.stringify(current);
78
+ const mergedString = JSON.stringify(merged);
79
+ if (currentString !== mergedString) {
80
+ writeJson(secretsPath, merged);
81
+ console.log('[ensure-test-config] Added missing keys in test/config/secrets.json');
82
+ } else {
83
+ console.log('[ensure-test-config] test/config/secrets.json already valid');
84
+ }
85
+ }
86
+
87
+ function ensureDockerEnv() {
88
+ const content = [
89
+ 'KEYCLOAK_CERT_PATH=./certs',
90
+ 'KEYCLOAK_HOSTNAME=localhost'
91
+ ].join('\n') + '\n';
92
+
93
+ if (!fs.existsSync(dockerEnvPath) || force) {
94
+ ensureDir(dockerEnvPath);
95
+ fs.writeFileSync(dockerEnvPath, content, 'utf8');
96
+ console.log(`[ensure-test-config] ${force ? 'Regenerated' : 'Created'} test/docker-keycloak/.env`);
97
+ return;
98
+ }
99
+
100
+ const existing = fs.readFileSync(dockerEnvPath, 'utf8');
101
+ if (!existing.includes('KEYCLOAK_CERT_PATH=') || !existing.includes('KEYCLOAK_HOSTNAME=')) {
102
+ fs.writeFileSync(dockerEnvPath, content, 'utf8');
103
+ console.log('[ensure-test-config] Repaired test/docker-keycloak/.env');
104
+ } else {
105
+ console.log('[ensure-test-config] test/docker-keycloak/.env already valid');
106
+ }
107
+ }
108
+
109
+ function main() {
110
+ ensureSecrets();
111
+ ensureDockerEnv();
112
+ }
113
+
114
+ main();
package/oidc-methods.js DELETED
@@ -1,327 +0,0 @@
1
- /**
2
- * OIDC Authentication Methods for keycloak-express-middleware
3
- *
4
- * These methods provide OAuth2 OIDC token endpoint helpers:
5
- * - generateAuthorizationUrl(): Generate PKCE authorization URL
6
- * - loginWithCredentials(): Exchange credentials for tokens (generic OIDC grant)
7
- * - loginPKCE(): Exchange authorization code for tokens (PKCE flow)
8
- *
9
- * Integration Instructions:
10
- * 1. Copy these methods into the keycloakExpressMiddleware class in index.js
11
- * 2. In the constructor, save the keycloakConfig:
12
- * this.keycloakConfig = keycloakConfig;
13
- * this.clientId = keycloakConfig.resource || keycloakOptions.clientId;
14
- * this.clientSecret = keycloakConfig.credentials?.secret || keycloakOptions.clientSecret;
15
- * 3. Run tests to verify: npm test
16
- * 4. No external dependencies needed (crypto is built-in, fetch is global in Node 18+)
17
- */
18
-
19
- const crypto = require('crypto');
20
-
21
- /**
22
- * Helper: Base64url encode for PKCE
23
- * @private
24
- */
25
- function base64url(buffer) {
26
- return buffer
27
- .toString('base64')
28
- .replace(/\+/g, '-')
29
- .replace(/\//g, '_')
30
- .replace(/=/g, '');
31
- }
32
-
33
- /**
34
- * Generate Authorization URL + PKCE pair for initiating OAuth2 flow
35
- *
36
- * This method generates everything needed to start the PKCE flow:
37
- * - Authorization URL with code_challenge and state
38
- * - PKCE code_verifier (to exchange code later)
39
- * - State parameter (for CSRF protection)
40
- *
41
- * Store state + codeVerifier in session server-side, redirect user to authUrl
42
- *
43
- * @param {Object} options - Configuration options
44
- * @param {string} options.redirect_uri - Redirect URI (where user returns after login) - REQUIRED
45
- * @param {string} options.redirectUri - CamelCase alias of redirect_uri
46
- * @param {string} [options.scope] - Space-separated scopes (default: 'openid profile email')
47
- * @param {string} [options.state] - Custom state value (auto-generated if not provided)
48
- *
49
- * @returns {Object} PKCE initialization data:
50
- * - authUrl: Ready-to-use authorization URL
51
- * - state: CSRF token (store in session)
52
- * - codeVerifier: PKCE proof (store in session, never expose to client)
53
- *
54
- * @example
55
- * const pkceFlow = keycloakAdapter.generateAuthorizationUrl({
56
- * redirect_uri: 'https://app.example.com/auth/callback'
57
- * });
58
- *
59
- * // Output:
60
- * // {
61
- * // authUrl: 'https://keycloak.../auth?client_id=...&code_challenge=...',
62
- * // state: 'random_state_value',
63
- * // codeVerifier: 'random_verifier_value'
64
- * // }
65
- *
66
- * // Store in session
67
- * req.session.pkce_state = pkceFlow.state;
68
- * req.session.pkce_verifier = pkceFlow.codeVerifier;
69
- *
70
- * // Redirect user to Keycloak
71
- * res.redirect(pkceFlow.authUrl);
72
- */
73
- function generateAuthorizationUrl(options = {}) {
74
- if (!this.authServerUrl || !this.realmName || !this.clientId) {
75
- throw new Error(
76
- 'generateAuthorizationUrl requires middleware to be initialized with ' +
77
- 'valid authServerUrl, realmName, and clientId'
78
- );
79
- }
80
-
81
- const {
82
- redirect_uri,
83
- redirectUri,
84
- scope,
85
- state: customState
86
- } = options;
87
-
88
- const resolvedRedirectUri = redirect_uri || redirectUri;
89
- if (!resolvedRedirectUri) {
90
- throw new Error(
91
- 'generateAuthorizationUrl requires "redirect_uri" (or "redirectUri")'
92
- );
93
- }
94
-
95
- // Generate PKCE pair
96
- const codeVerifier = base64url(crypto.randomBytes(96));
97
- const codeChallenge = base64url(
98
- crypto.createHash('sha256').update(codeVerifier).digest()
99
- );
100
-
101
- // Generate or use provided state
102
- const state = customState || base64url(crypto.randomBytes(32));
103
-
104
- // Build authorization URL
105
- const authUrl = new URL(
106
- `${this.authServerUrl}realms/${this.realmName}/protocol/openid-connect/auth`
107
- );
108
-
109
- authUrl.searchParams.append('client_id', this.clientId);
110
- authUrl.searchParams.append('response_type', 'code');
111
- authUrl.searchParams.append('redirect_uri', resolvedRedirectUri);
112
- authUrl.searchParams.append('code_challenge', codeChallenge);
113
- authUrl.searchParams.append('code_challenge_method', 'S256');
114
- authUrl.searchParams.append('state', state);
115
-
116
- if (scope) {
117
- authUrl.searchParams.append('scope', scope);
118
- } else {
119
- authUrl.searchParams.append('scope', 'openid profile email');
120
- }
121
-
122
- return {
123
- authUrl: authUrl.toString(),
124
- state,
125
- codeVerifier
126
- };
127
- }
128
-
129
- /**
130
- * Exchange credentials for OIDC tokens (generic token endpoint helper)
131
- *
132
- * Supports any OAuth2 grant type:
133
- * - password: Resource Owner Password Grant (username + password)
134
- * - client_credentials: Client Credentials Grant
135
- * - authorization_code: Authorization Code Grant (without PKCE)
136
- * - refresh_token: Refresh Token Grant
137
- *
138
- * The method automatically appends clientId/clientSecret if configured and not overridden.
139
- *
140
- * @param {Object} credentials - OIDC token request parameters
141
- * @param {string} credentials.grant_type - OAuth2 grant type (required)
142
- * @param {string} [credentials.username] - Username (for password grant)
143
- * @param {string} [credentials.password] - Password (for password grant)
144
- * @param {string} [credentials.client_id] - Client ID (uses middleware config if not provided)
145
- * @param {string} [credentials.client_secret] - Client secret (uses middleware config if not provided)
146
- * @param {string} [credentials.refresh_token] - Refresh token (for refresh_token grant)
147
- * @param {string} [credentials.code] - Authorization code (for authorization_code grant)
148
- * @param {string} [credentials.redirect_uri] - Redirect URI (for authorization_code grant)
149
- * @param {string} [credentials.scope] - OAuth2 scope
150
- *
151
- * @returns {Promise<Object>} Token response from Keycloak:
152
- * - access_token: JWT access token
153
- * - refresh_token: Refresh token (if configured)
154
- * - id_token: ID token (if openid scope requested)
155
- * - expires_in: Token expiration in seconds
156
- * - token_type: Always "Bearer"
157
- *
158
- * @throws {Error} If token request fails
159
- *
160
- * @example
161
- * // Resource Owner Password Grant
162
- * const tokens = await keycloakAdapter.loginWithCredentials({
163
- * grant_type: 'password',
164
- * username: 'user@example.com',
165
- * password: 'password123',
166
- * scope: 'openid profile email'
167
- * });
168
- *
169
- * @example
170
- * // Client Credentials Grant
171
- * const tokens = await keycloakAdapter.loginWithCredentials({
172
- * grant_type: 'client_credentials',
173
- * scope: 'openid profile'
174
- * });
175
- *
176
- * @example
177
- * // Refresh Token Grant
178
- * const tokens = await keycloakAdapter.loginWithCredentials({
179
- * grant_type: 'refresh_token',
180
- * refresh_token: oldRefreshToken
181
- * });
182
- */
183
- async function loginWithCredentials(credentials = {}) {
184
- if (!this.authServerUrl || !this.realmName) {
185
- throw new Error(
186
- 'loginWithCredentials requires middleware to be initialized with valid authServerUrl and realmName'
187
- );
188
- }
189
-
190
- const body = new URLSearchParams();
191
-
192
- // Add provided credentials
193
- Object.entries(credentials).forEach(([key, value]) => {
194
- if (value !== undefined && value !== null) {
195
- body.append(key, String(value));
196
- }
197
- });
198
-
199
- // Add clientId if not already provided and configured
200
- if (this.clientId && !body.has('client_id')) {
201
- body.append('client_id', this.clientId);
202
- }
203
-
204
- // Add clientSecret if not already provided and configured
205
- if (this.clientSecret && !body.has('client_secret')) {
206
- body.append('client_secret', this.clientSecret);
207
- }
208
-
209
- const tokenUrl = `${this.authServerUrl}realms/${this.realmName}/protocol/openid-connect/token`;
210
-
211
- const response = await fetch(tokenUrl, {
212
- method: 'POST',
213
- headers: {
214
- 'content-type': 'application/x-www-form-urlencoded'
215
- },
216
- body
217
- });
218
-
219
- const responseText = await response.text();
220
- const payload = responseText ? JSON.parse(responseText) : {};
221
-
222
- if (!response.ok) {
223
- const errorMessage = payload.error_description || payload.error || 'Authentication failed';
224
- throw new Error(errorMessage);
225
- }
226
-
227
- return payload;
228
- }
229
-
230
- /**
231
- * Exchange authorization code + PKCE verifier for tokens (PKCE callback)
232
- *
233
- * This method is specialized for the callback route after user login.
234
- * It exchanges the authorization code (from redirect) + code_verifier for tokens.
235
- *
236
- * @param {Object} credentials - Token exchange parameters
237
- * @param {string} credentials.code - Authorization code (from Keycloak redirect) - REQUIRED
238
- * @param {string} credentials.redirect_uri - Redirect URI (must match authorize request) - REQUIRED
239
- * @param {string} credentials.redirectUri - CamelCase alias of redirect_uri
240
- * @param {string} credentials.code_verifier - PKCE code verifier (from session) - REQUIRED
241
- * @param {string} credentials.codeVerifier - CamelCase alias of code_verifier
242
- * @param {string} [credentials.client_id] - Client ID (uses middleware config if not provided)
243
- * @param {string} [credentials.clientId] - CamelCase alias of client_id
244
- * @param {string} [credentials.client_secret] - Client secret (uses middleware config if not provided)
245
- * @param {string} [credentials.clientSecret] - CamelCase alias of client_secret
246
- * @param {string} [credentials.scope] - Additional scope string
247
- *
248
- * @returns {Promise<Object>} Token response from Keycloak (same as loginWithCredentials())
249
- *
250
- * @throws {Error} If any required parameter is missing or token exchange fails
251
- *
252
- * @example
253
- * app.get('/auth/callback', async (req, res) => {
254
- * const { code, state } = req.query;
255
- *
256
- * // Validate state (CSRF protection)
257
- * if (state !== req.session.pkce_state) {
258
- * return res.status(400).send('CSRF attack detected');
259
- * }
260
- *
261
- * try {
262
- * // Exchange code for tokens
263
- * const tokens = await keycloakAdapter.loginPKCE({
264
- * code,
265
- * redirect_uri: 'https://app.example.com/auth/callback',
266
- * code_verifier: req.session.pkce_verifier
267
- * });
268
- *
269
- * // Set secure cookies
270
- * res.cookie('access_token', tokens.access_token, {
271
- * httpOnly: true,
272
- * secure: true,
273
- * sameSite: 'strict'
274
- * });
275
- *
276
- * res.redirect('/dashboard');
277
- * } catch (error) {
278
- * res.status(401).send('Authentication failed');
279
- * }
280
- * });
281
- */
282
- async function loginPKCE(credentials = {}) {
283
- const {
284
- code,
285
- redirect_uri,
286
- redirectUri,
287
- code_verifier,
288
- codeVerifier,
289
- client_id,
290
- clientId,
291
- client_secret,
292
- clientSecret,
293
- ...rest
294
- } = credentials;
295
-
296
- const resolvedCode = code;
297
- const resolvedRedirectUri = redirect_uri || redirectUri;
298
- const resolvedCodeVerifier = code_verifier || codeVerifier;
299
- const resolvedClientId = client_id || clientId;
300
- const resolvedClientSecret = client_secret || clientSecret;
301
-
302
- if (!resolvedCode) {
303
- throw new Error('loginPKCE requires "code".');
304
- }
305
- if (!resolvedRedirectUri) {
306
- throw new Error('loginPKCE requires "redirect_uri" (or "redirectUri").');
307
- }
308
- if (!resolvedCodeVerifier) {
309
- throw new Error('loginPKCE requires "code_verifier" (or "codeVerifier").');
310
- }
311
-
312
- return this.loginWithCredentials({
313
- grant_type: 'authorization_code',
314
- code: resolvedCode,
315
- redirect_uri: resolvedRedirectUri,
316
- code_verifier: resolvedCodeVerifier,
317
- ...(resolvedClientId ? { client_id: resolvedClientId } : {}),
318
- ...(resolvedClientSecret ? { client_secret: resolvedClientSecret } : {}),
319
- ...rest
320
- });
321
- }
322
-
323
- module.exports = {
324
- generateAuthorizationUrl,
325
- loginWithCredentials,
326
- loginPKCE
327
- };
@@ -1,9 +0,0 @@
1
- {
2
- "test": {
3
- "keycloak": {
4
- "adminPassword": "admin",
5
- "clientSecret": "test-client-secret",
6
- "testPassword": "test-password"
7
- }
8
- }
9
- }