@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
@@ -94,8 +94,6 @@ interface RequestConfig extends RequestOptions {
94
94
  params?: Record<string, unknown>;
95
95
  /** @internal Used to prevent infinite auth retry loops */
96
96
  _isAuthRetry?: boolean;
97
- /** @internal Used to prevent infinite CSRF retry loops */
98
- _isCsrfRetry?: boolean;
99
97
  }
100
98
  /**
101
99
  * Unified HTTP Service
@@ -141,9 +139,45 @@ export declare class HttpService {
141
139
  * it, and there is exactly one timestamp to reason about.
142
140
  */
143
141
  private lastRefreshWasRateLimited;
142
+ /**
143
+ * The access token the last SUCCESSFUL refresh handed back while it was still
144
+ * inside the refresh lead window, or `null`.
145
+ *
146
+ * The device mint (`POST /session/device/token`) answers with the session's
147
+ * STORED access token for as long as that token has not expired — it only
148
+ * mints a new one once the old one is past `exp`. A client that re-mints
149
+ * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
150
+ * token back, and because that counts as a success (no cooldown), every
151
+ * following request re-ran the preflight and minted again: one mint per
152
+ * request for the last minute of every token, which exhausted the server's
153
+ * 30/min mint budget. The 429 that followed imposed a 60s cooldown that
154
+ * straddled the token's real expiry, so the next request went out with no
155
+ * bearer, drew a 401, found the refresh cooling down, and signed the user out.
156
+ *
157
+ * Remembering the answer ends that: while the current token IS this value and
158
+ * has not yet expired, the server has already said it has nothing fresher, so
159
+ * the preflight sends the still-valid token instead of asking again. Once the
160
+ * token crosses `exp` the preflight asks once more and the server rotates.
161
+ */
162
+ private noFresherTokenThan;
144
163
  private authRefreshHandler;
145
164
  private accessTokenProvider;
146
165
  private deviceSecretMintInFlight;
166
+ /**
167
+ * Bumped by every {@link endSession}. A re-mint captures it before its first
168
+ * await and plants nothing if it moved: a refresh that was already in flight
169
+ * when the user signed out would otherwise complete a moment later and put a
170
+ * bearer — and through the token listeners, the account — back.
171
+ */
172
+ private sessionEpoch;
173
+ /**
174
+ * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
175
+ * native shared-keychain re-mint arm: after a sign-out a 401 is the expected
176
+ * answer, not a cue to sign back in with the identity key. The device-secret
177
+ * arm is NOT gated on it — minting from a credential the store still holds is
178
+ * how a signed-out tab joins a sign-in made in another tab.
179
+ */
180
+ private sessionEnded;
147
181
  /**
148
182
  * Epoch (ms) before which a cache-size telemetry warning must not be
149
183
  * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
@@ -286,11 +320,6 @@ export declare class HttpService {
286
320
  private buildURL;
287
321
  private getCredentialsMode;
288
322
  private shouldSendCredentials;
289
- /**
290
- * Fetch CSRF token from server (with deduplication)
291
- * Required for state-changing requests (POST, PUT, PATCH, DELETE)
292
- */
293
- private fetchCsrfToken;
294
323
  /**
295
324
  * Get auth header with automatic token refresh
296
325
  */
@@ -309,6 +338,13 @@ export declare class HttpService {
309
338
  * Public because the handler lives in `session/refresh.ts`, not on this class.
310
339
  */
311
340
  noteRefreshRateLimited(): void;
341
+ /**
342
+ * Whether the server has already answered a refresh of the CURRENT access
343
+ * token with that same, still-valid token — i.e. it has nothing fresher to
344
+ * give until the token's `exp`. The proactive scheduler reads this so it waits
345
+ * for expiry instead of re-asking at its 1s floor.
346
+ */
347
+ isAwaitingCurrentTokenExpiry(): boolean;
312
348
  /**
313
349
  * Whether the CURRENT stored access token is already past its `exp`. Drives
314
350
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -388,6 +424,22 @@ export declare class HttpService {
388
424
  patch<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<T>;
389
425
  delete<T = unknown>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<T>;
390
426
  setTokens(accessToken: string): void;
427
+ /**
428
+ * End the local session: clear the bearer and abandon every re-mint already
429
+ * in flight, so none of them can plant a token after the sign-out.
430
+ *
431
+ * {@link clearTokens} alone only drops the bearer, which is right for a
432
+ * mirror (a linked client following its parent) but not for a sign-out: a
433
+ * refresh started a moment earlier would finish and plant a new one.
434
+ */
435
+ endSession(): void;
436
+ /**
437
+ * The current session epoch — see {@link endSession}. A re-mint lane that
438
+ * plants tokens itself (the device-secret arm) compares it across its awaits.
439
+ */
440
+ getSessionEpoch(): number;
441
+ /** Whether {@link endSession} ran and no token has been planted since. */
442
+ hasSessionEnded(): boolean;
391
443
  setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
392
444
  setAccessTokenProvider(provider: AccessTokenProvider | null): void;
393
445
  clearTokens(): void;
@@ -96,7 +96,9 @@ export declare class OxyServicesBase {
96
96
  */
97
97
  setTokens(accessToken: string): void;
98
98
  /**
99
- * Clear stored authentication tokens
99
+ * Clear stored authentication tokens and end the local session: a re-mint
100
+ * already in flight plants nothing, and no new one runs until a token is set
101
+ * again (see `HttpService.endSession`).
100
102
  */
101
103
  clearTokens(): void;
102
104
  /**
@@ -502,7 +502,17 @@ export declare function OxyServicesCivicMixin<T extends typeof OxyServicesBase>(
502
502
  handleError(error: unknown): Error;
503
503
  healthCheck(): Promise<{
504
504
  status: string;
505
- users?: number;
505
+ users
506
+ /**
507
+ * The VC type tags. `'VerifiableCredential'` is the required base type and is
508
+ * prepended automatically when the caller omits it; provide at least one
509
+ * specific type alongside (e.g. `'EmploymentCredential'`).
510
+ */
511
+ ? /**
512
+ * The VC type tags. `'VerifiableCredential'` is the required base type and is
513
+ * prepended automatically when the caller omits it; provide at least one
514
+ * specific type alongside (e.g. `'EmploymentCredential'`).
515
+ */: number;
506
516
  timestamp?: string;
507
517
  [key: string]: any;
508
518
  }>;
@@ -235,10 +235,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
235
235
  * `PostHydrationService`) and is unchanged.
236
236
  * - **Plain client (browser / React Native with a user session):** when no
237
237
  * service credentials are configured, the chunk is fetched via
238
- * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
239
- * middleware skips bearer-authenticated writes, and `makeRequest` only
240
- * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
241
- * so the user-bearer POST is sent without CSRF and succeeds. Previously
238
+ * `makeRequest`, which attaches the configured user bearer. Previously
242
239
  * this method always used the service path, so every client-side caller
243
240
  * silently received `[]` because `getServiceToken()` had no credentials.
244
241
  *
@@ -33,6 +33,20 @@ import { type IdentityBinding } from './identitySession';
33
33
  * window — the scheduler just fires it during idle/background.
34
34
  */
35
35
  export declare const TOKEN_REFRESH_LEAD_MS = 60000;
36
+ /**
37
+ * How long past `exp` to re-mint when a successful refresh handed back a token
38
+ * that is STILL inside the lead window.
39
+ *
40
+ * The device mint returns the session's stored access token until that token
41
+ * has actually expired, so asking again before `exp` only returns the same
42
+ * token. Re-arming from expiry in that state computed a negative delay, hit the
43
+ * {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
44
+ * minute of every token — 30+ mints, the server's whole per-minute budget, and
45
+ * the 429 that followed straddled the real expiry. Waiting until just past
46
+ * `exp` asks exactly once more, when the server will rotate. The margin covers
47
+ * the server's strict `exp < now` comparison and a little clock skew.
48
+ */
49
+ export declare const REMINT_AFTER_EXPIRY_MS = 2000;
36
50
  export interface RefreshDeps {
37
51
  oxy: OxyServices;
38
52
  store: AuthStateStore;
@@ -68,6 +82,11 @@ export interface RefreshDeps {
68
82
  * The device secret is FINE — it is the identity binding that went stale, so
69
83
  * the caller must re-establish from the local key, never drop the credential.
70
84
  * - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
85
+ * - `session-ended` — the mint succeeded, but the local session was ended
86
+ * (`HttpService.endSession`, i.e. a sign-out) while it was in flight. Nothing
87
+ * is planted. The rotated secret is persisted only if the store still holds
88
+ * the credential that was presented — a store the sign-out cleared stays
89
+ * clear.
71
90
  * - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
72
91
  * durably persisted. The token is deliberately NOT planted: advertising a
73
92
  * healthy session on a secret that will not survive a reload is exactly the
@@ -89,6 +108,8 @@ export type DeviceSecretMintOutcome = {
89
108
  status: 'account-not-on-device';
90
109
  } | {
91
110
  status: 'transient';
111
+ } | {
112
+ status: 'session-ended';
92
113
  } | {
93
114
  status: 'persist-failed';
94
115
  };
@@ -93,7 +93,7 @@ export interface ParsedHttpErrorBody {
93
93
  * - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
94
94
  * OAuth token and userinfo endpoints. `error_description` is the human text
95
95
  * and `error` is the machine code, so both survive.
96
- * - `{ message, code }` — e.g. the API's CSRF rejections.
96
+ * - `{ message, code }` — e.g. the API's middleware rejections.
97
97
  * - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
98
98
  * `message`/`error_description` the string is the message, not a code: a bare
99
99
  * `error` string is not machine-readable enough to promote to `code`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
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",
@@ -18,7 +18,6 @@ import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/request
18
18
  import { retryAsync } from './utils/asyncUtils';
19
19
  import { createCancelledError, ErrorCodes, handleHttpError, isCancelledError, parseHttpErrorBody } from './utils/errorUtils';
20
20
  import { jwtDecode } from 'jwt-decode';
21
- import { isNative, getPlatformOS } from './utils/platform';
22
21
  import { isReactNative } from '@oxy.so/protocol';
23
22
  import { computeIdentityTag, fnv1a32 } from './utils/cacheKey';
24
23
  import { redactUrlQuery } from './utils/redactUrl';
@@ -28,12 +27,6 @@ import { OxyAuthenticationError } from './OxyServices.errors';
28
27
  import { getBrowserEdgeRegionHeader } from './utils/edgeRegion';
29
28
  import { getBrowserActivityIdHeader } from './utils/activityId';
30
29
 
31
- /**
32
- * Check if we're running in a native app environment (React Native, not web)
33
- * This is used to determine CSRF handling mode
34
- */
35
- const isNativeApp = isNative();
36
-
37
30
  interface JwtPayload {
38
31
  exp?: number;
39
32
  userId?: string;
@@ -144,8 +137,6 @@ interface RequestConfig extends RequestOptions {
144
137
  params?: Record<string, unknown>;
145
138
  /** @internal Used to prevent infinite auth retry loops */
146
139
  _isAuthRetry?: boolean;
147
- /** @internal Used to prevent infinite CSRF retry loops */
148
- _isCsrfRetry?: boolean;
149
140
  }
150
141
 
151
142
  /**
@@ -156,27 +147,6 @@ interface RequestConfig extends RequestOptions {
156
147
  */
157
148
  const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
158
149
 
159
- /**
160
- * Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
161
- * regular request timeout: this is a small, fast, unauthenticated call and
162
- * should never inherit a longer per-request budget.
163
- */
164
- const CSRF_FETCH_TIMEOUT_MS = 5000;
165
-
166
- /**
167
- * Number of attempts for fetching a CSRF token before giving up. The first
168
- * failure is usually a cold edge/cookie race; a single retry recovers it
169
- * without masking a genuinely broken `/csrf-token` route.
170
- */
171
- const CSRF_FETCH_MAX_ATTEMPTS = 2;
172
-
173
- /**
174
- * Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
175
- * fetch sits in the critical path of a state-changing request, so the retry
176
- * must add minimal latency.
177
- */
178
- const CSRF_FETCH_RETRY_DELAY_MS = 500;
179
-
180
150
  /**
181
151
  * Cooldown (ms) applied after a failed access-token refresh before another
182
152
  * refresh is attempted while the CURRENT token is still valid (a proactive,
@@ -240,6 +210,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000;
240
210
  */
241
211
  const TOKEN_REFRESH_LEAD_SECONDS = 60;
242
212
 
213
+ /**
214
+ * Whether `token` is a JWT that is still valid but already inside the refresh
215
+ * lead window. `false` for an expired, opaque or no-`exp` token.
216
+ */
217
+ function isInsideRefreshLeadWindow(token: string): boolean {
218
+ try {
219
+ const decoded = jwtDecode<JwtPayload>(token);
220
+ if (typeof decoded.exp !== 'number') {
221
+ return false;
222
+ }
223
+ const now = Math.floor(Date.now() / 1000);
224
+ // `<=`: the proactive scheduler fires at exactly `exp - lead`.
225
+ return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
243
231
  /**
244
232
  * Soft ceiling on the number of live entries in the identity-scoped GET
245
233
  * response cache. Crossing it does NOT evict anything (the {@link TTLCache}
@@ -266,8 +254,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
266
254
  */
267
255
  class TokenStore {
268
256
  private accessToken: string | null = null;
269
- private csrfToken: string | null = null;
270
- private csrfTokenFetchPromise: Promise<string | null> | null = null;
271
257
 
272
258
  setTokens(accessToken: string): void {
273
259
  this.accessToken = accessToken;
@@ -284,27 +270,6 @@ class TokenStore {
284
270
  hasAccessToken(): boolean {
285
271
  return !!this.accessToken;
286
272
  }
287
-
288
- setCsrfToken(token: string | null): void {
289
- this.csrfToken = token;
290
- }
291
-
292
- getCsrfToken(): string | null {
293
- return this.csrfToken;
294
- }
295
-
296
- setCsrfTokenFetchPromise(promise: Promise<string | null> | null): void {
297
- this.csrfTokenFetchPromise = promise;
298
- }
299
-
300
- getCsrfTokenFetchPromise(): Promise<string | null> | null {
301
- return this.csrfTokenFetchPromise;
302
- }
303
-
304
- clearCsrfToken(): void {
305
- this.csrfToken = null;
306
- this.csrfTokenFetchPromise = null;
307
- }
308
273
  }
309
274
 
310
275
  /**
@@ -390,9 +355,45 @@ export class HttpService {
390
355
  * it, and there is exactly one timestamp to reason about.
391
356
  */
392
357
  private lastRefreshWasRateLimited = false;
358
+ /**
359
+ * The access token the last SUCCESSFUL refresh handed back while it was still
360
+ * inside the refresh lead window, or `null`.
361
+ *
362
+ * The device mint (`POST /session/device/token`) answers with the session's
363
+ * STORED access token for as long as that token has not expired — it only
364
+ * mints a new one once the old one is past `exp`. A client that re-mints
365
+ * {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
366
+ * token back, and because that counts as a success (no cooldown), every
367
+ * following request re-ran the preflight and minted again: one mint per
368
+ * request for the last minute of every token, which exhausted the server's
369
+ * 30/min mint budget. The 429 that followed imposed a 60s cooldown that
370
+ * straddled the token's real expiry, so the next request went out with no
371
+ * bearer, drew a 401, found the refresh cooling down, and signed the user out.
372
+ *
373
+ * Remembering the answer ends that: while the current token IS this value and
374
+ * has not yet expired, the server has already said it has nothing fresher, so
375
+ * the preflight sends the still-valid token instead of asking again. Once the
376
+ * token crosses `exp` the preflight asks once more and the server rotates.
377
+ */
378
+ private noFresherTokenThan: string | null = null;
393
379
  private authRefreshHandler: AuthRefreshHandler | null = null;
394
380
  private accessTokenProvider: AccessTokenProvider | null = null;
395
381
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
382
+ /**
383
+ * Bumped by every {@link endSession}. A re-mint captures it before its first
384
+ * await and plants nothing if it moved: a refresh that was already in flight
385
+ * when the user signed out would otherwise complete a moment later and put a
386
+ * bearer — and through the token listeners, the account — back.
387
+ */
388
+ private sessionEpoch = 0;
389
+ /**
390
+ * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
391
+ * native shared-keychain re-mint arm: after a sign-out a 401 is the expected
392
+ * answer, not a cue to sign back in with the identity key. The device-secret
393
+ * arm is NOT gated on it — minting from a credential the store still holds is
394
+ * how a signed-out tab joins a sign-in made in another tab.
395
+ */
396
+ private sessionEnded = false;
396
397
 
397
398
  /**
398
399
  * Epoch (ms) before which a cache-size telemetry warning must not be
@@ -590,14 +591,9 @@ export class HttpService {
590
591
  // it before enqueue means auth-blocked requests hold NO slot while the shared
591
592
  // mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
592
593
  // and skip the near-expiry preflight — see RequestOptions.skipAuth. The
593
- // 401/CSRF retry re-enters request() with a fresh config, so it re-resolves
594
- // these here with the refreshed token / cleared CSRF.
595
- const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
594
+ // 401 retry re-enters request() with a fresh config, so it re-resolves
595
+ // this here with the refreshed token.
596
596
  const authHeader = config.skipAuth ? null : await this.getAuthHeader();
597
- // CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
598
- // clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
599
- // should not need to implement a duplicate `/csrf-token` route.
600
- const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
601
597
  const edgeRegionHeader = await getBrowserEdgeRegionHeader();
602
598
  const activityIdHeader = getBrowserActivityIdHeader();
603
599
 
@@ -678,32 +674,6 @@ export class HttpService {
678
674
  headers['Authorization'] = authHeader;
679
675
  }
680
676
 
681
- // Add CSRF token header for state-changing requests
682
- if (csrfToken) {
683
- headers['X-CSRF-Token'] = csrfToken;
684
- }
685
-
686
- // Add native app header for React Native (required for CSRF validation)
687
- // Native apps can't persist cookies like browsers, so the server uses
688
- // header-only CSRF validation when this header is present
689
- if (isNativeApp && isStateChangingMethod) {
690
- headers['X-Native-App'] = 'true';
691
- }
692
-
693
- // Debug logging for CSRF issues, routed through SimpleLogger so it only
694
- // fires when consumers opt in via `enableLogging`.
695
- if (isStateChangingMethod) {
696
- this.logger.debug('CSRF Debug:', {
697
- url,
698
- method,
699
- isNativeApp,
700
- platformOS: getPlatformOS(),
701
- hasCsrfToken: !!csrfToken,
702
- csrfTokenLength: csrfToken?.length,
703
- hasNativeAppHeader: headers['X-Native-App'] === 'true',
704
- });
705
- }
706
-
707
677
  // Merge custom headers if provided
708
678
  if (config.headers) {
709
679
  Object.entries(config.headers).forEach(([key, value]) => {
@@ -768,35 +738,20 @@ export class HttpService {
768
738
  if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
769
739
  const refreshed = await this.refreshAccessToken('response-401');
770
740
  if (refreshed) {
771
- // `deduplicate: false` is REQUIRED on the retry (mirrors the 403
772
- // CSRF retry below). This re-issue runs while the ORIGINAL request
773
- // is still in-flight under its dedupe key; the refreshed token is
741
+ // `deduplicate: false` is REQUIRED on the retry. This re-issue runs
742
+ // while the ORIGINAL request is still in-flight under its dedupe
743
+ // key; the refreshed token is
774
744
  // for the SAME user, so the identity-scoped key is UNCHANGED — a
775
745
  // deduplicated retry would resolve to the still-pending original
776
746
  // and await itself (deadlock). Opting the retry out of dedupe makes
777
747
  // it a fresh request.
778
748
  return this.request<T>({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
779
749
  }
780
- // Refresh failed or no token — clear tokens and stale CSRF
750
+ // Refresh failed or no token — clear tokens
781
751
  this.tokenStore.clearTokens();
782
- this.tokenStore.clearCsrfToken();
783
752
  this.notifyTokenChange();
784
753
  }
785
754
 
786
- // On 403 with CSRF error, clear cached token and retry once
787
- if (response.status === 403 && !config._isCsrfRetry) {
788
- try {
789
- const clonedResponse = response.clone();
790
- const errBody = await clonedResponse.json() as { code?: string } | null;
791
- if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
792
- this.tokenStore.clearCsrfToken();
793
- return this.request<T>({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
794
- }
795
- } catch {
796
- // Failed to parse error body — not a CSRF error
797
- }
798
- }
799
-
800
755
  // Read the error body (may be absent, non-JSON, empty or malformed).
801
756
  // Anything unreadable leaves `errorBody` undefined and degrades to the
802
757
  // status-based message — an error path that throws its own error is
@@ -1230,85 +1185,6 @@ export class HttpService {
1230
1185
  }
1231
1186
  }
1232
1187
 
1233
- /**
1234
- * Fetch CSRF token from server (with deduplication)
1235
- * Required for state-changing requests (POST, PUT, PATCH, DELETE)
1236
- */
1237
- private async fetchCsrfToken(): Promise<string | null> {
1238
- // Return cached token if available
1239
- const cachedToken = this.tokenStore.getCsrfToken();
1240
- if (cachedToken) {
1241
- this.logger.debug('Using cached CSRF token');
1242
- return cachedToken;
1243
- }
1244
-
1245
- // Deduplicate concurrent CSRF token fetches
1246
- const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
1247
- if (existingPromise) {
1248
- this.logger.debug('Waiting for existing CSRF fetch');
1249
- return existingPromise;
1250
- }
1251
-
1252
- const fetchPromise = (async () => {
1253
- for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
1254
- try {
1255
- this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
1256
-
1257
- // Use AbortController for timeout (more compatible than AbortSignal.timeout)
1258
- const controller = new AbortController();
1259
- const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
1260
-
1261
- const response = await fetch(`${this.baseURL}/csrf-token`, {
1262
- method: 'GET',
1263
- headers: { 'Accept': 'application/json' },
1264
- credentials: 'include', // Required to receive and send cookies
1265
- signal: controller.signal,
1266
- });
1267
-
1268
- clearTimeout(timeoutId);
1269
-
1270
- this.logger.debug('CSRF fetch response:', response.status, response.ok);
1271
-
1272
- if (response.ok) {
1273
- const data = await response.json() as { csrfToken?: string };
1274
- const token = data.csrfToken || null;
1275
- this.logger.debug('CSRF response data:', {
1276
- hasCsrfToken: typeof token === 'string' && token.length > 0,
1277
- csrfTokenLength: token?.length,
1278
- });
1279
- this.tokenStore.setCsrfToken(token);
1280
- this.logger.debug('CSRF token fetched');
1281
- return token;
1282
- }
1283
-
1284
- // Also check response header for CSRF token
1285
- const headerToken = response.headers.get('X-CSRF-Token');
1286
- if (headerToken) {
1287
- this.tokenStore.setCsrfToken(headerToken);
1288
- this.logger.debug('CSRF token from header');
1289
- return headerToken;
1290
- }
1291
-
1292
- this.logger.debug('CSRF fetch failed with status:', response.status);
1293
- this.logger.warn('Failed to fetch CSRF token:', response.status);
1294
- } catch (error) {
1295
- this.logger.debug('CSRF fetch error:', error);
1296
- this.logger.warn('CSRF token fetch error:', error);
1297
- }
1298
- // Brief backoff before the next attempt.
1299
- if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
1300
- await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
1301
- }
1302
- }
1303
- return null;
1304
- })().finally(() => {
1305
- this.tokenStore.setCsrfTokenFetchPromise(null);
1306
- });
1307
-
1308
- this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
1309
- return fetchPromise;
1310
- }
1311
-
1312
1188
  /**
1313
1189
  * Get auth header with automatic token refresh
1314
1190
  */
@@ -1324,6 +1200,12 @@ export class HttpService {
1324
1200
 
1325
1201
  // If the token expires within the refresh lead window, refresh it.
1326
1202
  if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
1203
+ // The server already answered this token's refresh with this very token:
1204
+ // it has nothing fresher until `exp`. Use it rather than asking again
1205
+ // (see `noFresherTokenThan`).
1206
+ if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
1207
+ return `Bearer ${accessToken}`;
1208
+ }
1327
1209
  const refreshed = await this.refreshAccessToken('preflight');
1328
1210
  if (refreshed) return `Bearer ${refreshed}`;
1329
1211
  if (decoded.exp > currentTime) {
@@ -1368,16 +1250,26 @@ export class HttpService {
1368
1250
  // by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
1369
1251
  // on the way out would discard the flag it just set.
1370
1252
  this.lastRefreshWasRateLimited = false;
1253
+ const epoch = this.sessionEpoch;
1371
1254
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1372
1255
  .then((newToken) => {
1256
+ if (epoch !== this.sessionEpoch) {
1257
+ // The session ended while this re-mint was in flight. Not a failure
1258
+ // (no cooldown), and nothing to plant.
1259
+ this.logger.debug('Discarded a token refresh that outlived its session');
1260
+ return null;
1261
+ }
1373
1262
  if (!newToken) {
1374
1263
  this.lastRefreshFailureAt = Date.now();
1375
1264
  return null;
1376
1265
  }
1266
+ // A token is planted again, so there is a session again.
1267
+ this.sessionEnded = false;
1377
1268
  if (this.tokenStore.getAccessToken() !== newToken) {
1378
1269
  this.tokenStore.setTokens(newToken);
1379
1270
  this.notifyTokenChange();
1380
1271
  }
1272
+ this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
1381
1273
  // A success clears the failure timestamp so the next refresh is never
1382
1274
  // throttled by a stale cooldown.
1383
1275
  this.lastRefreshFailureAt = 0;
@@ -1414,6 +1306,17 @@ export class HttpService {
1414
1306
  this.lastRefreshWasRateLimited = true;
1415
1307
  }
1416
1308
 
1309
+ /**
1310
+ * Whether the server has already answered a refresh of the CURRENT access
1311
+ * token with that same, still-valid token — i.e. it has nothing fresher to
1312
+ * give until the token's `exp`. The proactive scheduler reads this so it waits
1313
+ * for expiry instead of re-asking at its 1s floor.
1314
+ */
1315
+ isAwaitingCurrentTokenExpiry(): boolean {
1316
+ const token = this.tokenStore.getAccessToken();
1317
+ return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
1318
+ }
1319
+
1417
1320
  /**
1418
1321
  * Whether the CURRENT stored access token is already past its `exp`. Drives
1419
1322
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -1582,7 +1485,6 @@ export class HttpService {
1582
1485
  }
1583
1486
 
1584
1487
  this.tokenStore.clearTokens();
1585
- this.tokenStore.clearCsrfToken();
1586
1488
  this.notifyTokenChange();
1587
1489
  }
1588
1490
 
@@ -1623,10 +1525,38 @@ export class HttpService {
1623
1525
 
1624
1526
  // Token management
1625
1527
  setTokens(accessToken: string): void {
1528
+ this.sessionEnded = false;
1626
1529
  this.tokenStore.setTokens(accessToken);
1627
1530
  this.notifyTokenChange();
1628
1531
  }
1629
1532
 
1533
+ /**
1534
+ * End the local session: clear the bearer and abandon every re-mint already
1535
+ * in flight, so none of them can plant a token after the sign-out.
1536
+ *
1537
+ * {@link clearTokens} alone only drops the bearer, which is right for a
1538
+ * mirror (a linked client following its parent) but not for a sign-out: a
1539
+ * refresh started a moment earlier would finish and plant a new one.
1540
+ */
1541
+ endSession(): void {
1542
+ this.sessionEpoch += 1;
1543
+ this.sessionEnded = true;
1544
+ this.clearTokens();
1545
+ }
1546
+
1547
+ /**
1548
+ * The current session epoch — see {@link endSession}. A re-mint lane that
1549
+ * plants tokens itself (the device-secret arm) compares it across its awaits.
1550
+ */
1551
+ getSessionEpoch(): number {
1552
+ return this.sessionEpoch;
1553
+ }
1554
+
1555
+ /** Whether {@link endSession} ran and no token has been planted since. */
1556
+ hasSessionEnded(): boolean {
1557
+ return this.sessionEnded;
1558
+ }
1559
+
1630
1560
  setAuthRefreshHandler(handler: AuthRefreshHandler | null): void {
1631
1561
  this.authRefreshHandler = handler;
1632
1562
  }
@@ -1637,7 +1567,6 @@ export class HttpService {
1637
1567
 
1638
1568
  clearTokens(): void {
1639
1569
  this.tokenStore.clearTokens();
1640
- this.tokenStore.clearCsrfToken();
1641
1570
  // Drop the response cache on logout. The cache is identity-scoped, so a
1642
1571
  // different user could never read these entries, but a logged-out client
1643
1572
  // must not keep the previous session's personalized data resident in
@@ -1762,7 +1691,7 @@ export class HttpService {
1762
1691
 
1763
1692
  // Test-only utility — clears tokens on this instance
1764
1693
  __resetTokensForTests(): void {
1694
+ this.sessionEnded = false;
1765
1695
  this.tokenStore.clearTokens();
1766
- this.tokenStore.clearCsrfToken();
1767
1696
  }
1768
1697
  }