keycloak-express-middleware 6.3.2 → 6.3.4
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 +23 -0
- package/LICENSE +1 -1
- package/package.json +1 -1
- package/test/config/default.json +3 -2
- package/test/docker-keycloak/setup-keycloak.js +5 -5
- package/test/keycloak-integration.test.js +67 -0
- package/test/middleware-functions.test.js +329 -0
- package/test/oidc-methods.test.js +46 -0
- package/test/package-lock.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [6.3.4] - 2026-03-18
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Updated project license copyright line to:
|
|
9
|
+
- `Copyright (c) 2025 CRS4, aromanino, gporruvecchio`
|
|
10
|
+
- Aligned test workspace lockfile metadata with current package version.
|
|
11
|
+
|
|
12
|
+
## [6.3.3] - 2026-03-18
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Expanded test coverage for outbound helper APIs:
|
|
16
|
+
- `getServiceToken()` edge cases (`minValiditySeconds`, custom `cacheKey`, scope array normalization, per-scope cache isolation, missing `access_token` handling)
|
|
17
|
+
- `callProtectedApi()` edge cases (`passthrough`/`none` modes, invalid inputs, JSON body serialization, timeout abort, non-JSON and empty response handling, retry toggle behavior)
|
|
18
|
+
- Added integration coverage for:
|
|
19
|
+
- service token retrieval and cache behavior against configured Keycloak test realm
|
|
20
|
+
- outbound helper call behavior in user mode and none mode
|
|
21
|
+
- token-claim decoding flow in `getTokenClaims`
|
|
22
|
+
- Added OIDC negative-path tests for non-JSON token-endpoint errors and failed PKCE token exchange responses.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- 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).
|
|
26
|
+
- Normalized formatting of `test/config/default.json`.
|
|
27
|
+
|
|
5
28
|
## [6.3.2] - 2026-03-18
|
|
6
29
|
|
|
7
30
|
### Added
|
package/LICENSE
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keycloak-express-middleware",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.4",
|
|
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",
|
package/test/config/default.json
CHANGED
|
@@ -521,11 +521,11 @@ async function main() {
|
|
|
521
521
|
|
|
522
522
|
log('\n✓ Setup complete!\n', 'green');
|
|
523
523
|
|
|
524
|
-
//
|
|
525
|
-
log('4.
|
|
526
|
-
log(' (
|
|
527
|
-
log('5.
|
|
528
|
-
log('
|
|
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
|
});
|