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