@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.
@@ -116,6 +116,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60000;
116
116
  * expired so the request carries a fresh bearer rather than racing the clock.
117
117
  */
118
118
  const TOKEN_REFRESH_LEAD_SECONDS = 60;
119
+ /**
120
+ * Whether `token` is a JWT that is still valid but already inside the refresh
121
+ * lead window. `false` for an expired, opaque or no-`exp` token.
122
+ */
123
+ function isInsideRefreshLeadWindow(token) {
124
+ try {
125
+ const decoded = (0, jwt_decode_1.jwtDecode)(token);
126
+ if (typeof decoded.exp !== 'number') {
127
+ return false;
128
+ }
129
+ const now = Math.floor(Date.now() / 1000);
130
+ // `<=`: the proactive scheduler fires at exactly `exp - lead`.
131
+ return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
119
137
  /**
120
138
  * Soft ceiling on the number of live entries in the identity-scoped GET
121
139
  * response cache. Crossing it does NOT evict anything (the {@link TTLCache}
@@ -237,6 +255,27 @@ class HttpService {
237
255
  * it, and there is exactly one timestamp to reason about.
238
256
  */
239
257
  this.lastRefreshWasRateLimited = false;
258
+ /**
259
+ * The access token the last SUCCESSFUL refresh handed back while it was still
260
+ * inside the refresh lead window, or `null`.
261
+ *
262
+ * The device mint (`POST /session/device/token`) answers with the session's
263
+ * STORED access token for as long as that token has not expired — it only
264
+ * mints a new one once the old one is past `exp`. A client that re-mints
265
+ * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
266
+ * token back, and because that counts as a success (no cooldown), every
267
+ * following request re-ran the preflight and minted again: one mint per
268
+ * request for the last minute of every token, which exhausted the server's
269
+ * 30/min mint budget. The 429 that followed imposed a 60s cooldown that
270
+ * straddled the token's real expiry, so the next request went out with no
271
+ * bearer, drew a 401, found the refresh cooling down, and signed the user out.
272
+ *
273
+ * Remembering the answer ends that: while the current token IS this value and
274
+ * has not yet expired, the server has already said it has nothing fresher, so
275
+ * the preflight sends the still-valid token instead of asking again. Once the
276
+ * token crosses `exp` the preflight asks once more and the server rotates.
277
+ */
278
+ this.noFresherTokenThan = null;
240
279
  this.authRefreshHandler = null;
241
280
  this.accessTokenProvider = null;
242
281
  this.deviceSecretMintInFlight = null;
@@ -1028,6 +1067,12 @@ class HttpService {
1028
1067
  const currentTime = Math.floor(Date.now() / 1000);
1029
1068
  // If the token expires within the refresh lead window, refresh it.
1030
1069
  if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
1070
+ // The server already answered this token's refresh with this very token:
1071
+ // it has nothing fresher until `exp`. Use it rather than asking again
1072
+ // (see `noFresherTokenThan`).
1073
+ if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
1074
+ return `Bearer ${accessToken}`;
1075
+ }
1031
1076
  const refreshed = await this.refreshAccessToken('preflight');
1032
1077
  if (refreshed)
1033
1078
  return `Bearer ${refreshed}`;
@@ -1080,6 +1125,7 @@ class HttpService {
1080
1125
  this.tokenStore.setTokens(newToken);
1081
1126
  this.notifyTokenChange();
1082
1127
  }
1128
+ this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
1083
1129
  // A success clears the failure timestamp so the next refresh is never
1084
1130
  // throttled by a stale cooldown.
1085
1131
  this.lastRefreshFailureAt = 0;
@@ -1113,6 +1159,16 @@ class HttpService {
1113
1159
  noteRefreshRateLimited() {
1114
1160
  this.lastRefreshWasRateLimited = true;
1115
1161
  }
1162
+ /**
1163
+ * Whether the server has already answered a refresh of the CURRENT access
1164
+ * token with that same, still-valid token — i.e. it has nothing fresher to
1165
+ * give until the token's `exp`. The proactive scheduler reads this so it waits
1166
+ * for expiry instead of re-asking at its 1s floor.
1167
+ */
1168
+ isAwaitingCurrentTokenExpiry() {
1169
+ const token = this.tokenStore.getAccessToken();
1170
+ return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
1171
+ }
1116
1172
  /**
1117
1173
  * Whether the CURRENT stored access token is already past its `exp`. Drives
1118
1174
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TOKEN_REFRESH_LEAD_MS = void 0;
3
+ exports.REMINT_AFTER_EXPIRY_MS = exports.TOKEN_REFRESH_LEAD_MS = void 0;
4
4
  exports.refreshDeviceSecretArm = refreshDeviceSecretArm;
5
5
  exports.refreshPersistedSession = refreshPersistedSession;
6
6
  exports.createAuthRefreshHandler = createAuthRefreshHandler;
@@ -39,6 +39,20 @@ const MIN_SCHEDULE_DELAY_MS = 1000;
39
39
  */
40
40
  const MIN_FAILURE_BACKOFF_MS = 5000;
41
41
  const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
42
+ /**
43
+ * How long past `exp` to re-mint when a successful refresh handed back a token
44
+ * that is STILL inside the lead window.
45
+ *
46
+ * The device mint returns the session's stored access token until that token
47
+ * has actually expired, so asking again before `exp` only returns the same
48
+ * token. Re-arming from expiry in that state computed a negative delay, hit the
49
+ * {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
50
+ * minute of every token — 30+ mints, the server's whole per-minute budget, and
51
+ * the 429 that followed straddled the real expiry. Waiting until just past
52
+ * `exp` asks exactly once more, when the server will rotate. The margin covers
53
+ * the server's strict `exp < now` comparison and a little clock skew.
54
+ */
55
+ exports.REMINT_AFTER_EXPIRY_MS = 2000;
42
56
  /**
43
57
  * Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
44
58
  * single-flight (`httpService.runSingleFlightDeviceSecretMint`).
@@ -334,12 +348,26 @@ function startTokenRefreshScheduler(oxy) {
334
348
  if (expSeconds === null) {
335
349
  return;
336
350
  }
337
- armTimer(expSeconds * 1000 - Date.now() - exports.TOKEN_REFRESH_LEAD_MS);
351
+ const untilExpiryMs = expSeconds * 1000 - Date.now();
352
+ // The server already answered this token's refresh with this very token:
353
+ // asking again before `exp` only gets it back again. Ask just after expiry,
354
+ // when the server rotates (see REMINT_AFTER_EXPIRY_MS).
355
+ if (untilExpiryMs > 0 && oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
356
+ armTimer(untilExpiryMs + exports.REMINT_AFTER_EXPIRY_MS);
357
+ return;
358
+ }
359
+ armTimer(untilExpiryMs - exports.TOKEN_REFRESH_LEAD_MS);
338
360
  };
339
361
  const runRefresh = () => {
340
362
  // Clear any pending timer up front so an out-of-band trigger (focus) plus
341
363
  // a fired timer can never double-run.
342
364
  clearTimer();
365
+ // Another lane (a request-time preflight) already asked, and the server
366
+ // answered with this same token: re-arm for just past expiry instead.
367
+ if (oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
368
+ scheduleFromExpiry();
369
+ return;
370
+ }
343
371
  void oxy.httpService.refreshAccessToken('preflight')
344
372
  .then((token) => Boolean(token))
345
373
  .catch(() => false)
@@ -382,10 +410,16 @@ function startTokenRefreshScheduler(oxy) {
382
410
  schedule();
383
411
  }
384
412
  };
385
- const unsubscribeTokens = oxy.onTokensChanged(() => {
386
- if (!disposed) {
387
- schedule();
413
+ // Re-arm only on a token that actually CHANGED. The mint plants the token it
414
+ // returns even when that is the token already held, and treating the repeat
415
+ // as new re-armed from an in-lead-window expiry, i.e. at the 1s floor.
416
+ let lastSeenToken = oxy.getAccessToken();
417
+ const unsubscribeTokens = oxy.onTokensChanged((token) => {
418
+ if (disposed || token === lastSeenToken) {
419
+ return;
388
420
  }
421
+ lastSeenToken = token;
422
+ schedule();
389
423
  });
390
424
  let removeFocusListener = null;
391
425
  if (typeof document !== 'undefined') {