@oxy.so/core 7.8.1 → 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.
@@ -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 authHeader = config.skipAuth ? null : await this.getAuthHeader();
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 && !config._isAuthRetry && !config.skipAuth) {
679
- const refreshed = await this.refreshAccessToken('response-401');
680
- if (refreshed) {
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
- this.tokenStore.clearTokens();
703
- this.notifyTokenChange();
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.body?.cancel();
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.body?.cancel().catch(() => undefined);
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.body?.cancel();
1606
+ await discardResponseBody(response);
1507
1607
  throw error;
1508
1608
  }
1509
1609
  if (refreshed) {
1510
- await response.body?.cancel();
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() {
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deviceComparisonCode = deviceComparisonCode;
4
+ /**
5
+ * The code a person compares between a device with no keyboard (a TV, a photo
6
+ * frame) and the phone approving its sign-in: `CE7F-B31F`.
7
+ *
8
+ * The device's QR carries the request's full `authorizeCode` (16 random bytes,
9
+ * hex), and that alone is what approves. Its first eight characters are
10
+ * enough to tell "the request on this screen" from any other: the server draws
11
+ * the code at random, so a look-alike request would take about four billion
12
+ * rate-limited attempts, while thirty-two characters were too many for anyone
13
+ * to compare. Both sides (`OxyDeviceSignIn` on the device and the approval
14
+ * page on the phone) derive it here, so they always print the same thing.
15
+ */
16
+ const COMPARISON_LENGTH = 8;
17
+ const GROUP_LENGTH = 4;
18
+ function deviceComparisonCode(authorizeCode) {
19
+ const head = authorizeCode.slice(0, COMPARISON_LENGTH).toUpperCase();
20
+ return `${head.slice(0, GROUP_LENGTH)}-${head.slice(GROUP_LENGTH)}`;
21
+ }
@@ -8,7 +8,7 @@
8
8
  * the API with a token it already has never needs it.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.runColdBoot = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = exports.createNativeIdentityPinStore = exports.createWebIdentityPinStore = exports.readLocalDeviceCredential = exports.publishProvenDeviceCredential = exports.normalizeSharedDeviceSessionRead = exports.decideSharedDevicePublish = exports.decideSharedDeviceJoin = exports.createSharedMirroringAuthStateStore = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.resolveAccountDelegationAccess = exports.canSwitchIntoAccount = exports.isSwitchTargetAccount = exports.showsPrincipalHeaders = exports.buildSwitcherRows = exports.resolveDeviceContext = exports.resolveActiveContext = exports.projectDevicePrincipals = exports.directoryHandle = exports.directoryDisplayName = exports.canActivateContext = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.DeviceManager = void 0;
11
+ exports.runColdBoot = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = exports.createNativeIdentityPinStore = exports.createWebIdentityPinStore = exports.readLocalDeviceCredential = exports.publishProvenDeviceCredential = exports.normalizeSharedDeviceSessionRead = exports.decideSharedDevicePublish = exports.decideSharedDeviceJoin = exports.createSharedMirroringAuthStateStore = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.resolveAccountDelegationAccess = exports.canSwitchIntoAccount = exports.isSwitchTargetAccount = exports.deviceComparisonCode = exports.showsPrincipalHeaders = exports.buildSwitcherRows = exports.resolveDeviceContext = exports.resolveActiveContext = exports.projectDevicePrincipals = exports.directoryHandle = exports.directoryDisplayName = exports.canActivateContext = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.DeviceManager = void 0;
12
12
  // Session code reaches the identity key (identity-bound sessions), so the
13
13
  // crypto shim goes first — see `../crypto/polyfill`.
14
14
  require("../crypto/polyfill");
@@ -59,6 +59,8 @@ Object.defineProperty(exports, "resolveDeviceContext", { enumerable: true, get:
59
59
  var deviceSwitcherRows_1 = require("./deviceSwitcherRows");
60
60
  Object.defineProperty(exports, "buildSwitcherRows", { enumerable: true, get: function () { return deviceSwitcherRows_1.buildSwitcherRows; } });
61
61
  Object.defineProperty(exports, "showsPrincipalHeaders", { enumerable: true, get: function () { return deviceSwitcherRows_1.showsPrincipalHeaders; } });
62
+ var deviceComparisonCode_1 = require("./deviceComparisonCode");
63
+ Object.defineProperty(exports, "deviceComparisonCode", { enumerable: true, get: function () { return deviceComparisonCode_1.deviceComparisonCode; } });
62
64
  // The switch-target predicates over the account GRAPH — a list of accounts to
63
65
  // manage, not the device's list of identities to become (that is the directory
64
66
  // above). `isSwitchTargetAccount` is the structural half ("is this kind
@@ -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
  }
@@ -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 authHeader = config.skipAuth ? null : await this.getAuthHeader();
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 && !config._isAuthRetry && !config.skipAuth) {
676
- const refreshed = await this.refreshAccessToken('response-401');
677
- if (refreshed) {
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
- this.tokenStore.clearTokens();
700
- this.notifyTokenChange();
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.body?.cancel();
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.body?.cancel().catch(() => undefined);
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.body?.cancel();
1603
+ await discardResponseBody(response);
1504
1604
  throw error;
1505
1605
  }
1506
1606
  if (refreshed) {
1507
- await response.body?.cancel();
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() {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The code a person compares between a device with no keyboard (a TV, a photo
3
+ * frame) and the phone approving its sign-in: `CE7F-B31F`.
4
+ *
5
+ * The device's QR carries the request's full `authorizeCode` (16 random bytes,
6
+ * hex), and that alone is what approves. Its first eight characters are
7
+ * enough to tell "the request on this screen" from any other: the server draws
8
+ * the code at random, so a look-alike request would take about four billion
9
+ * rate-limited attempts, while thirty-two characters were too many for anyone
10
+ * to compare. Both sides (`OxyDeviceSignIn` on the device and the approval
11
+ * page on the phone) derive it here, so they always print the same thing.
12
+ */
13
+ const COMPARISON_LENGTH = 8;
14
+ const GROUP_LENGTH = 4;
15
+ export function deviceComparisonCode(authorizeCode) {
16
+ const head = authorizeCode.slice(0, COMPARISON_LENGTH).toUpperCase();
17
+ return `${head.slice(0, GROUP_LENGTH)}-${head.slice(GROUP_LENGTH)}`;
18
+ }
@@ -40,6 +40,7 @@ export { canActivateContext, directoryDisplayName, directoryHandle, projectDevic
40
40
  // auth.oxy.so chooser so the two cannot drift, the same reason the flat
41
41
  // projection lived here before it.
42
42
  export { buildSwitcherRows, showsPrincipalHeaders } from './deviceSwitcherRows.js';
43
+ export { deviceComparisonCode } from './deviceComparisonCode.js';
43
44
  // The switch-target predicates over the account GRAPH — a list of accounts to
44
45
  // manage, not the device's list of identities to become (that is the directory
45
46
  // above). `isSwitchTargetAccount` is the structural half ("is this kind
@@ -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
  }
@@ -0,0 +1 @@
1
+ export declare function deviceComparisonCode(authorizeCode: string): string;
@@ -18,6 +18,7 @@ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountId
18
18
  export { canActivateContext, directoryDisplayName, directoryHandle, projectDevicePrincipals, resolveActiveContext, resolveDeviceContext, } from './deviceDirectory';
19
19
  export type { DeviceContext, DeviceContextActor, DeviceContextSubject, DevicePrincipalGroup, } from './deviceDirectory';
20
20
  export { buildSwitcherRows, showsPrincipalHeaders } from './deviceSwitcherRows';
21
+ export { deviceComparisonCode } from './deviceComparisonCode';
21
22
  export type { ResolveAvatarUrl, SwitcherContextRow, SwitcherPrincipalRow, } from './deviceSwitcherRows';
22
23
  export { isSwitchTargetAccount, canSwitchIntoAccount, } from './accountSwitchTargets';
23
24
  export { resolveAccountDelegationAccess } from './accountDelegationAccess';
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.8.1",
3
+ "version": "7.9.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -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 = this.generateCacheKey(config.method, config.url, config.data || config.params);
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 authHeader = config.skipAuth ? null : await this.getAuthHeader();
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 (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
964
- const refreshed = await this.refreshAccessToken('response-401');
965
- if (refreshed) {
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
- this.tokenStore.clearTokens();
988
- this.notifyTokenChange();
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.body?.cancel();
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.body?.cancel().catch(() => undefined);
1996
+ void discardResponseBody(response);
1888
1997
  throw error;
1889
1998
  }
1890
1999
  try {
1891
2000
  assertCurrent();
1892
2001
  } catch (error) {
1893
- await response.body?.cancel();
2002
+ await discardResponseBody(response);
1894
2003
  throw error;
1895
2004
  }
1896
2005
  if (refreshed) {
1897
- await response.body?.cancel();
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
 
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The code a person compares between a device with no keyboard (a TV, a photo
3
+ * frame) and the phone approving its sign-in: `CE7F-B31F`.
4
+ *
5
+ * The device's QR carries the request's full `authorizeCode` (16 random bytes,
6
+ * hex), and that alone is what approves. Its first eight characters are
7
+ * enough to tell "the request on this screen" from any other: the server draws
8
+ * the code at random, so a look-alike request would take about four billion
9
+ * rate-limited attempts, while thirty-two characters were too many for anyone
10
+ * to compare. Both sides (`OxyDeviceSignIn` on the device and the approval
11
+ * page on the phone) derive it here, so they always print the same thing.
12
+ */
13
+ const COMPARISON_LENGTH = 8;
14
+ const GROUP_LENGTH = 4;
15
+
16
+ export function deviceComparisonCode(authorizeCode: string): string {
17
+ const head = authorizeCode.slice(0, COMPARISON_LENGTH).toUpperCase();
18
+ return `${head.slice(0, GROUP_LENGTH)}-${head.slice(GROUP_LENGTH)}`;
19
+ }
@@ -75,6 +75,7 @@ export type {
75
75
  // auth.oxy.so chooser so the two cannot drift, the same reason the flat
76
76
  // projection lived here before it.
77
77
  export { buildSwitcherRows, showsPrincipalHeaders } from './deviceSwitcherRows';
78
+ export { deviceComparisonCode } from './deviceComparisonCode';
78
79
  export type {
79
80
  ResolveAvatarUrl,
80
81
  SwitcherContextRow,
@@ -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
  /**
@@ -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
  }
@@ -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
  }