@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
|
@@ -141,6 +141,27 @@ export declare class HttpService {
|
|
|
141
141
|
* it, and there is exactly one timestamp to reason about.
|
|
142
142
|
*/
|
|
143
143
|
private lastRefreshWasRateLimited;
|
|
144
|
+
/**
|
|
145
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
146
|
+
* inside the refresh lead window, or `null`.
|
|
147
|
+
*
|
|
148
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
149
|
+
* STORED access token for as long as that token has not expired — it only
|
|
150
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
151
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
152
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
153
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
154
|
+
* request for the last minute of every token, which exhausted the server's
|
|
155
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
156
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
157
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
158
|
+
*
|
|
159
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
160
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
161
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
162
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
163
|
+
*/
|
|
164
|
+
private noFresherTokenThan;
|
|
144
165
|
private authRefreshHandler;
|
|
145
166
|
private accessTokenProvider;
|
|
146
167
|
private deviceSecretMintInFlight;
|
|
@@ -309,6 +330,13 @@ export declare class HttpService {
|
|
|
309
330
|
* Public because the handler lives in `session/refresh.ts`, not on this class.
|
|
310
331
|
*/
|
|
311
332
|
noteRefreshRateLimited(): void;
|
|
333
|
+
/**
|
|
334
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
335
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
336
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
337
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
338
|
+
*/
|
|
339
|
+
isAwaitingCurrentTokenExpiry(): boolean;
|
|
312
340
|
/**
|
|
313
341
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
314
342
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -33,6 +33,20 @@ import { type IdentityBinding } from './identitySession';
|
|
|
33
33
|
* window — the scheduler just fires it during idle/background.
|
|
34
34
|
*/
|
|
35
35
|
export declare const TOKEN_REFRESH_LEAD_MS = 60000;
|
|
36
|
+
/**
|
|
37
|
+
* How long past `exp` to re-mint when a successful refresh handed back a token
|
|
38
|
+
* that is STILL inside the lead window.
|
|
39
|
+
*
|
|
40
|
+
* The device mint returns the session's stored access token until that token
|
|
41
|
+
* has actually expired, so asking again before `exp` only returns the same
|
|
42
|
+
* token. Re-arming from expiry in that state computed a negative delay, hit the
|
|
43
|
+
* {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
|
|
44
|
+
* minute of every token — 30+ mints, the server's whole per-minute budget, and
|
|
45
|
+
* the 429 that followed straddled the real expiry. Waiting until just past
|
|
46
|
+
* `exp` asks exactly once more, when the server will rotate. The margin covers
|
|
47
|
+
* the server's strict `exp < now` comparison and a little clock skew.
|
|
48
|
+
*/
|
|
49
|
+
export declare const REMINT_AFTER_EXPIRY_MS = 2000;
|
|
36
50
|
export interface RefreshDeps {
|
|
37
51
|
oxy: OxyServices;
|
|
38
52
|
store: AuthStateStore;
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -240,6 +240,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000;
|
|
|
240
240
|
*/
|
|
241
241
|
const TOKEN_REFRESH_LEAD_SECONDS = 60;
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Whether `token` is a JWT that is still valid but already inside the refresh
|
|
245
|
+
* lead window. `false` for an expired, opaque or no-`exp` token.
|
|
246
|
+
*/
|
|
247
|
+
function isInsideRefreshLeadWindow(token: string): boolean {
|
|
248
|
+
try {
|
|
249
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
250
|
+
if (typeof decoded.exp !== 'number') {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const now = Math.floor(Date.now() / 1000);
|
|
254
|
+
// `<=`: the proactive scheduler fires at exactly `exp - lead`.
|
|
255
|
+
return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
/**
|
|
244
262
|
* Soft ceiling on the number of live entries in the identity-scoped GET
|
|
245
263
|
* response cache. Crossing it does NOT evict anything (the {@link TTLCache}
|
|
@@ -390,6 +408,27 @@ export class HttpService {
|
|
|
390
408
|
* it, and there is exactly one timestamp to reason about.
|
|
391
409
|
*/
|
|
392
410
|
private lastRefreshWasRateLimited = false;
|
|
411
|
+
/**
|
|
412
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
413
|
+
* inside the refresh lead window, or `null`.
|
|
414
|
+
*
|
|
415
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
416
|
+
* STORED access token for as long as that token has not expired — it only
|
|
417
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
418
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
419
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
420
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
421
|
+
* request for the last minute of every token, which exhausted the server's
|
|
422
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
423
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
424
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
425
|
+
*
|
|
426
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
427
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
428
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
429
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
430
|
+
*/
|
|
431
|
+
private noFresherTokenThan: string | null = null;
|
|
393
432
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
394
433
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
395
434
|
private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
@@ -1324,6 +1363,12 @@ export class HttpService {
|
|
|
1324
1363
|
|
|
1325
1364
|
// If the token expires within the refresh lead window, refresh it.
|
|
1326
1365
|
if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
|
|
1366
|
+
// The server already answered this token's refresh with this very token:
|
|
1367
|
+
// it has nothing fresher until `exp`. Use it rather than asking again
|
|
1368
|
+
// (see `noFresherTokenThan`).
|
|
1369
|
+
if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
|
|
1370
|
+
return `Bearer ${accessToken}`;
|
|
1371
|
+
}
|
|
1327
1372
|
const refreshed = await this.refreshAccessToken('preflight');
|
|
1328
1373
|
if (refreshed) return `Bearer ${refreshed}`;
|
|
1329
1374
|
if (decoded.exp > currentTime) {
|
|
@@ -1378,6 +1423,7 @@ export class HttpService {
|
|
|
1378
1423
|
this.tokenStore.setTokens(newToken);
|
|
1379
1424
|
this.notifyTokenChange();
|
|
1380
1425
|
}
|
|
1426
|
+
this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
|
|
1381
1427
|
// A success clears the failure timestamp so the next refresh is never
|
|
1382
1428
|
// throttled by a stale cooldown.
|
|
1383
1429
|
this.lastRefreshFailureAt = 0;
|
|
@@ -1414,6 +1460,17 @@ export class HttpService {
|
|
|
1414
1460
|
this.lastRefreshWasRateLimited = true;
|
|
1415
1461
|
}
|
|
1416
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
1465
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
1466
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
1467
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
1468
|
+
*/
|
|
1469
|
+
isAwaitingCurrentTokenExpiry(): boolean {
|
|
1470
|
+
const token = this.tokenStore.getAccessToken();
|
|
1471
|
+
return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1417
1474
|
/**
|
|
1418
1475
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1419
1476
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The near-expiry mint storm that signed a busy app out (OxyHQ/Mention#1140).
|
|
3
|
+
*
|
|
4
|
+
* What the server does: `POST /session/device/token` answers with the session's
|
|
5
|
+
* STORED access token for as long as that token has not expired, and mints a
|
|
6
|
+
* new one only once it is past `exp`. The mint is budgeted at 30/min per device.
|
|
7
|
+
*
|
|
8
|
+
* What the client did with that: the scheduler and the request-time preflight
|
|
9
|
+
* both re-mint 60s before `exp`. The server handed the same token back, which
|
|
10
|
+
* counted as a success (no cooldown), so the scheduler re-armed at its 1s floor
|
|
11
|
+
* and every request re-ran the preflight: ~30 mints in the token's last minute,
|
|
12
|
+
* then a 429, whose 60s cooldown straddled the real expiry. The next request
|
|
13
|
+
* carried no bearer, drew a 401, found the refresh cooling down, and the
|
|
14
|
+
* HttpService cleared the token — which the provider reads as a sign-out.
|
|
15
|
+
*
|
|
16
|
+
* Production logs for the device showed exactly this: a burst of ~30 mints at
|
|
17
|
+
* ~1.4s intervals every ~16 minutes, a single mint ~78s later (the scheduler's
|
|
18
|
+
* 5+10+20+40s backoff through the 60s rate-limit cooldown), and a sign-out on
|
|
19
|
+
* the one cycle where the app was in active use across the expiry.
|
|
20
|
+
*
|
|
21
|
+
* This suite simulates that server against the real HttpService, refresh
|
|
22
|
+
* scheduler and 401 lane.
|
|
23
|
+
*/
|
|
24
|
+
import { OxyServices } from '../OxyServices';
|
|
25
|
+
import { startTokenRefreshScheduler } from '../session/refresh';
|
|
26
|
+
|
|
27
|
+
const TOKEN_TTL_SECONDS = 15 * 60;
|
|
28
|
+
const MINT_BUDGET_PER_MINUTE = 30;
|
|
29
|
+
|
|
30
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
31
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
32
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expOf(token: string): number {
|
|
36
|
+
const [, payload] = token.split('.');
|
|
37
|
+
return (JSON.parse(Buffer.from(payload, 'base64url').toString()) as { exp: number }).exp;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The device mint as the server implements it, including its rate limiter.
|
|
42
|
+
*
|
|
43
|
+
* `rotateWithinSeconds` is how close to `exp` the stored token may be before the
|
|
44
|
+
* server rotates instead of handing it back: `0` is the server as it was (it
|
|
45
|
+
* rotated only a token already past `exp`), a positive value is the fixed one.
|
|
46
|
+
*/
|
|
47
|
+
function createMintServer(rotateWithinSeconds: number): {
|
|
48
|
+
mint: () => { status: 200; token: string } | { status: 429 };
|
|
49
|
+
storedToken: () => string;
|
|
50
|
+
mints: number[];
|
|
51
|
+
rateLimited: number;
|
|
52
|
+
} {
|
|
53
|
+
let serial = 0;
|
|
54
|
+
const issue = (): string => {
|
|
55
|
+
serial += 1;
|
|
56
|
+
return createJwt({ userId: 'u', jti: `t${serial}`, exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS });
|
|
57
|
+
};
|
|
58
|
+
let stored = issue();
|
|
59
|
+
const mints: number[] = [];
|
|
60
|
+
const state = { rateLimited: 0 };
|
|
61
|
+
return {
|
|
62
|
+
mint: () => {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
if (mints.filter((at) => now - at < 60_000).length >= MINT_BUDGET_PER_MINUTE) {
|
|
65
|
+
state.rateLimited += 1;
|
|
66
|
+
return { status: 429 };
|
|
67
|
+
}
|
|
68
|
+
mints.push(now);
|
|
69
|
+
// `getAccessToken`: hand back the stored token unless it has expired.
|
|
70
|
+
if (expOf(stored) - Math.floor(now / 1000) < (rotateWithinSeconds > 0 ? rotateWithinSeconds : 0)) {
|
|
71
|
+
stored = issue();
|
|
72
|
+
}
|
|
73
|
+
return { status: 200, token: stored };
|
|
74
|
+
},
|
|
75
|
+
storedToken: () => stored,
|
|
76
|
+
mints,
|
|
77
|
+
get rateLimited() {
|
|
78
|
+
return state.rateLimited;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('a busy app across an access token expiry', () => {
|
|
84
|
+
const originalFetch = globalThis.fetch;
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
globalThis.fetch = originalFetch;
|
|
88
|
+
jest.useRealTimers();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function runBusyApp(rotateWithinSeconds: number): Promise<{
|
|
92
|
+
tokenEvents: Array<string | null>;
|
|
93
|
+
finalToken: string | null;
|
|
94
|
+
server: ReturnType<typeof createMintServer>;
|
|
95
|
+
}> {
|
|
96
|
+
jest.useFakeTimers({ now: new Date('2026-09-25T13:48:00Z') });
|
|
97
|
+
const server = createMintServer(rotateWithinSeconds);
|
|
98
|
+
|
|
99
|
+
// The app's own API: 401 without a live bearer, as any resource server does.
|
|
100
|
+
globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
|
|
101
|
+
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
102
|
+
const bearer = headers.Authorization ?? headers.authorization;
|
|
103
|
+
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : null;
|
|
104
|
+
if (!token || expOf(token) <= Math.floor(Date.now() / 1000)) {
|
|
105
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
106
|
+
status: 401,
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return new Response(JSON.stringify({ data: { ok: true } }), {
|
|
111
|
+
status: 200,
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
});
|
|
114
|
+
}) as typeof fetch;
|
|
115
|
+
|
|
116
|
+
const oxy = new OxyServices({ baseURL: 'https://api.mention.earth', enableCache: false, enableRetry: false });
|
|
117
|
+
oxy.setTokens(server.storedToken());
|
|
118
|
+
oxy.httpService.setAuthRefreshHandler(async (reason) => {
|
|
119
|
+
const answer = server.mint();
|
|
120
|
+
if (answer.status === 429) {
|
|
121
|
+
// What `refreshDeviceSecretArm` does with a 429.
|
|
122
|
+
oxy.httpService.noteRefreshRateLimited();
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
oxy.setTokens(answer.token);
|
|
126
|
+
return answer.token;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const tokenEvents: Array<string | null> = [];
|
|
130
|
+
oxy.onTokensChanged((token) => tokenEvents.push(token));
|
|
131
|
+
const scheduler = startTokenRefreshScheduler(oxy);
|
|
132
|
+
|
|
133
|
+
// Twenty minutes of an app in use: a request every 700ms, straight across
|
|
134
|
+
// the first token's expiry at 14:03:00.
|
|
135
|
+
for (let elapsed = 0; elapsed < 20 * 60_000; elapsed += 700) {
|
|
136
|
+
await oxy.httpService.get('/feed').catch(() => undefined);
|
|
137
|
+
await jest.advanceTimersByTimeAsync(700);
|
|
138
|
+
}
|
|
139
|
+
scheduler.dispose();
|
|
140
|
+
return { tokenEvents, finalToken: oxy.getAccessToken(), server };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it('against the server that hands back an unexpired token: never cleared, never rate limited', async () => {
|
|
144
|
+
const { tokenEvents, finalToken, server } = await runBusyApp(0);
|
|
145
|
+
|
|
146
|
+
// Before the fix: ~30 mints of the same token, a 429, then the token was
|
|
147
|
+
// cleared (a run of `null` events) and the app was signed out.
|
|
148
|
+
expect(tokenEvents).not.toContain(null);
|
|
149
|
+
expect(finalToken).not.toBeNull();
|
|
150
|
+
expect(server.rateLimited).toBe(0);
|
|
151
|
+
// One rotation. The server still hands the expired token back for the
|
|
152
|
+
// second it expires in (it compares `exp < now`), which costs a few mints in
|
|
153
|
+
// that one second — nowhere near the budget.
|
|
154
|
+
expect(server.mints.length).toBeLessThan(MINT_BUDGET_PER_MINUTE / 3);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('against the server that rotates a token near expiry: one mint per rotation', async () => {
|
|
158
|
+
const { tokenEvents, finalToken, server } = await runBusyApp(120);
|
|
159
|
+
|
|
160
|
+
expect(tokenEvents).not.toContain(null);
|
|
161
|
+
expect(finalToken).not.toBeNull();
|
|
162
|
+
expect(server.rateLimited).toBe(0);
|
|
163
|
+
expect(server.mints).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
});
|
package/src/session/refresh.ts
CHANGED
|
@@ -63,6 +63,21 @@ const MIN_SCHEDULE_DELAY_MS = 1_000;
|
|
|
63
63
|
const MIN_FAILURE_BACKOFF_MS = 5_000;
|
|
64
64
|
const MAX_FAILURE_BACKOFF_MS = 5 * 60_000;
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* How long past `exp` to re-mint when a successful refresh handed back a token
|
|
68
|
+
* that is STILL inside the lead window.
|
|
69
|
+
*
|
|
70
|
+
* The device mint returns the session's stored access token until that token
|
|
71
|
+
* has actually expired, so asking again before `exp` only returns the same
|
|
72
|
+
* token. Re-arming from expiry in that state computed a negative delay, hit the
|
|
73
|
+
* {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
|
|
74
|
+
* minute of every token — 30+ mints, the server's whole per-minute budget, and
|
|
75
|
+
* the 429 that followed straddled the real expiry. Waiting until just past
|
|
76
|
+
* `exp` asks exactly once more, when the server will rotate. The margin covers
|
|
77
|
+
* the server's strict `exp < now` comparison and a little clock skew.
|
|
78
|
+
*/
|
|
79
|
+
export const REMINT_AFTER_EXPIRY_MS = 2_000;
|
|
80
|
+
|
|
66
81
|
export interface RefreshDeps {
|
|
67
82
|
oxy: OxyServices;
|
|
68
83
|
store: AuthStateStore;
|
|
@@ -447,13 +462,27 @@ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedu
|
|
|
447
462
|
if (expSeconds === null) {
|
|
448
463
|
return;
|
|
449
464
|
}
|
|
450
|
-
|
|
465
|
+
const untilExpiryMs = expSeconds * 1000 - Date.now();
|
|
466
|
+
// The server already answered this token's refresh with this very token:
|
|
467
|
+
// asking again before `exp` only gets it back again. Ask just after expiry,
|
|
468
|
+
// when the server rotates (see REMINT_AFTER_EXPIRY_MS).
|
|
469
|
+
if (untilExpiryMs > 0 && oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
|
|
470
|
+
armTimer(untilExpiryMs + REMINT_AFTER_EXPIRY_MS);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
armTimer(untilExpiryMs - TOKEN_REFRESH_LEAD_MS);
|
|
451
474
|
};
|
|
452
475
|
|
|
453
476
|
const runRefresh = (): void => {
|
|
454
477
|
// Clear any pending timer up front so an out-of-band trigger (focus) plus
|
|
455
478
|
// a fired timer can never double-run.
|
|
456
479
|
clearTimer();
|
|
480
|
+
// Another lane (a request-time preflight) already asked, and the server
|
|
481
|
+
// answered with this same token: re-arm for just past expiry instead.
|
|
482
|
+
if (oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
|
|
483
|
+
scheduleFromExpiry();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
457
486
|
void oxy.httpService.refreshAccessToken('preflight')
|
|
458
487
|
.then((token) => Boolean(token))
|
|
459
488
|
.catch(() => false)
|
|
@@ -498,10 +527,16 @@ export function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedu
|
|
|
498
527
|
}
|
|
499
528
|
};
|
|
500
529
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
530
|
+
// Re-arm only on a token that actually CHANGED. The mint plants the token it
|
|
531
|
+
// returns even when that is the token already held, and treating the repeat
|
|
532
|
+
// as new re-armed from an in-lead-window expiry, i.e. at the 1s floor.
|
|
533
|
+
let lastSeenToken = oxy.getAccessToken();
|
|
534
|
+
const unsubscribeTokens = oxy.onTokensChanged((token) => {
|
|
535
|
+
if (disposed || token === lastSeenToken) {
|
|
536
|
+
return;
|
|
504
537
|
}
|
|
538
|
+
lastSeenToken = token;
|
|
539
|
+
schedule();
|
|
505
540
|
});
|
|
506
541
|
|
|
507
542
|
let removeFocusListener: (() => void) | null = null;
|