@oxy.so/core 1.9.0 → 1.9.1
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +56 -0
- package/dist/cjs/session/refresh.js +39 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -0
- package/dist/esm/session/refresh.js +38 -4
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +28 -0
- package/dist/types/session/refresh.d.ts +14 -0
- package/package.json +1 -1
- package/src/HttpService.ts +57 -0
- package/src/__tests__/nearExpiryMintStorm.test.ts +165 -0
- package/src/session/refresh.ts +39 -4
package/dist/esm/HttpService.js
CHANGED
|
@@ -113,6 +113,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60000;
|
|
|
113
113
|
* expired so the request carries a fresh bearer rather than racing the clock.
|
|
114
114
|
*/
|
|
115
115
|
const TOKEN_REFRESH_LEAD_SECONDS = 60;
|
|
116
|
+
/**
|
|
117
|
+
* Whether `token` is a JWT that is still valid but already inside the refresh
|
|
118
|
+
* lead window. `false` for an expired, opaque or no-`exp` token.
|
|
119
|
+
*/
|
|
120
|
+
function isInsideRefreshLeadWindow(token) {
|
|
121
|
+
try {
|
|
122
|
+
const decoded = jwtDecode(token);
|
|
123
|
+
if (typeof decoded.exp !== 'number') {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const now = Math.floor(Date.now() / 1000);
|
|
127
|
+
// `<=`: the proactive scheduler fires at exactly `exp - lead`.
|
|
128
|
+
return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
116
134
|
/**
|
|
117
135
|
* Soft ceiling on the number of live entries in the identity-scoped GET
|
|
118
136
|
* response cache. Crossing it does NOT evict anything (the {@link TTLCache}
|
|
@@ -234,6 +252,27 @@ export class HttpService {
|
|
|
234
252
|
* it, and there is exactly one timestamp to reason about.
|
|
235
253
|
*/
|
|
236
254
|
this.lastRefreshWasRateLimited = false;
|
|
255
|
+
/**
|
|
256
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
257
|
+
* inside the refresh lead window, or `null`.
|
|
258
|
+
*
|
|
259
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
260
|
+
* STORED access token for as long as that token has not expired — it only
|
|
261
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
262
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
263
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
264
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
265
|
+
* request for the last minute of every token, which exhausted the server's
|
|
266
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
267
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
268
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
269
|
+
*
|
|
270
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
271
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
272
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
273
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
274
|
+
*/
|
|
275
|
+
this.noFresherTokenThan = null;
|
|
237
276
|
this.authRefreshHandler = null;
|
|
238
277
|
this.accessTokenProvider = null;
|
|
239
278
|
this.deviceSecretMintInFlight = null;
|
|
@@ -1025,6 +1064,12 @@ export class HttpService {
|
|
|
1025
1064
|
const currentTime = Math.floor(Date.now() / 1000);
|
|
1026
1065
|
// If the token expires within the refresh lead window, refresh it.
|
|
1027
1066
|
if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
|
|
1067
|
+
// The server already answered this token's refresh with this very token:
|
|
1068
|
+
// it has nothing fresher until `exp`. Use it rather than asking again
|
|
1069
|
+
// (see `noFresherTokenThan`).
|
|
1070
|
+
if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
|
|
1071
|
+
return `Bearer ${accessToken}`;
|
|
1072
|
+
}
|
|
1028
1073
|
const refreshed = await this.refreshAccessToken('preflight');
|
|
1029
1074
|
if (refreshed)
|
|
1030
1075
|
return `Bearer ${refreshed}`;
|
|
@@ -1077,6 +1122,7 @@ export class HttpService {
|
|
|
1077
1122
|
this.tokenStore.setTokens(newToken);
|
|
1078
1123
|
this.notifyTokenChange();
|
|
1079
1124
|
}
|
|
1125
|
+
this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
|
|
1080
1126
|
// A success clears the failure timestamp so the next refresh is never
|
|
1081
1127
|
// throttled by a stale cooldown.
|
|
1082
1128
|
this.lastRefreshFailureAt = 0;
|
|
@@ -1110,6 +1156,16 @@ export class HttpService {
|
|
|
1110
1156
|
noteRefreshRateLimited() {
|
|
1111
1157
|
this.lastRefreshWasRateLimited = true;
|
|
1112
1158
|
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
1161
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
1162
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
1163
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
1164
|
+
*/
|
|
1165
|
+
isAwaitingCurrentTokenExpiry() {
|
|
1166
|
+
const token = this.tokenStore.getAccessToken();
|
|
1167
|
+
return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
|
|
1168
|
+
}
|
|
1113
1169
|
/**
|
|
1114
1170
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1115
1171
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -31,6 +31,20 @@ const MIN_SCHEDULE_DELAY_MS = 1000;
|
|
|
31
31
|
*/
|
|
32
32
|
const MIN_FAILURE_BACKOFF_MS = 5000;
|
|
33
33
|
const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
|
|
34
|
+
/**
|
|
35
|
+
* How long past `exp` to re-mint when a successful refresh handed back a token
|
|
36
|
+
* that is STILL inside the lead window.
|
|
37
|
+
*
|
|
38
|
+
* The device mint returns the session's stored access token until that token
|
|
39
|
+
* has actually expired, so asking again before `exp` only returns the same
|
|
40
|
+
* token. Re-arming from expiry in that state computed a negative delay, hit the
|
|
41
|
+
* {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
|
|
42
|
+
* minute of every token — 30+ mints, the server's whole per-minute budget, and
|
|
43
|
+
* the 429 that followed straddled the real expiry. Waiting until just past
|
|
44
|
+
* `exp` asks exactly once more, when the server will rotate. The margin covers
|
|
45
|
+
* the server's strict `exp < now` comparison and a little clock skew.
|
|
46
|
+
*/
|
|
47
|
+
export const REMINT_AFTER_EXPIRY_MS = 2000;
|
|
34
48
|
/**
|
|
35
49
|
* Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
|
|
36
50
|
* single-flight (`httpService.runSingleFlightDeviceSecretMint`).
|
|
@@ -326,12 +340,26 @@ export function startTokenRefreshScheduler(oxy) {
|
|
|
326
340
|
if (expSeconds === null) {
|
|
327
341
|
return;
|
|
328
342
|
}
|
|
329
|
-
|
|
343
|
+
const untilExpiryMs = expSeconds * 1000 - Date.now();
|
|
344
|
+
// The server already answered this token's refresh with this very token:
|
|
345
|
+
// asking again before `exp` only gets it back again. Ask just after expiry,
|
|
346
|
+
// when the server rotates (see REMINT_AFTER_EXPIRY_MS).
|
|
347
|
+
if (untilExpiryMs > 0 && oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
|
|
348
|
+
armTimer(untilExpiryMs + REMINT_AFTER_EXPIRY_MS);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
armTimer(untilExpiryMs - TOKEN_REFRESH_LEAD_MS);
|
|
330
352
|
};
|
|
331
353
|
const runRefresh = () => {
|
|
332
354
|
// Clear any pending timer up front so an out-of-band trigger (focus) plus
|
|
333
355
|
// a fired timer can never double-run.
|
|
334
356
|
clearTimer();
|
|
357
|
+
// Another lane (a request-time preflight) already asked, and the server
|
|
358
|
+
// answered with this same token: re-arm for just past expiry instead.
|
|
359
|
+
if (oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
|
|
360
|
+
scheduleFromExpiry();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
335
363
|
void oxy.httpService.refreshAccessToken('preflight')
|
|
336
364
|
.then((token) => Boolean(token))
|
|
337
365
|
.catch(() => false)
|
|
@@ -374,10 +402,16 @@ export function startTokenRefreshScheduler(oxy) {
|
|
|
374
402
|
schedule();
|
|
375
403
|
}
|
|
376
404
|
};
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
405
|
+
// Re-arm only on a token that actually CHANGED. The mint plants the token it
|
|
406
|
+
// returns even when that is the token already held, and treating the repeat
|
|
407
|
+
// as new re-armed from an in-lead-window expiry, i.e. at the 1s floor.
|
|
408
|
+
let lastSeenToken = oxy.getAccessToken();
|
|
409
|
+
const unsubscribeTokens = oxy.onTokensChanged((token) => {
|
|
410
|
+
if (disposed || token === lastSeenToken) {
|
|
411
|
+
return;
|
|
380
412
|
}
|
|
413
|
+
lastSeenToken = token;
|
|
414
|
+
schedule();
|
|
381
415
|
});
|
|
382
416
|
let removeFocusListener = null;
|
|
383
417
|
if (typeof document !== 'undefined') {
|