@oxy.so/core 1.9.0 → 1.10.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/README.md +4 -4
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +112 -163
- package/dist/cjs/OxyServices.base.js +4 -2
- package/dist/cjs/boot/sessionColdBoot.js +3 -0
- package/dist/cjs/mixins/OxyServices.user.js +2 -5
- package/dist/cjs/server/cors.js +0 -1
- package/dist/cjs/session/refresh.js +71 -7
- package/dist/cjs/utils/errorUtils.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +112 -163
- package/dist/esm/OxyServices.base.js +4 -2
- package/dist/esm/boot/sessionColdBoot.js +3 -0
- package/dist/esm/mixins/OxyServices.user.js +2 -5
- package/dist/esm/server/cors.js +0 -1
- package/dist/esm/session/refresh.js +70 -6
- package/dist/esm/utils/errorUtils.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +59 -7
- package/dist/types/OxyServices.base.d.ts +3 -1
- package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
- package/dist/types/mixins/OxyServices.user.d.ts +1 -4
- package/dist/types/session/refresh.d.ts +21 -0
- package/dist/types/utils/errorUtils.d.ts +1 -1
- package/package.json +1 -1
- package/src/HttpService.ts +116 -187
- package/src/OxyServices.base.ts +4 -2
- package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
- package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
- package/src/__tests__/inSessionRefresh.test.ts +53 -0
- package/src/__tests__/linkedClient.test.ts +0 -7
- package/src/__tests__/nearExpiryMintStorm.test.ts +165 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
- package/src/boot/sessionColdBoot.ts +3 -0
- package/src/mixins/OxyServices.user.ts +2 -5
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
- package/src/mixins/__tests__/topics.test.ts +2 -3
- package/src/server/cors.ts +0 -1
- package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
- package/src/session/__tests__/refresh.test.ts +115 -2
- package/src/session/refresh.ts +77 -6
- package/src/utils/errorUtils.ts +1 -1
package/dist/cjs/HttpService.js
CHANGED
|
@@ -20,18 +20,12 @@ const requestUtils_1 = require("./utils/requestUtils");
|
|
|
20
20
|
const asyncUtils_1 = require("./utils/asyncUtils");
|
|
21
21
|
const errorUtils_1 = require("./utils/errorUtils");
|
|
22
22
|
const jwt_decode_1 = require("jwt-decode");
|
|
23
|
-
const platform_1 = require("./utils/platform");
|
|
24
23
|
const protocol_1 = require("@oxy.so/protocol");
|
|
25
24
|
const cacheKey_1 = require("./utils/cacheKey");
|
|
26
25
|
const redactUrl_1 = require("./utils/redactUrl");
|
|
27
26
|
const OxyServices_errors_1 = require("./OxyServices.errors");
|
|
28
27
|
const edgeRegion_1 = require("./utils/edgeRegion");
|
|
29
28
|
const activityId_1 = require("./utils/activityId");
|
|
30
|
-
/**
|
|
31
|
-
* Check if we're running in a native app environment (React Native, not web)
|
|
32
|
-
* This is used to determine CSRF handling mode
|
|
33
|
-
*/
|
|
34
|
-
const isNativeApp = (0, platform_1.isNative)();
|
|
35
29
|
/**
|
|
36
30
|
* Default per-request timeout (ms) when neither the call site nor
|
|
37
31
|
* {@link OxyConfig.requestTimeout} overrides it. Kept tight so a stalled
|
|
@@ -39,24 +33,6 @@ const isNativeApp = (0, platform_1.isNative)();
|
|
|
39
33
|
* request queue.
|
|
40
34
|
*/
|
|
41
35
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
|
42
|
-
/**
|
|
43
|
-
* Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
|
|
44
|
-
* regular request timeout: this is a small, fast, unauthenticated call and
|
|
45
|
-
* should never inherit a longer per-request budget.
|
|
46
|
-
*/
|
|
47
|
-
const CSRF_FETCH_TIMEOUT_MS = 5000;
|
|
48
|
-
/**
|
|
49
|
-
* Number of attempts for fetching a CSRF token before giving up. The first
|
|
50
|
-
* failure is usually a cold edge/cookie race; a single retry recovers it
|
|
51
|
-
* without masking a genuinely broken `/csrf-token` route.
|
|
52
|
-
*/
|
|
53
|
-
const CSRF_FETCH_MAX_ATTEMPTS = 2;
|
|
54
|
-
/**
|
|
55
|
-
* Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
|
|
56
|
-
* fetch sits in the critical path of a state-changing request, so the retry
|
|
57
|
-
* must add minimal latency.
|
|
58
|
-
*/
|
|
59
|
-
const CSRF_FETCH_RETRY_DELAY_MS = 500;
|
|
60
36
|
/**
|
|
61
37
|
* Cooldown (ms) applied after a failed access-token refresh before another
|
|
62
38
|
* refresh is attempted while the CURRENT token is still valid (a proactive,
|
|
@@ -116,6 +92,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60000;
|
|
|
116
92
|
* expired so the request carries a fresh bearer rather than racing the clock.
|
|
117
93
|
*/
|
|
118
94
|
const TOKEN_REFRESH_LEAD_SECONDS = 60;
|
|
95
|
+
/**
|
|
96
|
+
* Whether `token` is a JWT that is still valid but already inside the refresh
|
|
97
|
+
* lead window. `false` for an expired, opaque or no-`exp` token.
|
|
98
|
+
*/
|
|
99
|
+
function isInsideRefreshLeadWindow(token) {
|
|
100
|
+
try {
|
|
101
|
+
const decoded = (0, jwt_decode_1.jwtDecode)(token);
|
|
102
|
+
if (typeof decoded.exp !== 'number') {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const now = Math.floor(Date.now() / 1000);
|
|
106
|
+
// `<=`: the proactive scheduler fires at exactly `exp - lead`.
|
|
107
|
+
return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
119
113
|
/**
|
|
120
114
|
* Soft ceiling on the number of live entries in the identity-scoped GET
|
|
121
115
|
* response cache. Crossing it does NOT evict anything (the {@link TTLCache}
|
|
@@ -141,8 +135,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
|
|
|
141
135
|
class TokenStore {
|
|
142
136
|
constructor() {
|
|
143
137
|
this.accessToken = null;
|
|
144
|
-
this.csrfToken = null;
|
|
145
|
-
this.csrfTokenFetchPromise = null;
|
|
146
138
|
}
|
|
147
139
|
setTokens(accessToken) {
|
|
148
140
|
this.accessToken = accessToken;
|
|
@@ -156,22 +148,6 @@ class TokenStore {
|
|
|
156
148
|
hasAccessToken() {
|
|
157
149
|
return !!this.accessToken;
|
|
158
150
|
}
|
|
159
|
-
setCsrfToken(token) {
|
|
160
|
-
this.csrfToken = token;
|
|
161
|
-
}
|
|
162
|
-
getCsrfToken() {
|
|
163
|
-
return this.csrfToken;
|
|
164
|
-
}
|
|
165
|
-
setCsrfTokenFetchPromise(promise) {
|
|
166
|
-
this.csrfTokenFetchPromise = promise;
|
|
167
|
-
}
|
|
168
|
-
getCsrfTokenFetchPromise() {
|
|
169
|
-
return this.csrfTokenFetchPromise;
|
|
170
|
-
}
|
|
171
|
-
clearCsrfToken() {
|
|
172
|
-
this.csrfToken = null;
|
|
173
|
-
this.csrfTokenFetchPromise = null;
|
|
174
|
-
}
|
|
175
151
|
}
|
|
176
152
|
/**
|
|
177
153
|
* Propagate `parent`'s abort to `child`, returning a disposer.
|
|
@@ -237,9 +213,45 @@ class HttpService {
|
|
|
237
213
|
* it, and there is exactly one timestamp to reason about.
|
|
238
214
|
*/
|
|
239
215
|
this.lastRefreshWasRateLimited = false;
|
|
216
|
+
/**
|
|
217
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
218
|
+
* inside the refresh lead window, or `null`.
|
|
219
|
+
*
|
|
220
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
221
|
+
* STORED access token for as long as that token has not expired — it only
|
|
222
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
223
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
224
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
225
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
226
|
+
* request for the last minute of every token, which exhausted the server's
|
|
227
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
228
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
229
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
230
|
+
*
|
|
231
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
232
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
233
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
234
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
235
|
+
*/
|
|
236
|
+
this.noFresherTokenThan = null;
|
|
240
237
|
this.authRefreshHandler = null;
|
|
241
238
|
this.accessTokenProvider = null;
|
|
242
239
|
this.deviceSecretMintInFlight = null;
|
|
240
|
+
/**
|
|
241
|
+
* Bumped by every {@link endSession}. A re-mint captures it before its first
|
|
242
|
+
* await and plants nothing if it moved: a refresh that was already in flight
|
|
243
|
+
* when the user signed out would otherwise complete a moment later and put a
|
|
244
|
+
* bearer — and through the token listeners, the account — back.
|
|
245
|
+
*/
|
|
246
|
+
this.sessionEpoch = 0;
|
|
247
|
+
/**
|
|
248
|
+
* Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
|
|
249
|
+
* native shared-keychain re-mint arm: after a sign-out a 401 is the expected
|
|
250
|
+
* answer, not a cue to sign back in with the identity key. The device-secret
|
|
251
|
+
* arm is NOT gated on it — minting from a credential the store still holds is
|
|
252
|
+
* how a signed-out tab joins a sign-in made in another tab.
|
|
253
|
+
*/
|
|
254
|
+
this.sessionEnded = false;
|
|
243
255
|
/**
|
|
244
256
|
* Epoch (ms) before which a cache-size telemetry warning must not be
|
|
245
257
|
* re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
|
|
@@ -393,14 +405,9 @@ class HttpService {
|
|
|
393
405
|
// it before enqueue means auth-blocked requests hold NO slot while the shared
|
|
394
406
|
// mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
|
|
395
407
|
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
396
|
-
// 401
|
|
397
|
-
//
|
|
398
|
-
const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
|
408
|
+
// 401 retry re-enters request() with a fresh config, so it re-resolves
|
|
409
|
+
// this here with the refreshed token.
|
|
399
410
|
const authHeader = config.skipAuth ? null : await this.getAuthHeader();
|
|
400
|
-
// CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
|
|
401
|
-
// clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
|
|
402
|
-
// should not need to implement a duplicate `/csrf-token` route.
|
|
403
|
-
const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
|
|
404
411
|
const edgeRegionHeader = await (0, edgeRegion_1.getBrowserEdgeRegionHeader)();
|
|
405
412
|
const activityIdHeader = (0, activityId_1.getBrowserActivityIdHeader)();
|
|
406
413
|
// A request the caller has ALREADY abandoned takes no queue slot and makes
|
|
@@ -471,29 +478,6 @@ class HttpService {
|
|
|
471
478
|
if (authHeader) {
|
|
472
479
|
headers['Authorization'] = authHeader;
|
|
473
480
|
}
|
|
474
|
-
// Add CSRF token header for state-changing requests
|
|
475
|
-
if (csrfToken) {
|
|
476
|
-
headers['X-CSRF-Token'] = csrfToken;
|
|
477
|
-
}
|
|
478
|
-
// Add native app header for React Native (required for CSRF validation)
|
|
479
|
-
// Native apps can't persist cookies like browsers, so the server uses
|
|
480
|
-
// header-only CSRF validation when this header is present
|
|
481
|
-
if (isNativeApp && isStateChangingMethod) {
|
|
482
|
-
headers['X-Native-App'] = 'true';
|
|
483
|
-
}
|
|
484
|
-
// Debug logging for CSRF issues, routed through SimpleLogger so it only
|
|
485
|
-
// fires when consumers opt in via `enableLogging`.
|
|
486
|
-
if (isStateChangingMethod) {
|
|
487
|
-
this.logger.debug('CSRF Debug:', {
|
|
488
|
-
url,
|
|
489
|
-
method,
|
|
490
|
-
isNativeApp,
|
|
491
|
-
platformOS: (0, platform_1.getPlatformOS)(),
|
|
492
|
-
hasCsrfToken: !!csrfToken,
|
|
493
|
-
csrfTokenLength: csrfToken?.length,
|
|
494
|
-
hasNativeAppHeader: headers['X-Native-App'] === 'true',
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
481
|
// Merge custom headers if provided
|
|
498
482
|
if (config.headers) {
|
|
499
483
|
Object.entries(config.headers).forEach(([key, value]) => {
|
|
@@ -545,34 +529,19 @@ class HttpService {
|
|
|
545
529
|
if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
|
|
546
530
|
const refreshed = await this.refreshAccessToken('response-401');
|
|
547
531
|
if (refreshed) {
|
|
548
|
-
// `deduplicate: false` is REQUIRED on the retry
|
|
549
|
-
//
|
|
550
|
-
//
|
|
532
|
+
// `deduplicate: false` is REQUIRED on the retry. This re-issue runs
|
|
533
|
+
// while the ORIGINAL request is still in-flight under its dedupe
|
|
534
|
+
// key; the refreshed token is
|
|
551
535
|
// for the SAME user, so the identity-scoped key is UNCHANGED — a
|
|
552
536
|
// deduplicated retry would resolve to the still-pending original
|
|
553
537
|
// and await itself (deadlock). Opting the retry out of dedupe makes
|
|
554
538
|
// it a fresh request.
|
|
555
539
|
return this.request({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
|
|
556
540
|
}
|
|
557
|
-
// Refresh failed or no token — clear tokens
|
|
541
|
+
// Refresh failed or no token — clear tokens
|
|
558
542
|
this.tokenStore.clearTokens();
|
|
559
|
-
this.tokenStore.clearCsrfToken();
|
|
560
543
|
this.notifyTokenChange();
|
|
561
544
|
}
|
|
562
|
-
// On 403 with CSRF error, clear cached token and retry once
|
|
563
|
-
if (response.status === 403 && !config._isCsrfRetry) {
|
|
564
|
-
try {
|
|
565
|
-
const clonedResponse = response.clone();
|
|
566
|
-
const errBody = await clonedResponse.json();
|
|
567
|
-
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
568
|
-
this.tokenStore.clearCsrfToken();
|
|
569
|
-
return this.request({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
catch {
|
|
573
|
-
// Failed to parse error body — not a CSRF error
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
545
|
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
577
546
|
// Anything unreadable leaves `errorBody` undefined and degrades to the
|
|
578
547
|
// status-based message — an error path that throws its own error is
|
|
@@ -946,75 +915,6 @@ class HttpService {
|
|
|
946
915
|
return false;
|
|
947
916
|
}
|
|
948
917
|
}
|
|
949
|
-
/**
|
|
950
|
-
* Fetch CSRF token from server (with deduplication)
|
|
951
|
-
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
952
|
-
*/
|
|
953
|
-
async fetchCsrfToken() {
|
|
954
|
-
// Return cached token if available
|
|
955
|
-
const cachedToken = this.tokenStore.getCsrfToken();
|
|
956
|
-
if (cachedToken) {
|
|
957
|
-
this.logger.debug('Using cached CSRF token');
|
|
958
|
-
return cachedToken;
|
|
959
|
-
}
|
|
960
|
-
// Deduplicate concurrent CSRF token fetches
|
|
961
|
-
const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
|
|
962
|
-
if (existingPromise) {
|
|
963
|
-
this.logger.debug('Waiting for existing CSRF fetch');
|
|
964
|
-
return existingPromise;
|
|
965
|
-
}
|
|
966
|
-
const fetchPromise = (async () => {
|
|
967
|
-
for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
|
|
968
|
-
try {
|
|
969
|
-
this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
|
|
970
|
-
// Use AbortController for timeout (more compatible than AbortSignal.timeout)
|
|
971
|
-
const controller = new AbortController();
|
|
972
|
-
const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
|
|
973
|
-
const response = await fetch(`${this.baseURL}/csrf-token`, {
|
|
974
|
-
method: 'GET',
|
|
975
|
-
headers: { 'Accept': 'application/json' },
|
|
976
|
-
credentials: 'include', // Required to receive and send cookies
|
|
977
|
-
signal: controller.signal,
|
|
978
|
-
});
|
|
979
|
-
clearTimeout(timeoutId);
|
|
980
|
-
this.logger.debug('CSRF fetch response:', response.status, response.ok);
|
|
981
|
-
if (response.ok) {
|
|
982
|
-
const data = await response.json();
|
|
983
|
-
const token = data.csrfToken || null;
|
|
984
|
-
this.logger.debug('CSRF response data:', {
|
|
985
|
-
hasCsrfToken: typeof token === 'string' && token.length > 0,
|
|
986
|
-
csrfTokenLength: token?.length,
|
|
987
|
-
});
|
|
988
|
-
this.tokenStore.setCsrfToken(token);
|
|
989
|
-
this.logger.debug('CSRF token fetched');
|
|
990
|
-
return token;
|
|
991
|
-
}
|
|
992
|
-
// Also check response header for CSRF token
|
|
993
|
-
const headerToken = response.headers.get('X-CSRF-Token');
|
|
994
|
-
if (headerToken) {
|
|
995
|
-
this.tokenStore.setCsrfToken(headerToken);
|
|
996
|
-
this.logger.debug('CSRF token from header');
|
|
997
|
-
return headerToken;
|
|
998
|
-
}
|
|
999
|
-
this.logger.debug('CSRF fetch failed with status:', response.status);
|
|
1000
|
-
this.logger.warn('Failed to fetch CSRF token:', response.status);
|
|
1001
|
-
}
|
|
1002
|
-
catch (error) {
|
|
1003
|
-
this.logger.debug('CSRF fetch error:', error);
|
|
1004
|
-
this.logger.warn('CSRF token fetch error:', error);
|
|
1005
|
-
}
|
|
1006
|
-
// Brief backoff before the next attempt.
|
|
1007
|
-
if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
|
|
1008
|
-
await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
return null;
|
|
1012
|
-
})().finally(() => {
|
|
1013
|
-
this.tokenStore.setCsrfTokenFetchPromise(null);
|
|
1014
|
-
});
|
|
1015
|
-
this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
|
|
1016
|
-
return fetchPromise;
|
|
1017
|
-
}
|
|
1018
918
|
/**
|
|
1019
919
|
* Get auth header with automatic token refresh
|
|
1020
920
|
*/
|
|
@@ -1028,6 +928,12 @@ class HttpService {
|
|
|
1028
928
|
const currentTime = Math.floor(Date.now() / 1000);
|
|
1029
929
|
// If the token expires within the refresh lead window, refresh it.
|
|
1030
930
|
if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
|
|
931
|
+
// The server already answered this token's refresh with this very token:
|
|
932
|
+
// it has nothing fresher until `exp`. Use it rather than asking again
|
|
933
|
+
// (see `noFresherTokenThan`).
|
|
934
|
+
if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
|
|
935
|
+
return `Bearer ${accessToken}`;
|
|
936
|
+
}
|
|
1031
937
|
const refreshed = await this.refreshAccessToken('preflight');
|
|
1032
938
|
if (refreshed)
|
|
1033
939
|
return `Bearer ${refreshed}`;
|
|
@@ -1070,16 +976,26 @@ class HttpService {
|
|
|
1070
976
|
// by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
|
|
1071
977
|
// on the way out would discard the flag it just set.
|
|
1072
978
|
this.lastRefreshWasRateLimited = false;
|
|
979
|
+
const epoch = this.sessionEpoch;
|
|
1073
980
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1074
981
|
.then((newToken) => {
|
|
982
|
+
if (epoch !== this.sessionEpoch) {
|
|
983
|
+
// The session ended while this re-mint was in flight. Not a failure
|
|
984
|
+
// (no cooldown), and nothing to plant.
|
|
985
|
+
this.logger.debug('Discarded a token refresh that outlived its session');
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
1075
988
|
if (!newToken) {
|
|
1076
989
|
this.lastRefreshFailureAt = Date.now();
|
|
1077
990
|
return null;
|
|
1078
991
|
}
|
|
992
|
+
// A token is planted again, so there is a session again.
|
|
993
|
+
this.sessionEnded = false;
|
|
1079
994
|
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
1080
995
|
this.tokenStore.setTokens(newToken);
|
|
1081
996
|
this.notifyTokenChange();
|
|
1082
997
|
}
|
|
998
|
+
this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
|
|
1083
999
|
// A success clears the failure timestamp so the next refresh is never
|
|
1084
1000
|
// throttled by a stale cooldown.
|
|
1085
1001
|
this.lastRefreshFailureAt = 0;
|
|
@@ -1113,6 +1029,16 @@ class HttpService {
|
|
|
1113
1029
|
noteRefreshRateLimited() {
|
|
1114
1030
|
this.lastRefreshWasRateLimited = true;
|
|
1115
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
1034
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
1035
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
1036
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
1037
|
+
*/
|
|
1038
|
+
isAwaitingCurrentTokenExpiry() {
|
|
1039
|
+
const token = this.tokenStore.getAccessToken();
|
|
1040
|
+
return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
|
|
1041
|
+
}
|
|
1116
1042
|
/**
|
|
1117
1043
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1118
1044
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -1264,7 +1190,6 @@ class HttpService {
|
|
|
1264
1190
|
return this.requestAuthenticatedResponseAttempt(config, `Bearer ${refreshed}`, true);
|
|
1265
1191
|
}
|
|
1266
1192
|
this.tokenStore.clearTokens();
|
|
1267
|
-
this.tokenStore.clearCsrfToken();
|
|
1268
1193
|
this.notifyTokenChange();
|
|
1269
1194
|
}
|
|
1270
1195
|
const duration = Date.now() - startTime;
|
|
@@ -1299,9 +1224,34 @@ class HttpService {
|
|
|
1299
1224
|
}
|
|
1300
1225
|
// Token management
|
|
1301
1226
|
setTokens(accessToken) {
|
|
1227
|
+
this.sessionEnded = false;
|
|
1302
1228
|
this.tokenStore.setTokens(accessToken);
|
|
1303
1229
|
this.notifyTokenChange();
|
|
1304
1230
|
}
|
|
1231
|
+
/**
|
|
1232
|
+
* End the local session: clear the bearer and abandon every re-mint already
|
|
1233
|
+
* in flight, so none of them can plant a token after the sign-out.
|
|
1234
|
+
*
|
|
1235
|
+
* {@link clearTokens} alone only drops the bearer, which is right for a
|
|
1236
|
+
* mirror (a linked client following its parent) but not for a sign-out: a
|
|
1237
|
+
* refresh started a moment earlier would finish and plant a new one.
|
|
1238
|
+
*/
|
|
1239
|
+
endSession() {
|
|
1240
|
+
this.sessionEpoch += 1;
|
|
1241
|
+
this.sessionEnded = true;
|
|
1242
|
+
this.clearTokens();
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* The current session epoch — see {@link endSession}. A re-mint lane that
|
|
1246
|
+
* plants tokens itself (the device-secret arm) compares it across its awaits.
|
|
1247
|
+
*/
|
|
1248
|
+
getSessionEpoch() {
|
|
1249
|
+
return this.sessionEpoch;
|
|
1250
|
+
}
|
|
1251
|
+
/** Whether {@link endSession} ran and no token has been planted since. */
|
|
1252
|
+
hasSessionEnded() {
|
|
1253
|
+
return this.sessionEnded;
|
|
1254
|
+
}
|
|
1305
1255
|
setAuthRefreshHandler(handler) {
|
|
1306
1256
|
this.authRefreshHandler = handler;
|
|
1307
1257
|
}
|
|
@@ -1310,7 +1260,6 @@ class HttpService {
|
|
|
1310
1260
|
}
|
|
1311
1261
|
clearTokens() {
|
|
1312
1262
|
this.tokenStore.clearTokens();
|
|
1313
|
-
this.tokenStore.clearCsrfToken();
|
|
1314
1263
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
1315
1264
|
// different user could never read these entries, but a logged-out client
|
|
1316
1265
|
// must not keep the previous session's personalized data resident in
|
|
@@ -1426,8 +1375,8 @@ class HttpService {
|
|
|
1426
1375
|
}
|
|
1427
1376
|
// Test-only utility — clears tokens on this instance
|
|
1428
1377
|
__resetTokensForTests() {
|
|
1378
|
+
this.sessionEnded = false;
|
|
1429
1379
|
this.tokenStore.clearTokens();
|
|
1430
|
-
this.tokenStore.clearCsrfToken();
|
|
1431
1380
|
}
|
|
1432
1381
|
}
|
|
1433
1382
|
exports.HttpService = HttpService;
|
|
@@ -168,10 +168,12 @@ class OxyServicesBase {
|
|
|
168
168
|
this.httpService.setTokens(accessToken);
|
|
169
169
|
}
|
|
170
170
|
/**
|
|
171
|
-
* Clear stored authentication tokens
|
|
171
|
+
* Clear stored authentication tokens and end the local session: a re-mint
|
|
172
|
+
* already in flight plants nothing, and no new one runs until a token is set
|
|
173
|
+
* again (see `HttpService.endSession`).
|
|
172
174
|
*/
|
|
173
175
|
clearTokens() {
|
|
174
|
-
this.httpService.
|
|
176
|
+
this.httpService.endSession();
|
|
175
177
|
this._cachedUserId = undefined;
|
|
176
178
|
this._cachedAccessToken = null;
|
|
177
179
|
}
|
|
@@ -205,6 +205,9 @@ async function runSessionColdBoot(opts) {
|
|
|
205
205
|
// Network / 5xx: keep the secret; a later attempt can succeed.
|
|
206
206
|
logger_1.logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
|
|
207
207
|
return { kind: 'skip' };
|
|
208
|
+
case 'session-ended':
|
|
209
|
+
// Signed out while the boot was minting; the arm planted nothing.
|
|
210
|
+
return { kind: 'skip' };
|
|
208
211
|
case 'no-secret':
|
|
209
212
|
return { kind: 'skip' };
|
|
210
213
|
}
|
|
@@ -288,10 +288,7 @@ function OxyServicesUserMixin(Base) {
|
|
|
288
288
|
* `PostHydrationService`) and is unchanged.
|
|
289
289
|
* - **Plain client (browser / React Native with a user session):** when no
|
|
290
290
|
* service credentials are configured, the chunk is fetched via
|
|
291
|
-
* `makeRequest`, which attaches the configured user bearer.
|
|
292
|
-
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
293
|
-
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
294
|
-
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
291
|
+
* `makeRequest`, which attaches the configured user bearer. Previously
|
|
295
292
|
* this method always used the service path, so every client-side caller
|
|
296
293
|
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
297
294
|
*
|
|
@@ -318,7 +315,7 @@ function OxyServicesUserMixin(Base) {
|
|
|
318
315
|
}
|
|
319
316
|
// A backend that called configureServiceAuth() uses the bearer-service
|
|
320
317
|
// path; any other caller (browser / RN with a user session) uses the
|
|
321
|
-
// user-bearer path.
|
|
318
|
+
// user-bearer path.
|
|
322
319
|
const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
323
320
|
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
324
321
|
const settled = await Promise.all(chunks.map(async (chunk) => {
|
package/dist/cjs/server/cors.js
CHANGED
|
@@ -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`).
|
|
@@ -65,6 +79,7 @@ async function refreshDeviceSecretArm(deps) {
|
|
|
65
79
|
const { oxy, store } = deps;
|
|
66
80
|
const pin = deps.pin ?? null;
|
|
67
81
|
return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
|
|
82
|
+
const epoch = oxy.httpService.getSessionEpoch();
|
|
68
83
|
const persisted = await store.load();
|
|
69
84
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
70
85
|
return { status: 'no-secret' };
|
|
@@ -130,12 +145,26 @@ async function refreshDeviceSecretArm(deps) {
|
|
|
130
145
|
expiresAt: mint.expiresAt,
|
|
131
146
|
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
132
147
|
};
|
|
148
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
149
|
+
// Signed out while minting. The server has rotated the secret, so a store
|
|
150
|
+
// that still holds the presented one must learn the rotation (the
|
|
151
|
+
// token-null lane keeps the store so a reload can restore); a store the
|
|
152
|
+
// sign-out cleared must NOT be refilled. Either way, plant nothing.
|
|
153
|
+
const current = await store.load();
|
|
154
|
+
if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
|
|
155
|
+
await store.save(next);
|
|
156
|
+
}
|
|
157
|
+
return { status: 'session-ended' };
|
|
158
|
+
}
|
|
133
159
|
// Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
|
|
134
160
|
// A failed durable persist must NOT plant.
|
|
135
161
|
const persistedOk = await store.save(next);
|
|
136
162
|
if (!persistedOk) {
|
|
137
163
|
return { status: 'persist-failed' };
|
|
138
164
|
}
|
|
165
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
166
|
+
return { status: 'session-ended' };
|
|
167
|
+
}
|
|
139
168
|
oxy.setTokens(mint.accessToken);
|
|
140
169
|
return {
|
|
141
170
|
status: 'ok',
|
|
@@ -173,6 +202,7 @@ async function refreshPersistedSession(deps) {
|
|
|
173
202
|
const identity = deps.identity ?? null;
|
|
174
203
|
// The shared keychain is never an identity-bound client's recovery path.
|
|
175
204
|
const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? (0, platform_1.isNative)());
|
|
205
|
+
const epoch = oxy.httpService.getSessionEpoch();
|
|
176
206
|
// Resolved per call: a re-established identity session can move the pin, and a
|
|
177
207
|
// replaced/removed local key clears it (in which case arm 1 must NOT mint —
|
|
178
208
|
// an unpinned mint would adopt whatever account the device switched to).
|
|
@@ -184,6 +214,8 @@ async function refreshPersistedSession(deps) {
|
|
|
184
214
|
switch (arm1.status) {
|
|
185
215
|
case 'ok':
|
|
186
216
|
return arm1.token;
|
|
217
|
+
case 'session-ended':
|
|
218
|
+
return null;
|
|
187
219
|
case 'transient':
|
|
188
220
|
logger_1.logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
|
|
189
221
|
return null;
|
|
@@ -222,10 +254,18 @@ async function refreshPersistedSession(deps) {
|
|
|
222
254
|
if (identity) {
|
|
223
255
|
return recoverIdentitySession(oxy, store, identity);
|
|
224
256
|
}
|
|
225
|
-
|
|
257
|
+
// Never after a sign-out: the shared keychain holds an identity KEY, not a
|
|
258
|
+
// session, and using it here would sign the user straight back in.
|
|
259
|
+
if (allowSharedKeyFallback && !oxy.httpService.hasSessionEnded()) {
|
|
226
260
|
try {
|
|
227
|
-
|
|
261
|
+
// Planted here, not by the sign-in: a sign-out that lands while the
|
|
262
|
+
// challenge round-trips must win, or the shared keychain signs the user
|
|
263
|
+
// straight back in.
|
|
264
|
+
const session = await oxy.signInWithSharedIdentity({ plantTokens: false });
|
|
228
265
|
if (session?.accessToken) {
|
|
266
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
229
269
|
// Repopulate the fast device-secret lane from the shared-key re-mint.
|
|
230
270
|
if (session.deviceId && session.deviceSecret) {
|
|
231
271
|
await store.save({
|
|
@@ -237,6 +277,10 @@ async function refreshPersistedSession(deps) {
|
|
|
237
277
|
expiresAt: session.expiresAt,
|
|
238
278
|
});
|
|
239
279
|
}
|
|
280
|
+
if (oxy.httpService.getSessionEpoch() !== epoch) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
oxy.setTokens(session.accessToken);
|
|
240
284
|
return session.accessToken;
|
|
241
285
|
}
|
|
242
286
|
}
|
|
@@ -334,12 +378,26 @@ function startTokenRefreshScheduler(oxy) {
|
|
|
334
378
|
if (expSeconds === null) {
|
|
335
379
|
return;
|
|
336
380
|
}
|
|
337
|
-
|
|
381
|
+
const untilExpiryMs = expSeconds * 1000 - Date.now();
|
|
382
|
+
// The server already answered this token's refresh with this very token:
|
|
383
|
+
// asking again before `exp` only gets it back again. Ask just after expiry,
|
|
384
|
+
// when the server rotates (see REMINT_AFTER_EXPIRY_MS).
|
|
385
|
+
if (untilExpiryMs > 0 && oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
|
|
386
|
+
armTimer(untilExpiryMs + exports.REMINT_AFTER_EXPIRY_MS);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
armTimer(untilExpiryMs - exports.TOKEN_REFRESH_LEAD_MS);
|
|
338
390
|
};
|
|
339
391
|
const runRefresh = () => {
|
|
340
392
|
// Clear any pending timer up front so an out-of-band trigger (focus) plus
|
|
341
393
|
// a fired timer can never double-run.
|
|
342
394
|
clearTimer();
|
|
395
|
+
// Another lane (a request-time preflight) already asked, and the server
|
|
396
|
+
// answered with this same token: re-arm for just past expiry instead.
|
|
397
|
+
if (oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
|
|
398
|
+
scheduleFromExpiry();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
343
401
|
void oxy.httpService.refreshAccessToken('preflight')
|
|
344
402
|
.then((token) => Boolean(token))
|
|
345
403
|
.catch(() => false)
|
|
@@ -382,10 +440,16 @@ function startTokenRefreshScheduler(oxy) {
|
|
|
382
440
|
schedule();
|
|
383
441
|
}
|
|
384
442
|
};
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
443
|
+
// Re-arm only on a token that actually CHANGED. The mint plants the token it
|
|
444
|
+
// returns even when that is the token already held, and treating the repeat
|
|
445
|
+
// as new re-armed from an in-lead-window expiry, i.e. at the 1s floor.
|
|
446
|
+
let lastSeenToken = oxy.getAccessToken();
|
|
447
|
+
const unsubscribeTokens = oxy.onTokensChanged((token) => {
|
|
448
|
+
if (disposed || token === lastSeenToken) {
|
|
449
|
+
return;
|
|
388
450
|
}
|
|
451
|
+
lastSeenToken = token;
|
|
452
|
+
schedule();
|
|
389
453
|
});
|
|
390
454
|
let removeFocusListener = null;
|
|
391
455
|
if (typeof document !== 'undefined') {
|
|
@@ -82,7 +82,7 @@ const nonEmptyString = (value) => typeof value === 'string' && value.trim().leng
|
|
|
82
82
|
* - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
|
|
83
83
|
* OAuth token and userinfo endpoints. `error_description` is the human text
|
|
84
84
|
* and `error` is the machine code, so both survive.
|
|
85
|
-
* - `{ message, code }` — e.g. the API's
|
|
85
|
+
* - `{ message, code }` — e.g. the API's middleware rejections.
|
|
86
86
|
* - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
|
|
87
87
|
* `message`/`error_description` the string is the message, not a code: a bare
|
|
88
88
|
* `error` string is not machine-readable enough to promote to `code`.
|