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