keycloak-express-middleware 6.3.2 → 6.3.3

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,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [6.3.3] - 2026-03-18
6
+
7
+ ### Added
8
+ - Expanded test coverage for outbound helper APIs:
9
+ - `getServiceToken()` edge cases (`minValiditySeconds`, custom `cacheKey`, scope array normalization, per-scope cache isolation, missing `access_token` handling)
10
+ - `callProtectedApi()` edge cases (`passthrough`/`none` modes, invalid inputs, JSON body serialization, timeout abort, non-JSON and empty response handling, retry toggle behavior)
11
+ - Added integration coverage for:
12
+ - service token retrieval and cache behavior against configured Keycloak test realm
13
+ - outbound helper call behavior in user mode and none mode
14
+ - token-claim decoding flow in `getTokenClaims`
15
+ - Added OIDC negative-path tests for non-JSON token-endpoint errors and failed PKCE token exchange responses.
16
+
17
+ ### Changed
18
+ - Updated deploy setup output in `test/docker-keycloak/setup-keycloak.js` to reflect the current automated bootstrap flow (no misleading manual secret-copy instruction in the default path).
19
+ - Normalized formatting of `test/config/default.json`.
20
+
5
21
  ## [6.3.2] - 2026-03-18
6
22
 
7
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keycloak-express-middleware",
3
- "version": "6.3.2",
3
+ "version": "6.3.3",
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",
@@ -10,6 +10,7 @@
10
10
  "realm": "express-middleware-test",
11
11
  "clientId": "express-middleware-test-client",
12
12
  "clientSecret": "test-client-secret",
13
- "testUser": "test-user" }
13
+ "testUser": "test-user"
14
+ }
14
15
  }
15
- }
16
+ }
@@ -521,11 +521,11 @@ async function main() {
521
521
 
522
522
  log('\n✓ Setup complete!\n', 'green');
523
523
 
524
- // === CLIENT SECRET RETRIEVAL INSTRUCTION ===
525
- log('4. Copy the Secret value and update test/config/secrets.json and test/config/default.json', 'yellow');
526
- log(' ("clientSecret" field under the "test" keycloak block)', 'yellow');
527
- log('5. Re-run npm test', 'yellow');
528
- log('\nIf you want to automate this, add a script to fetch the secret via Keycloak Admin API after deployment.\n', 'yellow');
524
+ // Test config bootstrap already creates local config files and test setup recreates realm/client.
525
+ log('4. Run npm test to bootstrap test config and sync realm/client/user automatically.', 'yellow');
526
+ log(' (test/helpers/ensure-test-config.js + test/helpers/keycloak-setup.js)', 'yellow');
527
+ log('5. Only update test/config/secrets.json manually if you intentionally changed credentials.', 'yellow');
528
+ log(' (adminPassword, clientSecret, testPassword)', 'yellow');
529
529
 
530
530
  } catch (err) {
531
531
  log(`\nSetup failed: ${err.message}\n`, 'red');
@@ -80,5 +80,72 @@ describe('Keycloak Integration - All Methods', function() {
80
80
  );
81
81
  });
82
82
 
83
+
84
+ it('should obtain a service token via client_credentials', async function() {
85
+ const result = await keycloak.getServiceToken({ scope: 'openid' });
86
+ assert(result.accessToken, 'should get access_token via client_credentials');
87
+ assert.strictEqual(result.source, 'fresh');
88
+ assert.strictEqual(typeof result.expiresIn, 'number');
89
+ assert(result.expiresIn > 0, 'expiresIn should be positive');
90
+ });
91
+
92
+ it('should return cached service token on second call', async function() {
93
+ // First call already made in previous test — same scope key hits cache
94
+ const a = await keycloak.getServiceToken({ scope: 'openid' });
95
+ const b = await keycloak.getServiceToken({ scope: 'openid' });
96
+ assert.strictEqual(b.source, 'cache');
97
+ assert.strictEqual(a.accessToken, b.accessToken);
98
+ });
99
+
100
+ it('should call Keycloak userinfo endpoint via callProtectedApi', async function() {
101
+ // Use a deterministic public endpoint and send user token in Authorization header.
102
+ const openidConfigUrl = `${config.baseUrl.replace(/\/$/, '')}/realms/${config.realm}/.well-known/openid-configuration`;
103
+
104
+ const result = await keycloak.callProtectedApi({
105
+ url: openidConfigUrl,
106
+ authMode: 'user',
107
+ userToken: tokens.access_token
108
+ });
109
+
110
+ assert.strictEqual(result.ok, true, `OpenID configuration call failed: ${JSON.stringify(result.data)}`);
111
+ assert(result.data.issuer, 'response should contain issuer');
112
+ assert.strictEqual(result.auth.mode, 'user');
113
+ assert.strictEqual(result.auth.retriedWithFreshToken, false);
114
+ });
115
+
116
+ it('should return 401 data with invalid token via callProtectedApi (none mode)', async function() {
117
+ const userInfoUrl = `${config.baseUrl.replace(/\/$/, '')}/realms/${config.realm}/protocol/openid-connect/userinfo`;
118
+
119
+ const result = await keycloak.callProtectedApi({
120
+ url: userInfoUrl,
121
+ authMode: 'none' // no auth header → 401
122
+ });
123
+
124
+ assert.strictEqual(result.ok, false);
125
+ assert.strictEqual(result.status, 401);
126
+ });
127
+
128
+ it('should decode token claims with getTokenClaims after real login', function() {
129
+ // tokens.access_token is a JWT, decode payload and place it where getTokenClaims expects it.
130
+ const jwtPayloadPart = String(tokens.access_token || '').split('.')[1] || '';
131
+ const jsonPayload = Buffer.from(
132
+ jwtPayloadPart.replace(/-/g, '+').replace(/_/g, '/'),
133
+ 'base64'
134
+ ).toString('utf8');
135
+ const decodedPayload = JSON.parse(jsonPayload);
136
+
137
+ const mockReq = {
138
+ kauth: {
139
+ grant: {
140
+ access_token: {
141
+ content: decodedPayload
142
+ }
143
+ }
144
+ }
145
+ };
146
+ const claims = keycloak.getTokenClaims(mockReq);
147
+ assert(claims.sub, 'should have sub claim');
148
+ });
149
+
83
150
  // Add more tests for middleware and utility methods as needed
84
151
  });
@@ -749,4 +749,333 @@ describe('Middleware and Imperative Methods', function() {
749
749
  }
750
750
  });
751
751
  });
752
+
753
+ // ── getServiceToken edge cases ──────────────────────────────────────────
754
+
755
+ it('getServiceToken forces fresh fetch when token is within minValiditySeconds', async function() {
756
+ const adapter = buildAdapter();
757
+ let calls = 0;
758
+
759
+ adapter.loginWithCredentials = async () => {
760
+ calls += 1;
761
+ return {
762
+ access_token: `svc-token-${calls}`,
763
+ token_type: 'Bearer',
764
+ expires_in: 10, // expires in 10 s
765
+ scope: 'openid'
766
+ };
767
+ };
768
+
769
+ // First fetch — stores token with ~10 s TTL
770
+ await adapter.getServiceToken({ scope: 'openid' });
771
+
772
+ // Request with minValiditySeconds=20 → cached token expires too soon → must refresh
773
+ const result = await adapter.getServiceToken({ scope: 'openid', minValiditySeconds: 20 });
774
+
775
+ assert.strictEqual(result.source, 'fresh');
776
+ assert.strictEqual(calls, 2, 'should refetch when token expires within minValiditySeconds');
777
+ });
778
+
779
+ it('getServiceToken uses custom cacheKey independently from default key', async function() {
780
+ const adapter = buildAdapter();
781
+ let calls = 0;
782
+
783
+ adapter.loginWithCredentials = async () => {
784
+ calls += 1;
785
+ return { access_token: `token-${calls}`, token_type: 'Bearer', expires_in: 300 };
786
+ };
787
+
788
+ const a = await adapter.getServiceToken({ scope: 'openid', cacheKey: 'my-key' });
789
+ const b = await adapter.getServiceToken({ scope: 'openid', cacheKey: 'my-key' }); // same key → cache
790
+ const c = await adapter.getServiceToken({ scope: 'openid' }); // default key → fresh
791
+
792
+ assert.strictEqual(a.source, 'fresh');
793
+ assert.strictEqual(b.source, 'cache');
794
+ assert.strictEqual(c.source, 'fresh');
795
+ assert.strictEqual(calls, 2);
796
+ });
797
+
798
+ it('getServiceToken joins scope array into space-separated string', async function() {
799
+ const adapter = buildAdapter();
800
+ let capturedScope;
801
+
802
+ adapter.loginWithCredentials = async (creds) => {
803
+ capturedScope = creds.scope;
804
+ return { access_token: 'tok', token_type: 'Bearer', expires_in: 300, scope: creds.scope };
805
+ };
806
+
807
+ await adapter.getServiceToken({ scope: ['openid', 'profile', 'email'] });
808
+ assert.strictEqual(capturedScope, 'openid profile email');
809
+ });
810
+
811
+ it('getServiceToken isolates cache entries per scope', async function() {
812
+ const adapter = buildAdapter();
813
+ let calls = 0;
814
+
815
+ adapter.loginWithCredentials = async (creds) => {
816
+ calls += 1;
817
+ return { access_token: `tok-${creds.scope}`, token_type: 'Bearer', expires_in: 300, scope: creds.scope };
818
+ };
819
+
820
+ const a = await adapter.getServiceToken({ scope: 'openid' });
821
+ const b = await adapter.getServiceToken({ scope: 'openid profile' });
822
+ const c = await adapter.getServiceToken({ scope: 'openid' }); // cache hit
823
+
824
+ assert.strictEqual(a.accessToken, 'tok-openid');
825
+ assert.strictEqual(b.accessToken, 'tok-openid profile');
826
+ assert.strictEqual(c.source, 'cache');
827
+ assert.strictEqual(calls, 2);
828
+ });
829
+
830
+ it('getServiceToken throws when token endpoint returns no access_token', async function() {
831
+ const adapter = buildAdapter();
832
+
833
+ adapter.loginWithCredentials = async () => ({ token_type: 'Bearer', expires_in: 300 });
834
+
835
+ await assert.rejects(
836
+ () => adapter.getServiceToken({ scope: 'openid' }),
837
+ /access_token/
838
+ );
839
+ });
840
+
841
+ // ── callProtectedApi edge cases ─────────────────────────────────────────
842
+
843
+ it('callProtectedApi throws when url is missing', async function() {
844
+ const adapter = buildAdapter();
845
+
846
+ await assert.rejects(
847
+ () => adapter.callProtectedApi({ authMode: 'none' }),
848
+ /url/i
849
+ );
850
+ });
851
+
852
+ it('callProtectedApi passthrough mode preserves existing Authorization header', async function() {
853
+ const adapter = buildAdapter();
854
+ const originalFetch = global.fetch;
855
+ let capturedAuth;
856
+
857
+ global.fetch = async (_url, options) => {
858
+ capturedAuth = options.headers.Authorization;
859
+ return {
860
+ ok: true, status: 200, statusText: 'OK',
861
+ headers: { forEach: () => {}, get: () => null },
862
+ text: async () => ''
863
+ };
864
+ };
865
+
866
+ try {
867
+ await adapter.callProtectedApi({
868
+ url: 'https://api.example.com/data',
869
+ authMode: 'passthrough',
870
+ headers: { Authorization: 'Bearer my-existing-token' }
871
+ });
872
+ assert.strictEqual(capturedAuth, 'Bearer my-existing-token');
873
+ } finally {
874
+ global.fetch = originalFetch;
875
+ }
876
+ });
877
+
878
+ it('callProtectedApi none mode sends no Authorization header', async function() {
879
+ const adapter = buildAdapter();
880
+ const originalFetch = global.fetch;
881
+ let capturedHeaders;
882
+
883
+ global.fetch = async (_url, options) => {
884
+ capturedHeaders = options.headers;
885
+ return {
886
+ ok: true, status: 200, statusText: 'OK',
887
+ headers: { forEach: () => {}, get: () => null },
888
+ text: async () => ''
889
+ };
890
+ };
891
+
892
+ try {
893
+ await adapter.callProtectedApi({ url: 'https://api.example.com/data', authMode: 'none' });
894
+ assert.strictEqual(capturedHeaders.Authorization, undefined);
895
+ } finally {
896
+ global.fetch = originalFetch;
897
+ }
898
+ });
899
+
900
+ it('callProtectedApi throws when authMode=user without userToken', async function() {
901
+ const adapter = buildAdapter();
902
+
903
+ await assert.rejects(
904
+ () => adapter.callProtectedApi({ url: 'https://api.example.com/data', authMode: 'user' }),
905
+ /userToken/i
906
+ );
907
+ });
908
+
909
+ it('callProtectedApi throws on unsupported authMode', async function() {
910
+ const adapter = buildAdapter();
911
+
912
+ await assert.rejects(
913
+ () => adapter.callProtectedApi({ url: 'https://api.example.com/data', authMode: 'magic' }),
914
+ /unsupported authMode/i
915
+ );
916
+ });
917
+
918
+ it('callProtectedApi serializes json body and sets content-type', async function() {
919
+ const adapter = buildAdapter();
920
+ const originalFetch = global.fetch;
921
+ let capturedBody, capturedContentType;
922
+
923
+ global.fetch = async (_url, options) => {
924
+ capturedBody = options.body;
925
+ capturedContentType = options.headers['content-type'];
926
+ return {
927
+ ok: true, status: 200, statusText: 'OK',
928
+ headers: { forEach: () => {}, get: () => 'application/json' },
929
+ text: async () => JSON.stringify({ ok: true })
930
+ };
931
+ };
932
+
933
+ try {
934
+ await adapter.callProtectedApi({
935
+ url: 'https://api.example.com/data',
936
+ method: 'POST',
937
+ authMode: 'none',
938
+ json: { foo: 'bar', num: 42 }
939
+ });
940
+ assert.strictEqual(capturedBody, JSON.stringify({ foo: 'bar', num: 42 }));
941
+ assert.strictEqual(capturedContentType, 'application/json');
942
+ } finally {
943
+ global.fetch = originalFetch;
944
+ }
945
+ });
946
+
947
+ it('callProtectedApi skips retry when retryOnAuthError=false', async function() {
948
+ const adapter = buildAdapter();
949
+ const originalFetch = global.fetch;
950
+ let fetchCalls = 0;
951
+ let getTokenCalls = 0;
952
+
953
+ adapter.getServiceToken = async () => {
954
+ getTokenCalls += 1;
955
+ return { accessToken: 'tok', tokenType: 'Bearer', source: 'cache' };
956
+ };
957
+
958
+ global.fetch = async () => {
959
+ fetchCalls += 1;
960
+ return {
961
+ ok: false, status: 401, statusText: 'Unauthorized',
962
+ headers: { forEach: () => {}, get: () => null },
963
+ text: async () => 'Unauthorized'
964
+ };
965
+ };
966
+
967
+ try {
968
+ const result = await adapter.callProtectedApi({
969
+ url: 'https://api.example.com/data',
970
+ authMode: 'service',
971
+ retryOnAuthError: false
972
+ });
973
+ assert.strictEqual(result.status, 401);
974
+ assert.strictEqual(fetchCalls, 1, 'should not retry');
975
+ assert.strictEqual(result.auth.retriedWithFreshToken, false);
976
+ } finally {
977
+ global.fetch = originalFetch;
978
+ }
979
+ });
980
+
981
+ it('callProtectedApi aborts request on timeout', async function() {
982
+ const adapter = buildAdapter();
983
+ const originalFetch = global.fetch;
984
+
985
+ global.fetch = async (_url, options) => {
986
+ return new Promise((_resolve, reject) => {
987
+ if (options.signal) {
988
+ options.signal.addEventListener('abort', () => {
989
+ const err = new Error('The operation was aborted');
990
+ err.name = 'AbortError';
991
+ reject(err);
992
+ });
993
+ }
994
+ // Never resolve — waits for abort
995
+ });
996
+ };
997
+
998
+ try {
999
+ await assert.rejects(
1000
+ () => adapter.callProtectedApi({
1001
+ url: 'https://api.example.com/slow',
1002
+ authMode: 'none',
1003
+ timeoutMs: 30
1004
+ }),
1005
+ (err) => err.name === 'AbortError' || /aborted/i.test(err.message)
1006
+ );
1007
+ } finally {
1008
+ global.fetch = originalFetch;
1009
+ }
1010
+ });
1011
+
1012
+ it('callProtectedApi returns text data for non-JSON response', async function() {
1013
+ const adapter = buildAdapter();
1014
+ const originalFetch = global.fetch;
1015
+
1016
+ global.fetch = async () => ({
1017
+ ok: true, status: 200, statusText: 'OK',
1018
+ headers: { forEach: () => {}, get: () => 'text/plain' },
1019
+ text: async () => 'plain text response'
1020
+ });
1021
+
1022
+ try {
1023
+ const result = await adapter.callProtectedApi({
1024
+ url: 'https://api.example.com/text',
1025
+ authMode: 'none'
1026
+ });
1027
+ assert.strictEqual(result.data, 'plain text response');
1028
+ } finally {
1029
+ global.fetch = originalFetch;
1030
+ }
1031
+ });
1032
+
1033
+ it('callProtectedApi returns null data for empty body', async function() {
1034
+ const adapter = buildAdapter();
1035
+ const originalFetch = global.fetch;
1036
+
1037
+ global.fetch = async () => ({
1038
+ ok: true, status: 204, statusText: 'No Content',
1039
+ headers: { forEach: () => {}, get: () => null },
1040
+ text: async () => ''
1041
+ });
1042
+
1043
+ try {
1044
+ const result = await adapter.callProtectedApi({
1045
+ url: 'https://api.example.com/delete',
1046
+ method: 'DELETE',
1047
+ authMode: 'none'
1048
+ });
1049
+ assert.strictEqual(result.status, 204);
1050
+ assert.strictEqual(result.data, null);
1051
+ } finally {
1052
+ global.fetch = originalFetch;
1053
+ }
1054
+ });
1055
+
1056
+ it('callProtectedApi auth.retriedWithFreshToken is false on first-attempt success', async function() {
1057
+ const adapter = buildAdapter();
1058
+ const originalFetch = global.fetch;
1059
+
1060
+ adapter.getServiceToken = async () => ({
1061
+ accessToken: 'tok', tokenType: 'Bearer', source: 'cache'
1062
+ });
1063
+
1064
+ global.fetch = async () => ({
1065
+ ok: true, status: 200, statusText: 'OK',
1066
+ headers: { forEach: () => {}, get: () => 'application/json' },
1067
+ text: async () => JSON.stringify({ data: 1 })
1068
+ });
1069
+
1070
+ try {
1071
+ const result = await adapter.callProtectedApi({
1072
+ url: 'https://api.example.com/data',
1073
+ authMode: 'service'
1074
+ });
1075
+ assert.strictEqual(result.auth.retriedWithFreshToken, false);
1076
+ assert.strictEqual(result.auth.mode, 'service');
1077
+ } finally {
1078
+ global.fetch = originalFetch;
1079
+ }
1080
+ });
752
1081
  });
@@ -313,6 +313,24 @@ describe('OIDC Methods', function() {
313
313
  global.fetch = originalFetch;
314
314
  }
315
315
  });
316
+
317
+ it('should throw on non-JSON error body from token endpoint', async function() {
318
+ const originalFetch = global.fetch;
319
+
320
+ global.fetch = async () => ({
321
+ ok: false,
322
+ text: async () => 'Service Unavailable'
323
+ });
324
+
325
+ try {
326
+ await adapter.loginWithCredentials({ grant_type: 'client_credentials' });
327
+ assert.fail('should have thrown');
328
+ } catch (error) {
329
+ assert(error.message.length > 0, 'should throw a non-empty error');
330
+ } finally {
331
+ global.fetch = originalFetch;
332
+ }
333
+ });
316
334
  });
317
335
 
318
336
  describe('loginPKCE()', function() {
@@ -410,5 +428,33 @@ describe('OIDC Methods', function() {
410
428
  global.fetch = originalFetch;
411
429
  }
412
430
  });
431
+
432
+ it('should throw on failed HTTP response from token endpoint', async function() {
433
+ const originalFetch = global.fetch;
434
+
435
+ global.fetch = async () => ({
436
+ ok: false,
437
+ text: async () => JSON.stringify({
438
+ error: 'invalid_grant',
439
+ error_description: 'Code not valid'
440
+ })
441
+ });
442
+
443
+ try {
444
+ await adapter.loginPKCE({
445
+ code: 'expired-code',
446
+ redirect_uri: 'https://app.example.com/callback',
447
+ code_verifier: 'verifier-123'
448
+ });
449
+ assert.fail('should have thrown');
450
+ } catch (error) {
451
+ assert(
452
+ error.message.includes('invalid_grant') || error.message.includes('Code not valid') || error.message.includes('failed'),
453
+ `unexpected error: ${error.message}`
454
+ );
455
+ } finally {
456
+ global.fetch = originalFetch;
457
+ }
458
+ });
413
459
  });
414
460
  });
@@ -23,7 +23,7 @@
23
23
  }
24
24
  },
25
25
  "..": {
26
- "version": "6.1.3",
26
+ "version": "6.3.2",
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "express-session": "^1.19.0",