@oxy.so/core 1.9.0 → 1.10.0

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