@ondewo/sip-client-nodejs 5.2.0 → 5.4.0
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/LICENSE +0 -0
- package/README.md +3 -0
- package/api/ondewo/sip/sip_grpc_pb.js +15 -16
- package/auth/offlineTokenProvider.d.ts +158 -0
- package/auth/offlineTokenProvider.js +320 -0
- package/auth/offlineTokenProvider.spec.ts +595 -0
- package/auth/offlineTokenProvider.ts +433 -0
- package/package.json +7 -4
- package/public-api.js +0 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// Copyright 2021-2026 ONDEWO GmbH
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
|
|
16
|
+
// Unit tests for the D18 offline-token helper. The token endpoint is mocked via the injectable
|
|
17
|
+
// `fetchImpl` option -- there is NO network access.
|
|
18
|
+
// node --test --experimental-strip-types auth/offlineTokenProvider.spec.ts
|
|
19
|
+
|
|
20
|
+
import { test as runTestCase, mock } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
login,
|
|
25
|
+
OfflineTokenProvider,
|
|
26
|
+
TokenError,
|
|
27
|
+
type OfflineTokenLoginOptions,
|
|
28
|
+
type TokenFetch,
|
|
29
|
+
type TokenFetchInit,
|
|
30
|
+
type TokenFetchResponse
|
|
31
|
+
} from './offlineTokenProvider';
|
|
32
|
+
|
|
33
|
+
/** Shared, valid login options reused by every test case (spread + overridden per case). */
|
|
34
|
+
const BASE_OPTIONS: OfflineTokenLoginOptions = {
|
|
35
|
+
keycloakUrl: 'https://auth.example.com/auth',
|
|
36
|
+
realm: 'ondewo-ccai-platform',
|
|
37
|
+
clientId: 'ondewo-sip-cai-sdk-public',
|
|
38
|
+
username: 'tech-user@example.com',
|
|
39
|
+
password: 'super-secret'
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The token endpoint URL {@link BASE_OPTIONS} is expected to resolve to. */
|
|
43
|
+
const EXPECTED_TOKEN_ENDPOINT: string =
|
|
44
|
+
'https://auth.example.com/auth/realms/ondewo-ccai-platform/protocol/openid-connect/token';
|
|
45
|
+
|
|
46
|
+
/** One scripted response for the fetch stub: an optional HTTP status plus the body (object or raw string). */
|
|
47
|
+
interface StubResponse {
|
|
48
|
+
/** The HTTP status code to return; defaults to `200` when omitted. */
|
|
49
|
+
status?: number;
|
|
50
|
+
/** The response body; objects are JSON-stringified, strings are returned verbatim. */
|
|
51
|
+
body: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A single fetch invocation captured by the stub for later assertions. */
|
|
55
|
+
interface RecordedCall {
|
|
56
|
+
/** The URL the stub was called with. */
|
|
57
|
+
url: string;
|
|
58
|
+
/** The request init (method, headers, body) the stub received. */
|
|
59
|
+
init: TokenFetchInit;
|
|
60
|
+
/** The parsed form parameters of the request body, for convenient assertions. */
|
|
61
|
+
params: URLSearchParams;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The injectable fetch plus the list it records its calls into. */
|
|
65
|
+
interface FetchStub {
|
|
66
|
+
/** The {@link TokenFetch} implementation to inject via the `fetchImpl` option. */
|
|
67
|
+
fetchImpl: TokenFetch;
|
|
68
|
+
/** The recorded calls, in invocation order. */
|
|
69
|
+
calls: RecordedCall[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a fake fetch that returns a sequence of JSON responses (one per call) and records the
|
|
74
|
+
* requests it received, so assertions can inspect the form-encoded body and the URL.
|
|
75
|
+
*
|
|
76
|
+
* @param responses - The scripted responses to return, one per call, in order.
|
|
77
|
+
* @returns A {@link FetchStub} exposing the injectable `fetchImpl` and its recorded `calls`.
|
|
78
|
+
* @throws {Error} If the stub is called more times than there are scripted responses.
|
|
79
|
+
*/
|
|
80
|
+
function makeFetchStub(responses: StubResponse[]): FetchStub {
|
|
81
|
+
const calls: RecordedCall[] = [];
|
|
82
|
+
const fetchImpl: TokenFetch = (url: string, init: TokenFetchInit): Promise<TokenFetchResponse> => {
|
|
83
|
+
calls.push({ url, init, params: new URLSearchParams(init.body) });
|
|
84
|
+
const next: StubResponse | undefined = responses.shift();
|
|
85
|
+
if (next === undefined) {
|
|
86
|
+
throw new Error('fetch stub called more times than expected');
|
|
87
|
+
}
|
|
88
|
+
const status: number = next.status !== undefined ? next.status : 200;
|
|
89
|
+
const bodyText: string = typeof next.body === 'string' ? next.body : JSON.stringify(next.body);
|
|
90
|
+
return Promise.resolve({
|
|
91
|
+
ok: status >= 200 && status < 300,
|
|
92
|
+
status,
|
|
93
|
+
text: (): Promise<string> => Promise.resolve(bodyText)
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
return { fetchImpl, calls };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Yield to the microtask queue so an awaited refresh inside a fired timer can settle.
|
|
101
|
+
*
|
|
102
|
+
* @returns A promise that resolves on the next tick of the event loop.
|
|
103
|
+
*/
|
|
104
|
+
function flushMicrotasks(): Promise<void> {
|
|
105
|
+
return new Promise((resolve: () => void): void => {
|
|
106
|
+
process.nextTick(resolve);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Verifies the one-time ROPC login posts `grant_type=password` + `scope=offline_access` to the realm
|
|
112
|
+
* token endpoint using the public client id and, per Q1, sends no `client_secret`.
|
|
113
|
+
*/
|
|
114
|
+
runTestCase(
|
|
115
|
+
'login posts ROPC + offline_access to the realm token endpoint with the public client (no secret)',
|
|
116
|
+
async () => {
|
|
117
|
+
const stub: FetchStub = makeFetchStub([
|
|
118
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1', expires_in: 300 } }
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl });
|
|
122
|
+
|
|
123
|
+
assert.equal(stub.calls.length, 1);
|
|
124
|
+
assert.equal(stub.calls[0].url, EXPECTED_TOKEN_ENDPOINT);
|
|
125
|
+
assert.equal(stub.calls[0].init.method, 'POST');
|
|
126
|
+
assert.equal(stub.calls[0].init.headers['Content-Type'], 'application/x-www-form-urlencoded');
|
|
127
|
+
|
|
128
|
+
const params: URLSearchParams = stub.calls[0].params;
|
|
129
|
+
assert.equal(params.get('grant_type'), 'password');
|
|
130
|
+
assert.equal(params.get('client_id'), 'ondewo-sip-cai-sdk-public');
|
|
131
|
+
assert.equal(params.get('username'), 'tech-user@example.com');
|
|
132
|
+
assert.equal(params.get('password'), 'super-secret');
|
|
133
|
+
assert.equal(params.get('scope'), 'offline_access');
|
|
134
|
+
// Q1: PUBLIC client -- there must be NO client_secret on the wire.
|
|
135
|
+
assert.equal(params.get('client_secret'), null);
|
|
136
|
+
|
|
137
|
+
assert.equal(provider.getAccessToken(), 'access-1');
|
|
138
|
+
assert.equal(provider.getAuthorizationHeader(), 'Bearer access-1');
|
|
139
|
+
provider.stop();
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Verifies the background loop fires before expiry and exchanges the offline `refresh_token` (via
|
|
145
|
+
* `grant_type=refresh_token`, still secret-less) for a fresh access token that replaces the old one.
|
|
146
|
+
*/
|
|
147
|
+
runTestCase('auto-refresh exchanges the offline refresh_token for a fresh access token before expiry', async () => {
|
|
148
|
+
const stub: FetchStub = makeFetchStub([
|
|
149
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1', expires_in: 31 } },
|
|
150
|
+
{ body: { access_token: 'access-2', refresh_token: 'offline-2', expires_in: 31 } }
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
// expires_in 31 - 30 skew = 1s scheduled delay; drive it deterministically via fake timers.
|
|
154
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
155
|
+
try {
|
|
156
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl });
|
|
157
|
+
assert.equal(provider.getAccessToken(), 'access-1');
|
|
158
|
+
|
|
159
|
+
mock.timers.tick(1000);
|
|
160
|
+
await flushMicrotasks();
|
|
161
|
+
|
|
162
|
+
assert.equal(stub.calls.length, 2);
|
|
163
|
+
const refreshParams: URLSearchParams = stub.calls[1].params;
|
|
164
|
+
assert.equal(refreshParams.get('grant_type'), 'refresh_token');
|
|
165
|
+
assert.equal(refreshParams.get('refresh_token'), 'offline-1');
|
|
166
|
+
assert.equal(refreshParams.get('client_id'), 'ondewo-sip-cai-sdk-public');
|
|
167
|
+
assert.equal(refreshParams.get('client_secret'), null);
|
|
168
|
+
|
|
169
|
+
assert.equal(provider.getAccessToken(), 'access-2');
|
|
170
|
+
assert.equal(provider.getAuthorizationHeader(), 'Bearer access-2');
|
|
171
|
+
provider.stop();
|
|
172
|
+
} finally {
|
|
173
|
+
mock.timers.reset();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Verifies that once the bounded `tokenExpirationInS` deadline has passed, a fired timer performs no
|
|
179
|
+
* further token-endpoint call (the loop self-terminates and the access token is left to lapse).
|
|
180
|
+
*/
|
|
181
|
+
runTestCase('the refresh loop stops after tokenExpirationInS elapses (no further renewal)', async () => {
|
|
182
|
+
const stub: FetchStub = makeFetchStub([
|
|
183
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1', expires_in: 31 } }
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
let fakeNowInMs: number = 1_000_000;
|
|
187
|
+
const nowInMs: () => number = (): number => fakeNowInMs;
|
|
188
|
+
|
|
189
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
190
|
+
try {
|
|
191
|
+
// Bound the loop to 2s; the first refresh is armed at ~1s but the deadline passes before it fires.
|
|
192
|
+
const provider: OfflineTokenProvider = await login({
|
|
193
|
+
...BASE_OPTIONS,
|
|
194
|
+
fetchImpl: stub.fetchImpl,
|
|
195
|
+
nowInMs,
|
|
196
|
+
tokenExpirationInS: 2
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Advance the wall clock past the deadline before the timer fires.
|
|
200
|
+
fakeNowInMs += 3000;
|
|
201
|
+
mock.timers.tick(1000);
|
|
202
|
+
await flushMicrotasks();
|
|
203
|
+
|
|
204
|
+
// Deadline already passed -> refresh must NOT have fired; only the initial login call happened.
|
|
205
|
+
assert.equal(stub.calls.length, 1);
|
|
206
|
+
provider.stop();
|
|
207
|
+
} finally {
|
|
208
|
+
mock.timers.reset();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
/** Verifies a non-2xx token-endpoint response (HTTP 401) is surfaced as a {@link TokenError}. */
|
|
213
|
+
runTestCase('login rejects a non-2xx token response with TokenError', async () => {
|
|
214
|
+
const stub: FetchStub = makeFetchStub([{ status: 401, body: { error: 'invalid_grant' } }]);
|
|
215
|
+
await assert.rejects(() => login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl }), TokenError);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Verifies that a successful token response lacking a `refresh_token` (i.e. the client was not granted
|
|
220
|
+
* `offline_access`) is rejected with a {@link TokenError} rather than yielding a non-refreshable provider.
|
|
221
|
+
*/
|
|
222
|
+
runTestCase('login rejects when the token response carries no refresh_token (missing offline_access)', async () => {
|
|
223
|
+
const stub: FetchStub = makeFetchStub([{ body: { access_token: 'access-1', expires_in: 300 } }]);
|
|
224
|
+
await assert.rejects(() => login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl }), TokenError);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/** Verifies that an empty required option (here `clientId`) is rejected with a {@link TokenError} before any fetch. */
|
|
228
|
+
runTestCase('login validates required options', async () => {
|
|
229
|
+
const stub: FetchStub = makeFetchStub([]);
|
|
230
|
+
await assert.rejects(() => login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl, clientId: '' }), TokenError);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Verifies that {@link OfflineTokenProvider.getAuthorizationHeader} throws a {@link TokenError} before
|
|
235
|
+
* bootstrap (no token yet) while {@link OfflineTokenProvider.getAccessToken} returns `null`.
|
|
236
|
+
*/
|
|
237
|
+
runTestCase('getAuthorizationHeader throws before bootstrap when no token is available', () => {
|
|
238
|
+
const stub: FetchStub = makeFetchStub([]);
|
|
239
|
+
const provider: OfflineTokenProvider = new OfflineTokenProvider({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl });
|
|
240
|
+
assert.throws(() => provider.getAuthorizationHeader(), TokenError);
|
|
241
|
+
assert.equal(provider.getAccessToken(), null);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/** Verifies a 2xx response with a non-JSON body is rejected with a {@link TokenError}. */
|
|
245
|
+
runTestCase('login rejects a 2xx token response whose body is not valid JSON', async () => {
|
|
246
|
+
const stub: FetchStub = makeFetchStub([{ body: '<<<not-json>>>' }]);
|
|
247
|
+
await assert.rejects(() => login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl }), TokenError);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/** Verifies a valid-JSON response missing the `access_token` field is rejected with a {@link TokenError}. */
|
|
251
|
+
runTestCase('login rejects a parseable token response that carries no access_token', async () => {
|
|
252
|
+
const stub: FetchStub = makeFetchStub([{ body: { refresh_token: 'offline-1', expires_in: 300 } }]);
|
|
253
|
+
await assert.rejects(() => login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl }), TokenError);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
/** Verifies that passing `null` or `undefined` as the options object is rejected with a {@link TokenError}. */
|
|
257
|
+
runTestCase('login rejects a missing options object', async () => {
|
|
258
|
+
await assert.rejects(() => login(null as unknown as OfflineTokenLoginOptions), TokenError);
|
|
259
|
+
await assert.rejects(() => login(undefined as unknown as OfflineTokenLoginOptions), TokenError);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Verifies that a failed background refresh (HTTP 500) is delivered to the registered
|
|
264
|
+
* {@link OfflineTokenProvider.onRefreshError} handler and does not clobber the still-valid access token.
|
|
265
|
+
*/
|
|
266
|
+
runTestCase('a failed background refresh is surfaced to onRefreshError and keeps the stale token', async () => {
|
|
267
|
+
const stub: FetchStub = makeFetchStub([
|
|
268
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1', expires_in: 31 } },
|
|
269
|
+
{ status: 500, body: 'boom' }
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
273
|
+
try {
|
|
274
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl });
|
|
275
|
+
let captured: unknown = null;
|
|
276
|
+
provider.onRefreshError((error: unknown): void => {
|
|
277
|
+
captured = error;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
mock.timers.tick(1000);
|
|
281
|
+
await flushMicrotasks();
|
|
282
|
+
await flushMicrotasks();
|
|
283
|
+
|
|
284
|
+
assert.ok(captured instanceof TokenError);
|
|
285
|
+
assert.match(captured.message, /500/);
|
|
286
|
+
// The transient failure must NOT clobber the still-valid access token.
|
|
287
|
+
assert.equal(provider.getAccessToken(), 'access-1');
|
|
288
|
+
provider.stop();
|
|
289
|
+
} finally {
|
|
290
|
+
mock.timers.reset();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Verifies that a failed background refresh (HTTP 503) with no registered error handler is swallowed
|
|
296
|
+
* silently: nothing throws and the stale-but-valid access token survives.
|
|
297
|
+
*/
|
|
298
|
+
runTestCase('a failed background refresh without a registered handler is swallowed silently', async () => {
|
|
299
|
+
const stub: FetchStub = makeFetchStub([
|
|
300
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1', expires_in: 31 } },
|
|
301
|
+
{ status: 503, body: 'down' }
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
305
|
+
try {
|
|
306
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl });
|
|
307
|
+
|
|
308
|
+
mock.timers.tick(1000);
|
|
309
|
+
await flushMicrotasks();
|
|
310
|
+
await flushMicrotasks();
|
|
311
|
+
|
|
312
|
+
// No handler -> the rejection is swallowed; the stale token survives and nothing throws.
|
|
313
|
+
assert.equal(provider.getAccessToken(), 'access-1');
|
|
314
|
+
provider.stop();
|
|
315
|
+
} finally {
|
|
316
|
+
mock.timers.reset();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Verifies refresh-token rotation handling: when a refresh response omits a new `refresh_token`, the
|
|
322
|
+
* provider keeps reusing the previously held offline token on the subsequent refresh.
|
|
323
|
+
*/
|
|
324
|
+
runTestCase('a refresh response without a rotated refresh_token keeps reusing the previous one', async () => {
|
|
325
|
+
const stub: FetchStub = makeFetchStub([
|
|
326
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1', expires_in: 31 } },
|
|
327
|
+
// First refresh succeeds but Keycloak does NOT rotate the offline token.
|
|
328
|
+
{ body: { access_token: 'access-2', expires_in: 31 } },
|
|
329
|
+
{ body: { access_token: 'access-3', refresh_token: 'offline-3', expires_in: 31 } }
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
333
|
+
try {
|
|
334
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl });
|
|
335
|
+
|
|
336
|
+
mock.timers.tick(1000);
|
|
337
|
+
await flushMicrotasks();
|
|
338
|
+
assert.equal(provider.getAccessToken(), 'access-2');
|
|
339
|
+
|
|
340
|
+
mock.timers.tick(1000);
|
|
341
|
+
await flushMicrotasks();
|
|
342
|
+
// The second refresh must still send the original offline-1 token (it was never rotated).
|
|
343
|
+
assert.equal(stub.calls[2].params.get('refresh_token'), 'offline-1');
|
|
344
|
+
assert.equal(provider.getAccessToken(), 'access-3');
|
|
345
|
+
provider.stop();
|
|
346
|
+
} finally {
|
|
347
|
+
mock.timers.reset();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Verifies that a token response with no `expires_in` clamps the refresh delay to
|
|
353
|
+
* `MIN_REFRESH_DELAY_IN_S` (1s) rather than scheduling an immediate hot loop.
|
|
354
|
+
*/
|
|
355
|
+
runTestCase('an absent/zero expires_in falls back to the minimum refresh delay', async () => {
|
|
356
|
+
const stub: FetchStub = makeFetchStub([
|
|
357
|
+
// No expires_in -> the scheduler must clamp to MIN_REFRESH_DELAY_IN_S (1s), not spin a hot loop.
|
|
358
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1' } },
|
|
359
|
+
{ body: { access_token: 'access-2', refresh_token: 'offline-2', expires_in: 31 } }
|
|
360
|
+
]);
|
|
361
|
+
|
|
362
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
363
|
+
try {
|
|
364
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS, fetchImpl: stub.fetchImpl });
|
|
365
|
+
assert.equal(provider.getAccessToken(), 'access-1');
|
|
366
|
+
|
|
367
|
+
// The refresh must fire exactly at the 1s minimum delay.
|
|
368
|
+
mock.timers.tick(1000);
|
|
369
|
+
await flushMicrotasks();
|
|
370
|
+
|
|
371
|
+
assert.equal(stub.calls.length, 2);
|
|
372
|
+
assert.equal(provider.getAccessToken(), 'access-2');
|
|
373
|
+
provider.stop();
|
|
374
|
+
} finally {
|
|
375
|
+
mock.timers.reset();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Verifies that `tokenExpirationInS = 0` makes the deadline equal "now" at bootstrap, so
|
|
381
|
+
* `scheduleRefresh` arms no timer and no refresh ever fires.
|
|
382
|
+
*/
|
|
383
|
+
runTestCase('a non-positive tokenExpirationInS lapses the loop immediately at schedule time', async () => {
|
|
384
|
+
const stub: FetchStub = makeFetchStub([
|
|
385
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1', expires_in: 31 } }
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
let fakeNowInMs: number = 5_000_000;
|
|
389
|
+
const nowInMs: () => number = (): number => fakeNowInMs;
|
|
390
|
+
|
|
391
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
392
|
+
try {
|
|
393
|
+
// tokenExpirationInS=0 -> deadline == now at bootstrap -> scheduleRefresh sees remaining <= 0 and stops.
|
|
394
|
+
const provider: OfflineTokenProvider = await login({
|
|
395
|
+
...BASE_OPTIONS,
|
|
396
|
+
fetchImpl: stub.fetchImpl,
|
|
397
|
+
nowInMs,
|
|
398
|
+
tokenExpirationInS: 0
|
|
399
|
+
});
|
|
400
|
+
assert.equal(provider.getAccessToken(), 'access-1');
|
|
401
|
+
|
|
402
|
+
// No timer was armed; advancing the clock must not trigger a refresh.
|
|
403
|
+
fakeNowInMs += 100_000;
|
|
404
|
+
mock.timers.tick(100_000);
|
|
405
|
+
await flushMicrotasks();
|
|
406
|
+
|
|
407
|
+
assert.equal(stub.calls.length, 1);
|
|
408
|
+
provider.stop();
|
|
409
|
+
} finally {
|
|
410
|
+
mock.timers.reset();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* By default (flag omitted) the default transport uses the plain global `fetch` with NO dispatcher, so
|
|
416
|
+
* TLS certificate verification stays ON.
|
|
417
|
+
*/
|
|
418
|
+
runTestCase('keycloakVerifySsl default: the default transport attaches no dispatcher (TLS verify ON)', async () => {
|
|
419
|
+
let capturedInit: TokenFetchInit | undefined;
|
|
420
|
+
const originalFetch: typeof globalThis.fetch = globalThis.fetch;
|
|
421
|
+
// Override the global fetch so the default-transport branch is exercised without network.
|
|
422
|
+
globalThis.fetch = ((_url: string, init: TokenFetchInit): Promise<TokenFetchResponse> => {
|
|
423
|
+
capturedInit = init;
|
|
424
|
+
return Promise.resolve({
|
|
425
|
+
ok: true,
|
|
426
|
+
status: 200,
|
|
427
|
+
text: (): Promise<string> =>
|
|
428
|
+
Promise.resolve(
|
|
429
|
+
JSON.stringify({ access_token: 'access-secure', refresh_token: 'offline-secure', expires_in: 300 })
|
|
430
|
+
)
|
|
431
|
+
});
|
|
432
|
+
}) as unknown as typeof globalThis.fetch;
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
// Omit fetchImpl (-> default transport) and keycloakVerifySsl (-> defaults to verify ON).
|
|
436
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS });
|
|
437
|
+
assert.ok(capturedInit !== undefined);
|
|
438
|
+
// No undici dispatcher => undici's global dispatcher with TLS verification ON.
|
|
439
|
+
assert.equal(capturedInit.dispatcher, undefined);
|
|
440
|
+
assert.equal(provider.getAccessToken(), 'access-secure');
|
|
441
|
+
provider.stop();
|
|
442
|
+
} finally {
|
|
443
|
+
globalThis.fetch = originalFetch;
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* With `keycloakVerifySsl: false` the default transport attaches an undici `Agent` dispatcher, disabling
|
|
449
|
+
* TLS certificate verification for the token call.
|
|
450
|
+
*/
|
|
451
|
+
runTestCase(
|
|
452
|
+
'keycloakVerifySsl false: the default transport attaches an undici Agent dispatcher (TLS verify OFF)',
|
|
453
|
+
async () => {
|
|
454
|
+
let capturedInit: TokenFetchInit | undefined;
|
|
455
|
+
const originalFetch: typeof globalThis.fetch = globalThis.fetch;
|
|
456
|
+
globalThis.fetch = ((_url: string, init: TokenFetchInit): Promise<TokenFetchResponse> => {
|
|
457
|
+
capturedInit = init;
|
|
458
|
+
return Promise.resolve({
|
|
459
|
+
ok: true,
|
|
460
|
+
status: 200,
|
|
461
|
+
text: (): Promise<string> =>
|
|
462
|
+
Promise.resolve(
|
|
463
|
+
JSON.stringify({ access_token: 'access-insecure', refresh_token: 'offline-insecure', expires_in: 300 })
|
|
464
|
+
)
|
|
465
|
+
});
|
|
466
|
+
}) as unknown as typeof globalThis.fetch;
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const provider: OfflineTokenProvider = await login({
|
|
470
|
+
...BASE_OPTIONS,
|
|
471
|
+
keycloakVerifySsl: false,
|
|
472
|
+
nowInMs: (): number => 0
|
|
473
|
+
});
|
|
474
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
475
|
+
const undici: { Agent: new (options: unknown) => unknown } = require('undici') as {
|
|
476
|
+
Agent: new (options: unknown) => unknown;
|
|
477
|
+
};
|
|
478
|
+
assert.ok(capturedInit !== undefined);
|
|
479
|
+
// The insecure undici Agent (rejectUnauthorized:false) reached the token POST.
|
|
480
|
+
assert.ok(capturedInit.dispatcher instanceof undici.Agent);
|
|
481
|
+
assert.equal(provider.getAccessToken(), 'access-insecure');
|
|
482
|
+
provider.stop();
|
|
483
|
+
} finally {
|
|
484
|
+
globalThis.fetch = originalFetch;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* An injected `fetchImpl` is used verbatim, so `keycloakVerifySsl: false` is a no-op (no dispatcher) for
|
|
491
|
+
* custom transports.
|
|
492
|
+
*/
|
|
493
|
+
runTestCase('keycloakVerifySsl false is ignored when a custom fetchImpl is injected', async () => {
|
|
494
|
+
const stub: FetchStub = makeFetchStub([
|
|
495
|
+
{ body: { access_token: 'access-1', refresh_token: 'offline-1', expires_in: 300 } }
|
|
496
|
+
]);
|
|
497
|
+
const provider: OfflineTokenProvider = await login({
|
|
498
|
+
...BASE_OPTIONS,
|
|
499
|
+
keycloakVerifySsl: false,
|
|
500
|
+
fetchImpl: stub.fetchImpl,
|
|
501
|
+
nowInMs: (): number => 0
|
|
502
|
+
});
|
|
503
|
+
assert.equal(stub.calls.length, 1);
|
|
504
|
+
// The injected transport receives the request unchanged -- the flag never touches it.
|
|
505
|
+
assert.equal(stub.calls[0].init.dispatcher, undefined);
|
|
506
|
+
assert.equal(provider.getAccessToken(), 'access-1');
|
|
507
|
+
provider.stop();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Verifies the default-fetch branch: when no `fetchImpl` is provided, the provider uses
|
|
512
|
+
* `globalThis.fetch` (here overridden to avoid real network) for the token-endpoint call.
|
|
513
|
+
*/
|
|
514
|
+
runTestCase('login falls back to the global fetch when no fetchImpl is provided', async () => {
|
|
515
|
+
const calls: string[] = [];
|
|
516
|
+
const originalFetch: typeof globalThis.fetch = globalThis.fetch;
|
|
517
|
+
// Override the global fetch so the default-branch (`globalThis.fetch`) is exercised without network.
|
|
518
|
+
globalThis.fetch = ((url: string): Promise<TokenFetchResponse> => {
|
|
519
|
+
calls.push(url);
|
|
520
|
+
return Promise.resolve({
|
|
521
|
+
ok: true,
|
|
522
|
+
status: 200,
|
|
523
|
+
text: (): Promise<string> =>
|
|
524
|
+
Promise.resolve(JSON.stringify({ access_token: 'global-1', refresh_token: 'offline-1', expires_in: 31 }))
|
|
525
|
+
});
|
|
526
|
+
}) as unknown as typeof globalThis.fetch;
|
|
527
|
+
|
|
528
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
529
|
+
try {
|
|
530
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS });
|
|
531
|
+
assert.equal(calls.length, 1);
|
|
532
|
+
assert.equal(calls[0], EXPECTED_TOKEN_ENDPOINT);
|
|
533
|
+
assert.equal(provider.getAccessToken(), 'global-1');
|
|
534
|
+
provider.stop();
|
|
535
|
+
} finally {
|
|
536
|
+
globalThis.fetch = originalFetch;
|
|
537
|
+
mock.timers.reset();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Verifies that calling {@link OfflineTokenProvider.stop} while a refresh is in flight lets that refresh
|
|
543
|
+
* complete (updating the token) but suppresses re-arming any further timer, so no extra fetch occurs.
|
|
544
|
+
*/
|
|
545
|
+
runTestCase('stop() during an in-flight refresh suppresses re-arming the next refresh', async () => {
|
|
546
|
+
const calls: URLSearchParams[] = [];
|
|
547
|
+
// Captures the refresh resolver so the test can complete the in-flight refresh on demand.
|
|
548
|
+
let releaseRefresh: () => void = (): void => {};
|
|
549
|
+
const fetchImpl: TokenFetch = (_url: string, init: TokenFetchInit): Promise<TokenFetchResponse> => {
|
|
550
|
+
calls.push(new URLSearchParams(init.body));
|
|
551
|
+
if (calls.length === 1) {
|
|
552
|
+
return Promise.resolve({
|
|
553
|
+
ok: true,
|
|
554
|
+
status: 200,
|
|
555
|
+
text: (): Promise<string> =>
|
|
556
|
+
Promise.resolve(JSON.stringify({ access_token: 'access-1', refresh_token: 'offline-1', expires_in: 31 }))
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
// Hold the refresh response open until the test releases it, after calling stop().
|
|
560
|
+
return new Promise((resolve: (value: TokenFetchResponse) => void): void => {
|
|
561
|
+
releaseRefresh = (): void => {
|
|
562
|
+
resolve({
|
|
563
|
+
ok: true,
|
|
564
|
+
status: 200,
|
|
565
|
+
text: (): Promise<string> =>
|
|
566
|
+
Promise.resolve(JSON.stringify({ access_token: 'access-2', refresh_token: 'offline-2', expires_in: 31 }))
|
|
567
|
+
});
|
|
568
|
+
};
|
|
569
|
+
});
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
mock.timers.enable({ apis: ['setTimeout'] });
|
|
573
|
+
try {
|
|
574
|
+
const provider: OfflineTokenProvider = await login({ ...BASE_OPTIONS, fetchImpl });
|
|
575
|
+
|
|
576
|
+
// Fire the timer so refresh() starts and is parked awaiting the (pending) refresh response.
|
|
577
|
+
mock.timers.tick(1000);
|
|
578
|
+
await flushMicrotasks();
|
|
579
|
+
assert.equal(calls.length, 2);
|
|
580
|
+
|
|
581
|
+
// Stop while the refresh is in flight, then let it complete.
|
|
582
|
+
provider.stop();
|
|
583
|
+
releaseRefresh();
|
|
584
|
+
await flushMicrotasks();
|
|
585
|
+
await flushMicrotasks();
|
|
586
|
+
|
|
587
|
+
// The completed refresh still updated the token, but scheduleRefresh saw `stopped` and armed nothing.
|
|
588
|
+
assert.equal(provider.getAccessToken(), 'access-2');
|
|
589
|
+
mock.timers.tick(100_000);
|
|
590
|
+
await flushMicrotasks();
|
|
591
|
+
assert.equal(calls.length, 2);
|
|
592
|
+
} finally {
|
|
593
|
+
mock.timers.reset();
|
|
594
|
+
}
|
|
595
|
+
});
|