@oxy.so/core 7.9.0 → 7.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/HttpService.js +113 -12
- package/dist/cjs/shared/utils/errorUtils.js +5 -2
- package/dist/cjs/utils/asyncUtils.js +6 -0
- package/dist/cjs/utils/authHelpers.js +3 -0
- package/dist/esm/HttpService.js +113 -12
- package/dist/esm/shared/utils/errorUtils.js +3 -1
- package/dist/esm/utils/asyncUtils.js +6 -0
- package/dist/esm/utils/authHelpers.js +3 -0
- package/dist/types/shared/utils/errorUtils.d.ts +2 -0
- package/package.json +1 -1
- package/src/HttpService.ts +122 -12
- package/src/shared/utils/errorUtils.ts +5 -1
- package/src/utils/asyncUtils.ts +7 -0
- package/src/utils/authHelpers.ts +2 -0
package/dist/cjs/HttpService.js
CHANGED
|
@@ -71,6 +71,18 @@ function sameTokenSession(left, right) {
|
|
|
71
71
|
'authMethodId',
|
|
72
72
|
].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
|
|
73
73
|
}
|
|
74
|
+
/** Discard an unused body even when a platform's stream shim lacks cancel. */
|
|
75
|
+
function discardResponseBody(response) {
|
|
76
|
+
try {
|
|
77
|
+
const body = response.body;
|
|
78
|
+
if (typeof body?.cancel !== 'function')
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
return Promise.resolve(body.cancel()).catch(() => { });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
74
86
|
/**
|
|
75
87
|
* The keys whose presence beside `data` makes a body a PAGE rather than a
|
|
76
88
|
* payload — see {@link HttpService.unwrapResponse} for why this list is narrow.
|
|
@@ -463,6 +475,13 @@ class HttpService {
|
|
|
463
475
|
* {@link ResponsePayload}, which makes reading the envelope a compile error.
|
|
464
476
|
*/
|
|
465
477
|
async request(config) {
|
|
478
|
+
if (config.signal?.aborted)
|
|
479
|
+
throw (0, errorUtils_1.createCancelledError)('Request cancelled before it was sent');
|
|
480
|
+
// The provider can change independently of the last request. Synchronize
|
|
481
|
+
// before deriving the cache and deduplication identity, including hits.
|
|
482
|
+
const explicitAuthorization = Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
|
|
483
|
+
if (!config.skipAuth && !explicitAuthorization)
|
|
484
|
+
this.syncAccessTokenFromProvider();
|
|
466
485
|
// Per-call authority is not represented by the instance's session cache
|
|
467
486
|
// key. Never share or retain responses authenticated by explicit headers.
|
|
468
487
|
if (Object.keys(config.headers ?? {}).some((key) => ['authorization', 'x-oxy-user-id'].includes(key.toLowerCase()) ||
|
|
@@ -484,7 +503,8 @@ class HttpService {
|
|
|
484
503
|
valid: true,
|
|
485
504
|
});
|
|
486
505
|
}
|
|
487
|
-
const key = this.generateCacheKey(config.method, config.url, config.data || config.params)
|
|
506
|
+
const key = this.generateCacheKey(config.method, config.url, config.data || config.params) +
|
|
507
|
+
(config.skipAuth ? ' auth=none' : '');
|
|
488
508
|
let lease = this.readLeases.get(key);
|
|
489
509
|
if (!lease) {
|
|
490
510
|
lease = { key, id: ++this.nextReadLeaseId, valid: true, readers: 0 };
|
|
@@ -514,6 +534,7 @@ class HttpService {
|
|
|
514
534
|
// overrides any per-request `cache:true`: nothing is read from nor written
|
|
515
535
|
// to the response cache. Request deduplication below is unaffected.
|
|
516
536
|
const cache = cacheRequested && !this.cacheDisabled;
|
|
537
|
+
const cacheSessionEpoch = this.sessionEpoch;
|
|
517
538
|
// Generate cache key (optimized for large objects)
|
|
518
539
|
const cacheKey = cache ? lease.key : null;
|
|
519
540
|
// Check cache first
|
|
@@ -538,7 +559,33 @@ class HttpService {
|
|
|
538
559
|
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
539
560
|
// 401 retry re-enters request() with a fresh config, so it re-resolves
|
|
540
561
|
// this here with the refreshed token.
|
|
541
|
-
const
|
|
562
|
+
const explicitAuth = Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
|
|
563
|
+
const usesSessionAuth = !config.skipAuth && !explicitAuth;
|
|
564
|
+
const contextToken = usesSessionAuth ? this.syncAccessTokenFromProvider() : null;
|
|
565
|
+
const contextEpoch = this.sessionEpoch;
|
|
566
|
+
const authHeader = usesSessionAuth
|
|
567
|
+
? await (0, requestSignal_1.awaitRequestSignal)(this.getAuthHeader(), signal)
|
|
568
|
+
: null;
|
|
569
|
+
const isOriginalSession = () => this.sessionEpoch === contextEpoch &&
|
|
570
|
+
sameTokenSession(contextToken, this.syncAccessTokenFromProvider());
|
|
571
|
+
const newerRequestToken = () => {
|
|
572
|
+
if (!contextToken || !authHeader?.startsWith('Bearer '))
|
|
573
|
+
return null;
|
|
574
|
+
const requestToken = authHeader.slice('Bearer '.length);
|
|
575
|
+
const current = this.syncAccessTokenFromProvider();
|
|
576
|
+
return current && current !== requestToken && sameTokenSession(requestToken, current)
|
|
577
|
+
? current
|
|
578
|
+
: null;
|
|
579
|
+
};
|
|
580
|
+
// The provider can change between cache-key selection and auth preflight.
|
|
581
|
+
// Refuse to attach a response to a lease belonging to another identity.
|
|
582
|
+
if (usesSessionAuth &&
|
|
583
|
+
(!isOriginalSession() ||
|
|
584
|
+
(lease.key &&
|
|
585
|
+
(cacheSessionEpoch !== this.sessionEpoch ||
|
|
586
|
+
lease.key !== this.generateCacheKey(method, url, data || params))))) {
|
|
587
|
+
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
588
|
+
}
|
|
542
589
|
// Never awaited: the PoP is a hint, and waiting on its trace fetch put up
|
|
543
590
|
// to a second in front of requests.
|
|
544
591
|
const edgeRegionHeader = (0, edgeRegion_1.peekBrowserEdgeRegionHeader)();
|
|
@@ -561,6 +608,9 @@ class HttpService {
|
|
|
561
608
|
if (domain.aborted) {
|
|
562
609
|
throw (0, errorUtils_1.createCancelledError)('Request cancelled');
|
|
563
610
|
}
|
|
611
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
612
|
+
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
613
|
+
}
|
|
564
614
|
/**
|
|
565
615
|
* Whether OUR timer fired, so a timeout can be told apart from a
|
|
566
616
|
* cancellation. Both reach us from `fetch` as an indistinguishable
|
|
@@ -568,6 +618,7 @@ class HttpService {
|
|
|
568
618
|
* an abandoned request as a transient failure.
|
|
569
619
|
*/
|
|
570
620
|
let timedOut = false;
|
|
621
|
+
let selfClearedEpoch = null;
|
|
571
622
|
// This attempt's controller carries its own timeout and inherits the
|
|
572
623
|
// call-wide abort domain. Declared OUTSIDE the `try` so the `finally`
|
|
573
624
|
// that releases them can see them — the timer and the listener are
|
|
@@ -670,14 +721,27 @@ class HttpService {
|
|
|
670
721
|
// it, including on every throw between here and there.
|
|
671
722
|
if (timeoutId)
|
|
672
723
|
clearTimeout(timeoutId);
|
|
724
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
725
|
+
void discardResponseBody(response);
|
|
726
|
+
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
727
|
+
}
|
|
673
728
|
// Handle response
|
|
674
729
|
if (!response.ok) {
|
|
675
730
|
// On 401, delegate to the installed auth refresh handler and retry
|
|
676
731
|
// once before giving up. HttpService deliberately does not know any
|
|
677
732
|
// session routes; the refresh handler owns session rotation.
|
|
678
|
-
if (response.status === 401 &&
|
|
679
|
-
|
|
680
|
-
|
|
733
|
+
if (response.status === 401 &&
|
|
734
|
+
!config._isAuthRetry &&
|
|
735
|
+
usesSessionAuth &&
|
|
736
|
+
isOriginalSession()) {
|
|
737
|
+
let refreshed = newerRequestToken();
|
|
738
|
+
if (!refreshed) {
|
|
739
|
+
if (domain.aborted)
|
|
740
|
+
throw (0, errorUtils_1.createCancelledError)();
|
|
741
|
+
refreshed = await (0, requestSignal_1.awaitRequestSignal)(this.refreshAccessToken('response-401'), domain);
|
|
742
|
+
}
|
|
743
|
+
refreshed || (refreshed = newerRequestToken());
|
|
744
|
+
if (refreshed && isOriginalSession()) {
|
|
681
745
|
// `deduplicate: false` is REQUIRED on the retry. This re-issue runs
|
|
682
746
|
// while the ORIGINAL request is still in-flight under its dedupe
|
|
683
747
|
// key; the refreshed token is
|
|
@@ -696,11 +760,19 @@ class HttpService {
|
|
|
696
760
|
retry: false,
|
|
697
761
|
deduplicate: false,
|
|
698
762
|
bypassQueue: true,
|
|
763
|
+
// The original caller's signal may be aborted while another
|
|
764
|
+
// deduplicated caller still owns this shared request.
|
|
765
|
+
signal: domain,
|
|
699
766
|
});
|
|
700
767
|
}
|
|
701
768
|
// Refresh failed or no token — clear tokens
|
|
702
|
-
|
|
703
|
-
|
|
769
|
+
if (isOriginalSession()) {
|
|
770
|
+
if (this.tokenStore.getAccessToken())
|
|
771
|
+
this.sessionEpoch += 1;
|
|
772
|
+
this.tokenStore.clearTokens();
|
|
773
|
+
selfClearedEpoch = this.sessionEpoch;
|
|
774
|
+
this.notifyTokenChange();
|
|
775
|
+
}
|
|
704
776
|
}
|
|
705
777
|
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
706
778
|
// Anything unreadable leaves `errorBody` undefined and degrades to the
|
|
@@ -787,6 +859,9 @@ class HttpService {
|
|
|
787
859
|
const text = await response.text();
|
|
788
860
|
responseData = text || null;
|
|
789
861
|
}
|
|
862
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
863
|
+
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
864
|
+
}
|
|
790
865
|
const duration = Date.now() - startTime;
|
|
791
866
|
this.updateMetrics(true, duration);
|
|
792
867
|
this.config.onRequestEnd?.(url, method, duration, true);
|
|
@@ -795,6 +870,21 @@ class HttpService {
|
|
|
795
870
|
catch (error) {
|
|
796
871
|
const duration = Date.now() - startTime;
|
|
797
872
|
this.updateMetrics(false, duration);
|
|
873
|
+
// A body reader can reject or finish parsing an error after the viewer
|
|
874
|
+
// changed. Do not surface that previous viewer's error or callbacks.
|
|
875
|
+
// An explicit abort/timeout retains its existing classification.
|
|
876
|
+
const providerToken = selfClearedEpoch !== null ? this.accessTokenProvider?.() : null;
|
|
877
|
+
const originalClearStillCurrent = selfClearedEpoch !== null &&
|
|
878
|
+
selfClearedEpoch === this.sessionEpoch &&
|
|
879
|
+
this.tokenStore.getAccessToken() === null &&
|
|
880
|
+
(!providerToken || sameTokenSession(contextToken, providerToken));
|
|
881
|
+
if (usesSessionAuth &&
|
|
882
|
+
!originalClearStillCurrent &&
|
|
883
|
+
!isOriginalSession() &&
|
|
884
|
+
!isAbortLike(error) &&
|
|
885
|
+
!controller.signal.aborted) {
|
|
886
|
+
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
887
|
+
}
|
|
798
888
|
this.config.onRequestEnd?.(url, method, duration, false);
|
|
799
889
|
this.config.onRequestError?.(url, method, error instanceof Error ? error : new Error(String(error)));
|
|
800
890
|
// An abort is the one failure whose CAUSE the error cannot carry on its
|
|
@@ -886,6 +976,9 @@ class HttpService {
|
|
|
886
976
|
disposeCallerLink?.();
|
|
887
977
|
}
|
|
888
978
|
}
|
|
979
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
980
|
+
throw new OxyServices_errors_1.OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
981
|
+
}
|
|
889
982
|
// Cache the result if caching is enabled
|
|
890
983
|
if (cache && cacheKey && lease.valid && result) {
|
|
891
984
|
this.cache.set(cacheKey, result, cacheTTL);
|
|
@@ -1049,7 +1142,7 @@ class HttpService {
|
|
|
1049
1142
|
* in `clearCacheEntry`.
|
|
1050
1143
|
*/
|
|
1051
1144
|
generateCacheKey(method, url, data) {
|
|
1052
|
-
return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()}`;
|
|
1145
|
+
return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()} epoch=${this.sessionEpoch}`;
|
|
1053
1146
|
}
|
|
1054
1147
|
/**
|
|
1055
1148
|
* Build the identity-agnostic portion of a cache key (`method:url[:data]`).
|
|
@@ -1173,6 +1266,7 @@ class HttpService {
|
|
|
1173
1266
|
// on the way out would discard the flag it just set.
|
|
1174
1267
|
this.lastRefreshWasRateLimited = false;
|
|
1175
1268
|
const epoch = this.sessionEpoch;
|
|
1269
|
+
const tokenAtStart = this.tokenStore.getAccessToken();
|
|
1176
1270
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1177
1271
|
.then((newToken) => {
|
|
1178
1272
|
if (epoch !== this.sessionEpoch) {
|
|
@@ -1185,6 +1279,12 @@ class HttpService {
|
|
|
1185
1279
|
this.logger.debug('Discarded a token refresh that outlived its session');
|
|
1186
1280
|
return null;
|
|
1187
1281
|
}
|
|
1282
|
+
const installed = this.tokenStore.getAccessToken();
|
|
1283
|
+
if (installed !== tokenAtStart && installed !== newToken) {
|
|
1284
|
+
// Another operation installed a token while this refresh was in
|
|
1285
|
+
// flight. Never overwrite it with this older completion.
|
|
1286
|
+
return installed && sameTokenSession(tokenAtStart, installed) ? installed : null;
|
|
1287
|
+
}
|
|
1188
1288
|
if (!newToken) {
|
|
1189
1289
|
this.lastRefreshFailureAt = Date.now();
|
|
1190
1290
|
return null;
|
|
@@ -1482,7 +1582,7 @@ class HttpService {
|
|
|
1482
1582
|
assertCurrent();
|
|
1483
1583
|
}
|
|
1484
1584
|
catch (error) {
|
|
1485
|
-
await response
|
|
1585
|
+
await discardResponseBody(response);
|
|
1486
1586
|
throw error;
|
|
1487
1587
|
}
|
|
1488
1588
|
const replayable = Boolean(config.bodyFactory) || body === undefined || typeof body === 'string';
|
|
@@ -1496,18 +1596,18 @@ class HttpService {
|
|
|
1496
1596
|
refreshed = await (0, requestSignal_1.awaitRequestSignal)(this.refreshAccessToken('response-401'), config.signal);
|
|
1497
1597
|
}
|
|
1498
1598
|
catch (error) {
|
|
1499
|
-
void response
|
|
1599
|
+
void discardResponseBody(response);
|
|
1500
1600
|
throw error;
|
|
1501
1601
|
}
|
|
1502
1602
|
try {
|
|
1503
1603
|
assertCurrent();
|
|
1504
1604
|
}
|
|
1505
1605
|
catch (error) {
|
|
1506
|
-
await response
|
|
1606
|
+
await discardResponseBody(response);
|
|
1507
1607
|
throw error;
|
|
1508
1608
|
}
|
|
1509
1609
|
if (refreshed) {
|
|
1510
|
-
await response
|
|
1610
|
+
await discardResponseBody(response);
|
|
1511
1611
|
if (config.signal?.aborted)
|
|
1512
1612
|
throw (0, errorUtils_1.createCancelledError)();
|
|
1513
1613
|
return this.requestResponseAttempt(config, fullUrl, `Bearer ${refreshed}`, true, epoch, contextToken);
|
|
@@ -1673,6 +1773,7 @@ class HttpService {
|
|
|
1673
1773
|
peekCachedGet(url, params) {
|
|
1674
1774
|
if (this.cacheDisabled)
|
|
1675
1775
|
return null;
|
|
1776
|
+
this.syncAccessTokenFromProvider();
|
|
1676
1777
|
return this.cache.get(this.generateCacheKey('GET', url, params));
|
|
1677
1778
|
}
|
|
1678
1779
|
clearCache() {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module shared/utils/errorUtils
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = void 0;
|
|
11
|
+
exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.isSessionChangedError = exports.getErrorStatus = exports.HttpStatus = void 0;
|
|
12
12
|
/**
|
|
13
13
|
* Common HTTP status codes used in error checking.
|
|
14
14
|
*/
|
|
@@ -50,6 +50,9 @@ const getErrorStatus = (error) => {
|
|
|
50
50
|
return undefined;
|
|
51
51
|
};
|
|
52
52
|
exports.getErrorStatus = getErrorStatus;
|
|
53
|
+
/** A locally superseded request is not an unauthorized response from the server. */
|
|
54
|
+
const isSessionChangedError = (error) => !!error && typeof error === 'object' && 'code' in error && error.code === 'AUTH_SESSION_CHANGED';
|
|
55
|
+
exports.isSessionChangedError = isSessionChangedError;
|
|
53
56
|
/**
|
|
54
57
|
* Extracts the error message from an error object.
|
|
55
58
|
*
|
|
@@ -104,7 +107,7 @@ exports.isAlreadyRegisteredError = isAlreadyRegisteredError;
|
|
|
104
107
|
* @returns true if the error is a 401 Unauthorized
|
|
105
108
|
*/
|
|
106
109
|
const isUnauthorizedError = (error) => {
|
|
107
|
-
return (0, exports.getErrorStatus)(error) === exports.HttpStatus.UNAUTHORIZED;
|
|
110
|
+
return !(0, exports.isSessionChangedError)(error) && (0, exports.getErrorStatus)(error) === exports.HttpStatus.UNAUTHORIZED;
|
|
108
111
|
};
|
|
109
112
|
exports.isUnauthorizedError = isUnauthorizedError;
|
|
110
113
|
/**
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.retryAsync = retryAsync;
|
|
7
7
|
const errorUtils_1 = require("./errorUtils");
|
|
8
|
+
const errorUtils_2 = require("../shared/utils/errorUtils");
|
|
8
9
|
/**
|
|
9
10
|
* Whether an error came from a request hitting its own timeout.
|
|
10
11
|
*
|
|
@@ -102,6 +103,11 @@ async function retryAsync(operation, maxRetriesOrOptions = 3, baseDelayArg = 100
|
|
|
102
103
|
if ((0, errorUtils_1.isCancelledError)(error)) {
|
|
103
104
|
break;
|
|
104
105
|
}
|
|
106
|
+
// A changed authentication context is a cancellation of this work, even
|
|
107
|
+
// if the caller's custom predicate would retry ordinary 401 responses.
|
|
108
|
+
if ((0, errorUtils_2.isSessionChangedError)(error)) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
105
111
|
if (!retryOnTimeout && isTimeoutError(error)) {
|
|
106
112
|
break;
|
|
107
113
|
}
|
|
@@ -9,6 +9,7 @@ exports.ensureValidToken = ensureValidToken;
|
|
|
9
9
|
exports.isAuthenticationError = isAuthenticationError;
|
|
10
10
|
exports.withAuthErrorHandling = withAuthErrorHandling;
|
|
11
11
|
exports.authenticatedApiCall = authenticatedApiCall;
|
|
12
|
+
const errorUtils_1 = require("../shared/utils/errorUtils");
|
|
12
13
|
/**
|
|
13
14
|
* Error thrown when session sync is required
|
|
14
15
|
*/
|
|
@@ -63,6 +64,8 @@ async function ensureValidToken(oxyServices, _activeSessionId, syncSession) {
|
|
|
63
64
|
* Checks if an error is an authentication error (401 or auth-related message)
|
|
64
65
|
*/
|
|
65
66
|
function isAuthenticationError(error) {
|
|
67
|
+
if ((0, errorUtils_1.isSessionChangedError)(error))
|
|
68
|
+
return false;
|
|
66
69
|
if (!error || typeof error !== 'object') {
|
|
67
70
|
return false;
|
|
68
71
|
}
|
package/dist/esm/HttpService.js
CHANGED
|
@@ -68,6 +68,18 @@ function sameTokenSession(left, right) {
|
|
|
68
68
|
'authMethodId',
|
|
69
69
|
].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
|
|
70
70
|
}
|
|
71
|
+
/** Discard an unused body even when a platform's stream shim lacks cancel. */
|
|
72
|
+
function discardResponseBody(response) {
|
|
73
|
+
try {
|
|
74
|
+
const body = response.body;
|
|
75
|
+
if (typeof body?.cancel !== 'function')
|
|
76
|
+
return Promise.resolve();
|
|
77
|
+
return Promise.resolve(body.cancel()).catch(() => { });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return Promise.resolve();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
71
83
|
/**
|
|
72
84
|
* The keys whose presence beside `data` makes a body a PAGE rather than a
|
|
73
85
|
* payload — see {@link HttpService.unwrapResponse} for why this list is narrow.
|
|
@@ -460,6 +472,13 @@ export class HttpService {
|
|
|
460
472
|
* {@link ResponsePayload}, which makes reading the envelope a compile error.
|
|
461
473
|
*/
|
|
462
474
|
async request(config) {
|
|
475
|
+
if (config.signal?.aborted)
|
|
476
|
+
throw createCancelledError('Request cancelled before it was sent');
|
|
477
|
+
// The provider can change independently of the last request. Synchronize
|
|
478
|
+
// before deriving the cache and deduplication identity, including hits.
|
|
479
|
+
const explicitAuthorization = Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
|
|
480
|
+
if (!config.skipAuth && !explicitAuthorization)
|
|
481
|
+
this.syncAccessTokenFromProvider();
|
|
463
482
|
// Per-call authority is not represented by the instance's session cache
|
|
464
483
|
// key. Never share or retain responses authenticated by explicit headers.
|
|
465
484
|
if (Object.keys(config.headers ?? {}).some((key) => ['authorization', 'x-oxy-user-id'].includes(key.toLowerCase()) ||
|
|
@@ -481,7 +500,8 @@ export class HttpService {
|
|
|
481
500
|
valid: true,
|
|
482
501
|
});
|
|
483
502
|
}
|
|
484
|
-
const key = this.generateCacheKey(config.method, config.url, config.data || config.params)
|
|
503
|
+
const key = this.generateCacheKey(config.method, config.url, config.data || config.params) +
|
|
504
|
+
(config.skipAuth ? ' auth=none' : '');
|
|
485
505
|
let lease = this.readLeases.get(key);
|
|
486
506
|
if (!lease) {
|
|
487
507
|
lease = { key, id: ++this.nextReadLeaseId, valid: true, readers: 0 };
|
|
@@ -511,6 +531,7 @@ export class HttpService {
|
|
|
511
531
|
// overrides any per-request `cache:true`: nothing is read from nor written
|
|
512
532
|
// to the response cache. Request deduplication below is unaffected.
|
|
513
533
|
const cache = cacheRequested && !this.cacheDisabled;
|
|
534
|
+
const cacheSessionEpoch = this.sessionEpoch;
|
|
514
535
|
// Generate cache key (optimized for large objects)
|
|
515
536
|
const cacheKey = cache ? lease.key : null;
|
|
516
537
|
// Check cache first
|
|
@@ -535,7 +556,33 @@ export class HttpService {
|
|
|
535
556
|
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
536
557
|
// 401 retry re-enters request() with a fresh config, so it re-resolves
|
|
537
558
|
// this here with the refreshed token.
|
|
538
|
-
const
|
|
559
|
+
const explicitAuth = Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
|
|
560
|
+
const usesSessionAuth = !config.skipAuth && !explicitAuth;
|
|
561
|
+
const contextToken = usesSessionAuth ? this.syncAccessTokenFromProvider() : null;
|
|
562
|
+
const contextEpoch = this.sessionEpoch;
|
|
563
|
+
const authHeader = usesSessionAuth
|
|
564
|
+
? await awaitRequestSignal(this.getAuthHeader(), signal)
|
|
565
|
+
: null;
|
|
566
|
+
const isOriginalSession = () => this.sessionEpoch === contextEpoch &&
|
|
567
|
+
sameTokenSession(contextToken, this.syncAccessTokenFromProvider());
|
|
568
|
+
const newerRequestToken = () => {
|
|
569
|
+
if (!contextToken || !authHeader?.startsWith('Bearer '))
|
|
570
|
+
return null;
|
|
571
|
+
const requestToken = authHeader.slice('Bearer '.length);
|
|
572
|
+
const current = this.syncAccessTokenFromProvider();
|
|
573
|
+
return current && current !== requestToken && sameTokenSession(requestToken, current)
|
|
574
|
+
? current
|
|
575
|
+
: null;
|
|
576
|
+
};
|
|
577
|
+
// The provider can change between cache-key selection and auth preflight.
|
|
578
|
+
// Refuse to attach a response to a lease belonging to another identity.
|
|
579
|
+
if (usesSessionAuth &&
|
|
580
|
+
(!isOriginalSession() ||
|
|
581
|
+
(lease.key &&
|
|
582
|
+
(cacheSessionEpoch !== this.sessionEpoch ||
|
|
583
|
+
lease.key !== this.generateCacheKey(method, url, data || params))))) {
|
|
584
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
585
|
+
}
|
|
539
586
|
// Never awaited: the PoP is a hint, and waiting on its trace fetch put up
|
|
540
587
|
// to a second in front of requests.
|
|
541
588
|
const edgeRegionHeader = peekBrowserEdgeRegionHeader();
|
|
@@ -558,6 +605,9 @@ export class HttpService {
|
|
|
558
605
|
if (domain.aborted) {
|
|
559
606
|
throw createCancelledError('Request cancelled');
|
|
560
607
|
}
|
|
608
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
609
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
610
|
+
}
|
|
561
611
|
/**
|
|
562
612
|
* Whether OUR timer fired, so a timeout can be told apart from a
|
|
563
613
|
* cancellation. Both reach us from `fetch` as an indistinguishable
|
|
@@ -565,6 +615,7 @@ export class HttpService {
|
|
|
565
615
|
* an abandoned request as a transient failure.
|
|
566
616
|
*/
|
|
567
617
|
let timedOut = false;
|
|
618
|
+
let selfClearedEpoch = null;
|
|
568
619
|
// This attempt's controller carries its own timeout and inherits the
|
|
569
620
|
// call-wide abort domain. Declared OUTSIDE the `try` so the `finally`
|
|
570
621
|
// that releases them can see them — the timer and the listener are
|
|
@@ -667,14 +718,27 @@ export class HttpService {
|
|
|
667
718
|
// it, including on every throw between here and there.
|
|
668
719
|
if (timeoutId)
|
|
669
720
|
clearTimeout(timeoutId);
|
|
721
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
722
|
+
void discardResponseBody(response);
|
|
723
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
724
|
+
}
|
|
670
725
|
// Handle response
|
|
671
726
|
if (!response.ok) {
|
|
672
727
|
// On 401, delegate to the installed auth refresh handler and retry
|
|
673
728
|
// once before giving up. HttpService deliberately does not know any
|
|
674
729
|
// session routes; the refresh handler owns session rotation.
|
|
675
|
-
if (response.status === 401 &&
|
|
676
|
-
|
|
677
|
-
|
|
730
|
+
if (response.status === 401 &&
|
|
731
|
+
!config._isAuthRetry &&
|
|
732
|
+
usesSessionAuth &&
|
|
733
|
+
isOriginalSession()) {
|
|
734
|
+
let refreshed = newerRequestToken();
|
|
735
|
+
if (!refreshed) {
|
|
736
|
+
if (domain.aborted)
|
|
737
|
+
throw createCancelledError();
|
|
738
|
+
refreshed = await awaitRequestSignal(this.refreshAccessToken('response-401'), domain);
|
|
739
|
+
}
|
|
740
|
+
refreshed || (refreshed = newerRequestToken());
|
|
741
|
+
if (refreshed && isOriginalSession()) {
|
|
678
742
|
// `deduplicate: false` is REQUIRED on the retry. This re-issue runs
|
|
679
743
|
// while the ORIGINAL request is still in-flight under its dedupe
|
|
680
744
|
// key; the refreshed token is
|
|
@@ -693,11 +757,19 @@ export class HttpService {
|
|
|
693
757
|
retry: false,
|
|
694
758
|
deduplicate: false,
|
|
695
759
|
bypassQueue: true,
|
|
760
|
+
// The original caller's signal may be aborted while another
|
|
761
|
+
// deduplicated caller still owns this shared request.
|
|
762
|
+
signal: domain,
|
|
696
763
|
});
|
|
697
764
|
}
|
|
698
765
|
// Refresh failed or no token — clear tokens
|
|
699
|
-
|
|
700
|
-
|
|
766
|
+
if (isOriginalSession()) {
|
|
767
|
+
if (this.tokenStore.getAccessToken())
|
|
768
|
+
this.sessionEpoch += 1;
|
|
769
|
+
this.tokenStore.clearTokens();
|
|
770
|
+
selfClearedEpoch = this.sessionEpoch;
|
|
771
|
+
this.notifyTokenChange();
|
|
772
|
+
}
|
|
701
773
|
}
|
|
702
774
|
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
703
775
|
// Anything unreadable leaves `errorBody` undefined and degrades to the
|
|
@@ -784,6 +856,9 @@ export class HttpService {
|
|
|
784
856
|
const text = await response.text();
|
|
785
857
|
responseData = text || null;
|
|
786
858
|
}
|
|
859
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
860
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
861
|
+
}
|
|
787
862
|
const duration = Date.now() - startTime;
|
|
788
863
|
this.updateMetrics(true, duration);
|
|
789
864
|
this.config.onRequestEnd?.(url, method, duration, true);
|
|
@@ -792,6 +867,21 @@ export class HttpService {
|
|
|
792
867
|
catch (error) {
|
|
793
868
|
const duration = Date.now() - startTime;
|
|
794
869
|
this.updateMetrics(false, duration);
|
|
870
|
+
// A body reader can reject or finish parsing an error after the viewer
|
|
871
|
+
// changed. Do not surface that previous viewer's error or callbacks.
|
|
872
|
+
// An explicit abort/timeout retains its existing classification.
|
|
873
|
+
const providerToken = selfClearedEpoch !== null ? this.accessTokenProvider?.() : null;
|
|
874
|
+
const originalClearStillCurrent = selfClearedEpoch !== null &&
|
|
875
|
+
selfClearedEpoch === this.sessionEpoch &&
|
|
876
|
+
this.tokenStore.getAccessToken() === null &&
|
|
877
|
+
(!providerToken || sameTokenSession(contextToken, providerToken));
|
|
878
|
+
if (usesSessionAuth &&
|
|
879
|
+
!originalClearStillCurrent &&
|
|
880
|
+
!isOriginalSession() &&
|
|
881
|
+
!isAbortLike(error) &&
|
|
882
|
+
!controller.signal.aborted) {
|
|
883
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
884
|
+
}
|
|
795
885
|
this.config.onRequestEnd?.(url, method, duration, false);
|
|
796
886
|
this.config.onRequestError?.(url, method, error instanceof Error ? error : new Error(String(error)));
|
|
797
887
|
// An abort is the one failure whose CAUSE the error cannot carry on its
|
|
@@ -883,6 +973,9 @@ export class HttpService {
|
|
|
883
973
|
disposeCallerLink?.();
|
|
884
974
|
}
|
|
885
975
|
}
|
|
976
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
977
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
978
|
+
}
|
|
886
979
|
// Cache the result if caching is enabled
|
|
887
980
|
if (cache && cacheKey && lease.valid && result) {
|
|
888
981
|
this.cache.set(cacheKey, result, cacheTTL);
|
|
@@ -1046,7 +1139,7 @@ export class HttpService {
|
|
|
1046
1139
|
* in `clearCacheEntry`.
|
|
1047
1140
|
*/
|
|
1048
1141
|
generateCacheKey(method, url, data) {
|
|
1049
|
-
return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()}`;
|
|
1142
|
+
return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()} epoch=${this.sessionEpoch}`;
|
|
1050
1143
|
}
|
|
1051
1144
|
/**
|
|
1052
1145
|
* Build the identity-agnostic portion of a cache key (`method:url[:data]`).
|
|
@@ -1170,6 +1263,7 @@ export class HttpService {
|
|
|
1170
1263
|
// on the way out would discard the flag it just set.
|
|
1171
1264
|
this.lastRefreshWasRateLimited = false;
|
|
1172
1265
|
const epoch = this.sessionEpoch;
|
|
1266
|
+
const tokenAtStart = this.tokenStore.getAccessToken();
|
|
1173
1267
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1174
1268
|
.then((newToken) => {
|
|
1175
1269
|
if (epoch !== this.sessionEpoch) {
|
|
@@ -1182,6 +1276,12 @@ export class HttpService {
|
|
|
1182
1276
|
this.logger.debug('Discarded a token refresh that outlived its session');
|
|
1183
1277
|
return null;
|
|
1184
1278
|
}
|
|
1279
|
+
const installed = this.tokenStore.getAccessToken();
|
|
1280
|
+
if (installed !== tokenAtStart && installed !== newToken) {
|
|
1281
|
+
// Another operation installed a token while this refresh was in
|
|
1282
|
+
// flight. Never overwrite it with this older completion.
|
|
1283
|
+
return installed && sameTokenSession(tokenAtStart, installed) ? installed : null;
|
|
1284
|
+
}
|
|
1185
1285
|
if (!newToken) {
|
|
1186
1286
|
this.lastRefreshFailureAt = Date.now();
|
|
1187
1287
|
return null;
|
|
@@ -1479,7 +1579,7 @@ export class HttpService {
|
|
|
1479
1579
|
assertCurrent();
|
|
1480
1580
|
}
|
|
1481
1581
|
catch (error) {
|
|
1482
|
-
await response
|
|
1582
|
+
await discardResponseBody(response);
|
|
1483
1583
|
throw error;
|
|
1484
1584
|
}
|
|
1485
1585
|
const replayable = Boolean(config.bodyFactory) || body === undefined || typeof body === 'string';
|
|
@@ -1493,18 +1593,18 @@ export class HttpService {
|
|
|
1493
1593
|
refreshed = await awaitRequestSignal(this.refreshAccessToken('response-401'), config.signal);
|
|
1494
1594
|
}
|
|
1495
1595
|
catch (error) {
|
|
1496
|
-
void response
|
|
1596
|
+
void discardResponseBody(response);
|
|
1497
1597
|
throw error;
|
|
1498
1598
|
}
|
|
1499
1599
|
try {
|
|
1500
1600
|
assertCurrent();
|
|
1501
1601
|
}
|
|
1502
1602
|
catch (error) {
|
|
1503
|
-
await response
|
|
1603
|
+
await discardResponseBody(response);
|
|
1504
1604
|
throw error;
|
|
1505
1605
|
}
|
|
1506
1606
|
if (refreshed) {
|
|
1507
|
-
await response
|
|
1607
|
+
await discardResponseBody(response);
|
|
1508
1608
|
if (config.signal?.aborted)
|
|
1509
1609
|
throw createCancelledError();
|
|
1510
1610
|
return this.requestResponseAttempt(config, fullUrl, `Bearer ${refreshed}`, true, epoch, contextToken);
|
|
@@ -1670,6 +1770,7 @@ export class HttpService {
|
|
|
1670
1770
|
peekCachedGet(url, params) {
|
|
1671
1771
|
if (this.cacheDisabled)
|
|
1672
1772
|
return null;
|
|
1773
|
+
this.syncAccessTokenFromProvider();
|
|
1673
1774
|
return this.cache.get(this.generateCacheKey('GET', url, params));
|
|
1674
1775
|
}
|
|
1675
1776
|
clearCache() {
|
|
@@ -46,6 +46,8 @@ export const getErrorStatus = (error) => {
|
|
|
46
46
|
return err.statusCode;
|
|
47
47
|
return undefined;
|
|
48
48
|
};
|
|
49
|
+
/** A locally superseded request is not an unauthorized response from the server. */
|
|
50
|
+
export const isSessionChangedError = (error) => !!error && typeof error === 'object' && 'code' in error && error.code === 'AUTH_SESSION_CHANGED';
|
|
49
51
|
/**
|
|
50
52
|
* Extracts the error message from an error object.
|
|
51
53
|
*
|
|
@@ -98,7 +100,7 @@ export const isAlreadyRegisteredError = (error) => {
|
|
|
98
100
|
* @returns true if the error is a 401 Unauthorized
|
|
99
101
|
*/
|
|
100
102
|
export const isUnauthorizedError = (error) => {
|
|
101
|
-
return getErrorStatus(error) === HttpStatus.UNAUTHORIZED;
|
|
103
|
+
return !isSessionChangedError(error) && getErrorStatus(error) === HttpStatus.UNAUTHORIZED;
|
|
102
104
|
};
|
|
103
105
|
/**
|
|
104
106
|
* Check if an error is a forbidden error (HTTP 403).
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Retry with exponential backoff and jitter — the policy `HttpService` uses.
|
|
3
3
|
*/
|
|
4
4
|
import { ErrorCodes, isCancelledError } from './errorUtils.js';
|
|
5
|
+
import { isSessionChangedError } from '../shared/utils/errorUtils.js';
|
|
5
6
|
/**
|
|
6
7
|
* Whether an error came from a request hitting its own timeout.
|
|
7
8
|
*
|
|
@@ -99,6 +100,11 @@ export async function retryAsync(operation, maxRetriesOrOptions = 3, baseDelayAr
|
|
|
99
100
|
if (isCancelledError(error)) {
|
|
100
101
|
break;
|
|
101
102
|
}
|
|
103
|
+
// A changed authentication context is a cancellation of this work, even
|
|
104
|
+
// if the caller's custom predicate would retry ordinary 401 responses.
|
|
105
|
+
if (isSessionChangedError(error)) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
102
108
|
if (!retryOnTimeout && isTimeoutError(error)) {
|
|
103
109
|
break;
|
|
104
110
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Authentication helper utilities for common token validation
|
|
3
3
|
* and authentication error handling patterns.
|
|
4
4
|
*/
|
|
5
|
+
import { isSessionChangedError } from '../shared/utils/errorUtils.js';
|
|
5
6
|
/**
|
|
6
7
|
* Error thrown when session sync is required
|
|
7
8
|
*/
|
|
@@ -54,6 +55,8 @@ export async function ensureValidToken(oxyServices, _activeSessionId, syncSessio
|
|
|
54
55
|
* Checks if an error is an authentication error (401 or auth-related message)
|
|
55
56
|
*/
|
|
56
57
|
export function isAuthenticationError(error) {
|
|
58
|
+
if (isSessionChangedError(error))
|
|
59
|
+
return false;
|
|
57
60
|
if (!error || typeof error !== 'object') {
|
|
58
61
|
return false;
|
|
59
62
|
}
|
|
@@ -29,6 +29,8 @@ export declare const HttpStatus: {
|
|
|
29
29
|
* @returns The status code if found, undefined otherwise
|
|
30
30
|
*/
|
|
31
31
|
export declare const getErrorStatus: (error: unknown) => number | undefined;
|
|
32
|
+
/** A locally superseded request is not an unauthorized response from the server. */
|
|
33
|
+
export declare const isSessionChangedError: (error: unknown) => boolean;
|
|
32
34
|
/**
|
|
33
35
|
* Extracts the error message from an error object.
|
|
34
36
|
*
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -88,6 +88,17 @@ function sameTokenSession(left: string | null, right: string | null): boolean {
|
|
|
88
88
|
].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** Discard an unused body even when a platform's stream shim lacks cancel. */
|
|
92
|
+
function discardResponseBody(response: Response): Promise<void> {
|
|
93
|
+
try {
|
|
94
|
+
const body = response.body;
|
|
95
|
+
if (typeof body?.cancel !== 'function') return Promise.resolve();
|
|
96
|
+
return Promise.resolve(body.cancel()).catch(() => {});
|
|
97
|
+
} catch {
|
|
98
|
+
return Promise.resolve();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
91
102
|
/** A trusted platform transport, for example Expo's streaming fetch. */
|
|
92
103
|
export type ResponseTransport = (input: string, init: RequestInit) => Promise<Response>;
|
|
93
104
|
|
|
@@ -695,6 +706,13 @@ export class HttpService {
|
|
|
695
706
|
* {@link ResponsePayload}, which makes reading the envelope a compile error.
|
|
696
707
|
*/
|
|
697
708
|
async request<T = unknown>(config: RequestConfig): Promise<ResponsePayload<T>> {
|
|
709
|
+
if (config.signal?.aborted) throw createCancelledError('Request cancelled before it was sent');
|
|
710
|
+
// The provider can change independently of the last request. Synchronize
|
|
711
|
+
// before deriving the cache and deduplication identity, including hits.
|
|
712
|
+
const explicitAuthorization = Object.keys(config.headers ?? {}).some(
|
|
713
|
+
(key) => key.toLowerCase() === 'authorization',
|
|
714
|
+
);
|
|
715
|
+
if (!config.skipAuth && !explicitAuthorization) this.syncAccessTokenFromProvider();
|
|
698
716
|
// Per-call authority is not represented by the instance's session cache
|
|
699
717
|
// key. Never share or retain responses authenticated by explicit headers.
|
|
700
718
|
if (
|
|
@@ -725,7 +743,9 @@ export class HttpService {
|
|
|
725
743
|
valid: true,
|
|
726
744
|
});
|
|
727
745
|
}
|
|
728
|
-
const key =
|
|
746
|
+
const key =
|
|
747
|
+
this.generateCacheKey(config.method, config.url, config.data || config.params) +
|
|
748
|
+
(config.skipAuth ? ' auth=none' : '');
|
|
729
749
|
let lease = this.readLeases.get(key);
|
|
730
750
|
if (!lease) {
|
|
731
751
|
lease = { key, id: ++this.nextReadLeaseId, valid: true, readers: 0 };
|
|
@@ -768,6 +788,7 @@ export class HttpService {
|
|
|
768
788
|
// overrides any per-request `cache:true`: nothing is read from nor written
|
|
769
789
|
// to the response cache. Request deduplication below is unaffected.
|
|
770
790
|
const cache = cacheRequested && !this.cacheDisabled;
|
|
791
|
+
const cacheSessionEpoch = this.sessionEpoch;
|
|
771
792
|
|
|
772
793
|
// Generate cache key (optimized for large objects)
|
|
773
794
|
const cacheKey = cache ? lease.key : null;
|
|
@@ -795,7 +816,37 @@ export class HttpService {
|
|
|
795
816
|
// and skip the near-expiry preflight — see RequestOptions.skipAuth. The
|
|
796
817
|
// 401 retry re-enters request() with a fresh config, so it re-resolves
|
|
797
818
|
// this here with the refreshed token.
|
|
798
|
-
const
|
|
819
|
+
const explicitAuth = Object.keys(config.headers ?? {}).some(
|
|
820
|
+
(key) => key.toLowerCase() === 'authorization',
|
|
821
|
+
);
|
|
822
|
+
const usesSessionAuth = !config.skipAuth && !explicitAuth;
|
|
823
|
+
const contextToken = usesSessionAuth ? this.syncAccessTokenFromProvider() : null;
|
|
824
|
+
const contextEpoch = this.sessionEpoch;
|
|
825
|
+
const authHeader = usesSessionAuth
|
|
826
|
+
? await awaitRequestSignal(this.getAuthHeader(), signal)
|
|
827
|
+
: null;
|
|
828
|
+
const isOriginalSession = () =>
|
|
829
|
+
this.sessionEpoch === contextEpoch &&
|
|
830
|
+
sameTokenSession(contextToken, this.syncAccessTokenFromProvider());
|
|
831
|
+
const newerRequestToken = () => {
|
|
832
|
+
if (!contextToken || !authHeader?.startsWith('Bearer ')) return null;
|
|
833
|
+
const requestToken = authHeader.slice('Bearer '.length);
|
|
834
|
+
const current = this.syncAccessTokenFromProvider();
|
|
835
|
+
return current && current !== requestToken && sameTokenSession(requestToken, current)
|
|
836
|
+
? current
|
|
837
|
+
: null;
|
|
838
|
+
};
|
|
839
|
+
// The provider can change between cache-key selection and auth preflight.
|
|
840
|
+
// Refuse to attach a response to a lease belonging to another identity.
|
|
841
|
+
if (
|
|
842
|
+
usesSessionAuth &&
|
|
843
|
+
(!isOriginalSession() ||
|
|
844
|
+
(lease.key &&
|
|
845
|
+
(cacheSessionEpoch !== this.sessionEpoch ||
|
|
846
|
+
lease.key !== this.generateCacheKey(method, url, data || params))))
|
|
847
|
+
) {
|
|
848
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
849
|
+
}
|
|
799
850
|
// Never awaited: the PoP is a hint, and waiting on its trace fetch put up
|
|
800
851
|
// to a second in front of requests.
|
|
801
852
|
const edgeRegionHeader = peekBrowserEdgeRegionHeader();
|
|
@@ -820,6 +871,9 @@ export class HttpService {
|
|
|
820
871
|
if (domain.aborted) {
|
|
821
872
|
throw createCancelledError('Request cancelled');
|
|
822
873
|
}
|
|
874
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
875
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
876
|
+
}
|
|
823
877
|
|
|
824
878
|
/**
|
|
825
879
|
* Whether OUR timer fired, so a timeout can be told apart from a
|
|
@@ -828,6 +882,7 @@ export class HttpService {
|
|
|
828
882
|
* an abandoned request as a transient failure.
|
|
829
883
|
*/
|
|
830
884
|
let timedOut = false;
|
|
885
|
+
let selfClearedEpoch: number | null = null;
|
|
831
886
|
|
|
832
887
|
// This attempt's controller carries its own timeout and inherits the
|
|
833
888
|
// call-wide abort domain. Declared OUTSIDE the `try` so the `finally`
|
|
@@ -955,14 +1010,29 @@ export class HttpService {
|
|
|
955
1010
|
// it, including on every throw between here and there.
|
|
956
1011
|
if (timeoutId) clearTimeout(timeoutId);
|
|
957
1012
|
|
|
1013
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
1014
|
+
void discardResponseBody(response);
|
|
1015
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1016
|
+
}
|
|
1017
|
+
|
|
958
1018
|
// Handle response
|
|
959
1019
|
if (!response.ok) {
|
|
960
1020
|
// On 401, delegate to the installed auth refresh handler and retry
|
|
961
1021
|
// once before giving up. HttpService deliberately does not know any
|
|
962
1022
|
// session routes; the refresh handler owns session rotation.
|
|
963
|
-
if (
|
|
964
|
-
|
|
965
|
-
|
|
1023
|
+
if (
|
|
1024
|
+
response.status === 401 &&
|
|
1025
|
+
!config._isAuthRetry &&
|
|
1026
|
+
usesSessionAuth &&
|
|
1027
|
+
isOriginalSession()
|
|
1028
|
+
) {
|
|
1029
|
+
let refreshed = newerRequestToken();
|
|
1030
|
+
if (!refreshed) {
|
|
1031
|
+
if (domain.aborted) throw createCancelledError();
|
|
1032
|
+
refreshed = await awaitRequestSignal(this.refreshAccessToken('response-401'), domain);
|
|
1033
|
+
}
|
|
1034
|
+
refreshed ||= newerRequestToken();
|
|
1035
|
+
if (refreshed && isOriginalSession()) {
|
|
966
1036
|
// `deduplicate: false` is REQUIRED on the retry. This re-issue runs
|
|
967
1037
|
// while the ORIGINAL request is still in-flight under its dedupe
|
|
968
1038
|
// key; the refreshed token is
|
|
@@ -981,11 +1051,18 @@ export class HttpService {
|
|
|
981
1051
|
retry: false,
|
|
982
1052
|
deduplicate: false,
|
|
983
1053
|
bypassQueue: true,
|
|
1054
|
+
// The original caller's signal may be aborted while another
|
|
1055
|
+
// deduplicated caller still owns this shared request.
|
|
1056
|
+
signal: domain,
|
|
984
1057
|
});
|
|
985
1058
|
}
|
|
986
1059
|
// Refresh failed or no token — clear tokens
|
|
987
|
-
|
|
988
|
-
|
|
1060
|
+
if (isOriginalSession()) {
|
|
1061
|
+
if (this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
|
|
1062
|
+
this.tokenStore.clearTokens();
|
|
1063
|
+
selfClearedEpoch = this.sessionEpoch;
|
|
1064
|
+
this.notifyTokenChange();
|
|
1065
|
+
}
|
|
989
1066
|
}
|
|
990
1067
|
|
|
991
1068
|
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
@@ -1079,6 +1156,9 @@ export class HttpService {
|
|
|
1079
1156
|
responseData = text || null;
|
|
1080
1157
|
}
|
|
1081
1158
|
|
|
1159
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
1160
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1161
|
+
}
|
|
1082
1162
|
const duration = Date.now() - startTime;
|
|
1083
1163
|
this.updateMetrics(true, duration);
|
|
1084
1164
|
this.config.onRequestEnd?.(url, method, duration, true);
|
|
@@ -1087,6 +1167,24 @@ export class HttpService {
|
|
|
1087
1167
|
} catch (error: unknown) {
|
|
1088
1168
|
const duration = Date.now() - startTime;
|
|
1089
1169
|
this.updateMetrics(false, duration);
|
|
1170
|
+
// A body reader can reject or finish parsing an error after the viewer
|
|
1171
|
+
// changed. Do not surface that previous viewer's error or callbacks.
|
|
1172
|
+
// An explicit abort/timeout retains its existing classification.
|
|
1173
|
+
const providerToken = selfClearedEpoch !== null ? this.accessTokenProvider?.() : null;
|
|
1174
|
+
const originalClearStillCurrent =
|
|
1175
|
+
selfClearedEpoch !== null &&
|
|
1176
|
+
selfClearedEpoch === this.sessionEpoch &&
|
|
1177
|
+
this.tokenStore.getAccessToken() === null &&
|
|
1178
|
+
(!providerToken || sameTokenSession(contextToken, providerToken));
|
|
1179
|
+
if (
|
|
1180
|
+
usesSessionAuth &&
|
|
1181
|
+
!originalClearStillCurrent &&
|
|
1182
|
+
!isOriginalSession() &&
|
|
1183
|
+
!isAbortLike(error) &&
|
|
1184
|
+
!controller.signal.aborted
|
|
1185
|
+
) {
|
|
1186
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1187
|
+
}
|
|
1090
1188
|
this.config.onRequestEnd?.(url, method, duration, false);
|
|
1091
1189
|
this.config.onRequestError?.(
|
|
1092
1190
|
url,
|
|
@@ -1188,6 +1286,10 @@ export class HttpService {
|
|
|
1188
1286
|
}
|
|
1189
1287
|
}
|
|
1190
1288
|
|
|
1289
|
+
if (usesSessionAuth && !isOriginalSession()) {
|
|
1290
|
+
throw new OxyAuthenticationError('The request session changed', 'AUTH_SESSION_CHANGED');
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1191
1293
|
// Cache the result if caching is enabled
|
|
1192
1294
|
if (cache && cacheKey && lease.valid && result) {
|
|
1193
1295
|
this.cache.set(cacheKey, result, cacheTTL);
|
|
@@ -1382,7 +1484,7 @@ export class HttpService {
|
|
|
1382
1484
|
* in `clearCacheEntry`.
|
|
1383
1485
|
*/
|
|
1384
1486
|
private generateCacheKey(method: string, url: string, data?: unknown): string {
|
|
1385
|
-
return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()}`;
|
|
1487
|
+
return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()} epoch=${this.sessionEpoch}`;
|
|
1386
1488
|
}
|
|
1387
1489
|
|
|
1388
1490
|
/**
|
|
@@ -1516,6 +1618,7 @@ export class HttpService {
|
|
|
1516
1618
|
// on the way out would discard the flag it just set.
|
|
1517
1619
|
this.lastRefreshWasRateLimited = false;
|
|
1518
1620
|
const epoch = this.sessionEpoch;
|
|
1621
|
+
const tokenAtStart = this.tokenStore.getAccessToken();
|
|
1519
1622
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1520
1623
|
.then((newToken) => {
|
|
1521
1624
|
if (epoch !== this.sessionEpoch) {
|
|
@@ -1528,6 +1631,12 @@ export class HttpService {
|
|
|
1528
1631
|
this.logger.debug('Discarded a token refresh that outlived its session');
|
|
1529
1632
|
return null;
|
|
1530
1633
|
}
|
|
1634
|
+
const installed = this.tokenStore.getAccessToken();
|
|
1635
|
+
if (installed !== tokenAtStart && installed !== newToken) {
|
|
1636
|
+
// Another operation installed a token while this refresh was in
|
|
1637
|
+
// flight. Never overwrite it with this older completion.
|
|
1638
|
+
return installed && sameTokenSession(tokenAtStart, installed) ? installed : null;
|
|
1639
|
+
}
|
|
1531
1640
|
if (!newToken) {
|
|
1532
1641
|
this.lastRefreshFailureAt = Date.now();
|
|
1533
1642
|
return null;
|
|
@@ -1865,7 +1974,7 @@ export class HttpService {
|
|
|
1865
1974
|
try {
|
|
1866
1975
|
assertCurrent();
|
|
1867
1976
|
} catch (error) {
|
|
1868
|
-
await response
|
|
1977
|
+
await discardResponseBody(response);
|
|
1869
1978
|
throw error;
|
|
1870
1979
|
}
|
|
1871
1980
|
const replayable =
|
|
@@ -1884,17 +1993,17 @@ export class HttpService {
|
|
|
1884
1993
|
config.signal,
|
|
1885
1994
|
);
|
|
1886
1995
|
} catch (error) {
|
|
1887
|
-
void response
|
|
1996
|
+
void discardResponseBody(response);
|
|
1888
1997
|
throw error;
|
|
1889
1998
|
}
|
|
1890
1999
|
try {
|
|
1891
2000
|
assertCurrent();
|
|
1892
2001
|
} catch (error) {
|
|
1893
|
-
await response
|
|
2002
|
+
await discardResponseBody(response);
|
|
1894
2003
|
throw error;
|
|
1895
2004
|
}
|
|
1896
2005
|
if (refreshed) {
|
|
1897
|
-
await response
|
|
2006
|
+
await discardResponseBody(response);
|
|
1898
2007
|
if (config.signal?.aborted) throw createCancelledError();
|
|
1899
2008
|
return this.requestResponseAttempt(
|
|
1900
2009
|
config,
|
|
@@ -2099,6 +2208,7 @@ export class HttpService {
|
|
|
2099
2208
|
*/
|
|
2100
2209
|
peekCachedGet<T>(url: string, params?: Record<string, unknown>): T | null {
|
|
2101
2210
|
if (this.cacheDisabled) return null;
|
|
2211
|
+
this.syncAccessTokenFromProvider();
|
|
2102
2212
|
return this.cache.get(this.generateCacheKey('GET', url, params)) as T | null;
|
|
2103
2213
|
}
|
|
2104
2214
|
|
|
@@ -50,6 +50,10 @@ export const getErrorStatus = (error: unknown): number | undefined => {
|
|
|
50
50
|
return undefined;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
/** A locally superseded request is not an unauthorized response from the server. */
|
|
54
|
+
export const isSessionChangedError = (error: unknown): boolean =>
|
|
55
|
+
!!error && typeof error === 'object' && 'code' in error && error.code === 'AUTH_SESSION_CHANGED';
|
|
56
|
+
|
|
53
57
|
/**
|
|
54
58
|
* Extracts the error message from an error object.
|
|
55
59
|
*
|
|
@@ -103,7 +107,7 @@ export const isAlreadyRegisteredError = (error: unknown): boolean => {
|
|
|
103
107
|
* @returns true if the error is a 401 Unauthorized
|
|
104
108
|
*/
|
|
105
109
|
export const isUnauthorizedError = (error: unknown): boolean => {
|
|
106
|
-
return getErrorStatus(error) === HttpStatus.UNAUTHORIZED;
|
|
110
|
+
return !isSessionChangedError(error) && getErrorStatus(error) === HttpStatus.UNAUTHORIZED;
|
|
107
111
|
};
|
|
108
112
|
|
|
109
113
|
/**
|
package/src/utils/asyncUtils.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { ErrorCodes, isCancelledError } from './errorUtils';
|
|
6
|
+
import { isSessionChangedError } from '../shared/utils/errorUtils';
|
|
6
7
|
|
|
7
8
|
/** Options for {@link retryAsync}. */
|
|
8
9
|
export interface RetryOptions {
|
|
@@ -146,6 +147,12 @@ export async function retryAsync<T>(
|
|
|
146
147
|
break;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
// A changed authentication context is a cancellation of this work, even
|
|
151
|
+
// if the caller's custom predicate would retry ordinary 401 responses.
|
|
152
|
+
if (isSessionChangedError(error)) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
149
156
|
if (!retryOnTimeout && isTimeoutError(error)) {
|
|
150
157
|
break;
|
|
151
158
|
}
|
package/src/utils/authHelpers.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { OxyServices } from '../OxyServices';
|
|
7
|
+
import { isSessionChangedError } from '../shared/utils/errorUtils';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Error thrown when session sync is required
|
|
@@ -80,6 +81,7 @@ export interface HandleApiErrorOptions {
|
|
|
80
81
|
* Checks if an error is an authentication error (401 or auth-related message)
|
|
81
82
|
*/
|
|
82
83
|
export function isAuthenticationError(error: unknown): boolean {
|
|
84
|
+
if (isSessionChangedError(error)) return false;
|
|
83
85
|
if (!error || typeof error !== 'object') {
|
|
84
86
|
return false;
|
|
85
87
|
}
|