@oxy.so/core 1.9.1 → 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 (44) hide show
  1. package/README.md +4 -4
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +56 -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 +32 -2
  9. package/dist/cjs/utils/errorUtils.js +1 -1
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/HttpService.js +56 -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 +32 -2
  17. package/dist/esm/utils/errorUtils.js +1 -1
  18. package/dist/types/.tsbuildinfo +1 -1
  19. package/dist/types/HttpService.d.ts +31 -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 +7 -0
  24. package/dist/types/utils/errorUtils.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/HttpService.ts +59 -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/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
  33. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
  34. package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
  35. package/src/boot/sessionColdBoot.ts +3 -0
  36. package/src/mixins/OxyServices.user.ts +2 -5
  37. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
  38. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
  39. package/src/mixins/__tests__/topics.test.ts +2 -3
  40. package/src/server/cors.ts +0 -1
  41. package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
  42. package/src/session/__tests__/refresh.test.ts +115 -2
  43. package/src/session/refresh.ts +38 -2
  44. 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
@@ -165,6 +163,21 @@ export declare class HttpService {
165
163
  private authRefreshHandler;
166
164
  private accessTokenProvider;
167
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;
168
181
  /**
169
182
  * Epoch (ms) before which a cache-size telemetry warning must not be
170
183
  * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
@@ -307,11 +320,6 @@ export declare class HttpService {
307
320
  private buildURL;
308
321
  private getCredentialsMode;
309
322
  private shouldSendCredentials;
310
- /**
311
- * Fetch CSRF token from server (with deduplication)
312
- * Required for state-changing requests (POST, PUT, PATCH, DELETE)
313
- */
314
- private fetchCsrfToken;
315
323
  /**
316
324
  * Get auth header with automatic token refresh
317
325
  */
@@ -416,6 +424,22 @@ export declare class HttpService {
416
424
  patch<T = unknown>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'url' | 'data'>): Promise<T>;
417
425
  delete<T = unknown>(url: string, config?: Omit<RequestConfig, 'method' | 'url'>): Promise<T>;
418
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;
419
443
  setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
420
444
  setAccessTokenProvider(provider: AccessTokenProvider | null): void;
421
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
  *
@@ -82,6 +82,11 @@ export interface RefreshDeps {
82
82
  * The device secret is FINE — it is the identity binding that went stale, so
83
83
  * the caller must re-establish from the local key, never drop the credential.
84
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.
85
90
  * - `persist-failed` — the mint succeeded but `nextDeviceSecret` could NOT be
86
91
  * durably persisted. The token is deliberately NOT planted: advertising a
87
92
  * healthy session on a secret that will not survive a reload is exactly the
@@ -103,6 +108,8 @@ export type DeviceSecretMintOutcome = {
103
108
  status: 'account-not-on-device';
104
109
  } | {
105
110
  status: 'transient';
111
+ } | {
112
+ status: 'session-ended';
106
113
  } | {
107
114
  status: 'persist-failed';
108
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.1",
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,
@@ -284,8 +254,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
284
254
  */
285
255
  class TokenStore {
286
256
  private accessToken: string | null = null;
287
- private csrfToken: string | null = null;
288
- private csrfTokenFetchPromise: Promise<string | null> | null = null;
289
257
 
290
258
  setTokens(accessToken: string): void {
291
259
  this.accessToken = accessToken;
@@ -302,27 +270,6 @@ class TokenStore {
302
270
  hasAccessToken(): boolean {
303
271
  return !!this.accessToken;
304
272
  }
305
-
306
- setCsrfToken(token: string | null): void {
307
- this.csrfToken = token;
308
- }
309
-
310
- getCsrfToken(): string | null {
311
- return this.csrfToken;
312
- }
313
-
314
- setCsrfTokenFetchPromise(promise: Promise<string | null> | null): void {
315
- this.csrfTokenFetchPromise = promise;
316
- }
317
-
318
- getCsrfTokenFetchPromise(): Promise<string | null> | null {
319
- return this.csrfTokenFetchPromise;
320
- }
321
-
322
- clearCsrfToken(): void {
323
- this.csrfToken = null;
324
- this.csrfTokenFetchPromise = null;
325
- }
326
273
  }
327
274
 
328
275
  /**
@@ -432,6 +379,21 @@ export class HttpService {
432
379
  private authRefreshHandler: AuthRefreshHandler | null = null;
433
380
  private accessTokenProvider: AccessTokenProvider | null = null;
434
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;
435
397
 
436
398
  /**
437
399
  * Epoch (ms) before which a cache-size telemetry warning must not be
@@ -629,14 +591,9 @@ export class HttpService {
629
591
  // it before enqueue means auth-blocked requests hold NO slot while the shared
630
592
  // mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
631
593
  // and skip the near-expiry preflight — see RequestOptions.skipAuth. The
632
- // 401/CSRF retry re-enters request() with a fresh config, so it re-resolves
633
- // these here with the refreshed token / cleared CSRF.
634
- 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.
635
596
  const authHeader = config.skipAuth ? null : await this.getAuthHeader();
636
- // CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
637
- // clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
638
- // should not need to implement a duplicate `/csrf-token` route.
639
- const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
640
597
  const edgeRegionHeader = await getBrowserEdgeRegionHeader();
641
598
  const activityIdHeader = getBrowserActivityIdHeader();
642
599
 
@@ -717,32 +674,6 @@ export class HttpService {
717
674
  headers['Authorization'] = authHeader;
718
675
  }
719
676
 
720
- // Add CSRF token header for state-changing requests
721
- if (csrfToken) {
722
- headers['X-CSRF-Token'] = csrfToken;
723
- }
724
-
725
- // Add native app header for React Native (required for CSRF validation)
726
- // Native apps can't persist cookies like browsers, so the server uses
727
- // header-only CSRF validation when this header is present
728
- if (isNativeApp && isStateChangingMethod) {
729
- headers['X-Native-App'] = 'true';
730
- }
731
-
732
- // Debug logging for CSRF issues, routed through SimpleLogger so it only
733
- // fires when consumers opt in via `enableLogging`.
734
- if (isStateChangingMethod) {
735
- this.logger.debug('CSRF Debug:', {
736
- url,
737
- method,
738
- isNativeApp,
739
- platformOS: getPlatformOS(),
740
- hasCsrfToken: !!csrfToken,
741
- csrfTokenLength: csrfToken?.length,
742
- hasNativeAppHeader: headers['X-Native-App'] === 'true',
743
- });
744
- }
745
-
746
677
  // Merge custom headers if provided
747
678
  if (config.headers) {
748
679
  Object.entries(config.headers).forEach(([key, value]) => {
@@ -807,35 +738,20 @@ export class HttpService {
807
738
  if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
808
739
  const refreshed = await this.refreshAccessToken('response-401');
809
740
  if (refreshed) {
810
- // `deduplicate: false` is REQUIRED on the retry (mirrors the 403
811
- // CSRF retry below). This re-issue runs while the ORIGINAL request
812
- // 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
813
744
  // for the SAME user, so the identity-scoped key is UNCHANGED — a
814
745
  // deduplicated retry would resolve to the still-pending original
815
746
  // and await itself (deadlock). Opting the retry out of dedupe makes
816
747
  // it a fresh request.
817
748
  return this.request<T>({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
818
749
  }
819
- // Refresh failed or no token — clear tokens and stale CSRF
750
+ // Refresh failed or no token — clear tokens
820
751
  this.tokenStore.clearTokens();
821
- this.tokenStore.clearCsrfToken();
822
752
  this.notifyTokenChange();
823
753
  }
824
754
 
825
- // On 403 with CSRF error, clear cached token and retry once
826
- if (response.status === 403 && !config._isCsrfRetry) {
827
- try {
828
- const clonedResponse = response.clone();
829
- const errBody = await clonedResponse.json() as { code?: string } | null;
830
- if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
831
- this.tokenStore.clearCsrfToken();
832
- return this.request<T>({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
833
- }
834
- } catch {
835
- // Failed to parse error body — not a CSRF error
836
- }
837
- }
838
-
839
755
  // Read the error body (may be absent, non-JSON, empty or malformed).
840
756
  // Anything unreadable leaves `errorBody` undefined and degrades to the
841
757
  // status-based message — an error path that throws its own error is
@@ -1269,85 +1185,6 @@ export class HttpService {
1269
1185
  }
1270
1186
  }
1271
1187
 
1272
- /**
1273
- * Fetch CSRF token from server (with deduplication)
1274
- * Required for state-changing requests (POST, PUT, PATCH, DELETE)
1275
- */
1276
- private async fetchCsrfToken(): Promise<string | null> {
1277
- // Return cached token if available
1278
- const cachedToken = this.tokenStore.getCsrfToken();
1279
- if (cachedToken) {
1280
- this.logger.debug('Using cached CSRF token');
1281
- return cachedToken;
1282
- }
1283
-
1284
- // Deduplicate concurrent CSRF token fetches
1285
- const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
1286
- if (existingPromise) {
1287
- this.logger.debug('Waiting for existing CSRF fetch');
1288
- return existingPromise;
1289
- }
1290
-
1291
- const fetchPromise = (async () => {
1292
- for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
1293
- try {
1294
- this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
1295
-
1296
- // Use AbortController for timeout (more compatible than AbortSignal.timeout)
1297
- const controller = new AbortController();
1298
- const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
1299
-
1300
- const response = await fetch(`${this.baseURL}/csrf-token`, {
1301
- method: 'GET',
1302
- headers: { 'Accept': 'application/json' },
1303
- credentials: 'include', // Required to receive and send cookies
1304
- signal: controller.signal,
1305
- });
1306
-
1307
- clearTimeout(timeoutId);
1308
-
1309
- this.logger.debug('CSRF fetch response:', response.status, response.ok);
1310
-
1311
- if (response.ok) {
1312
- const data = await response.json() as { csrfToken?: string };
1313
- const token = data.csrfToken || null;
1314
- this.logger.debug('CSRF response data:', {
1315
- hasCsrfToken: typeof token === 'string' && token.length > 0,
1316
- csrfTokenLength: token?.length,
1317
- });
1318
- this.tokenStore.setCsrfToken(token);
1319
- this.logger.debug('CSRF token fetched');
1320
- return token;
1321
- }
1322
-
1323
- // Also check response header for CSRF token
1324
- const headerToken = response.headers.get('X-CSRF-Token');
1325
- if (headerToken) {
1326
- this.tokenStore.setCsrfToken(headerToken);
1327
- this.logger.debug('CSRF token from header');
1328
- return headerToken;
1329
- }
1330
-
1331
- this.logger.debug('CSRF fetch failed with status:', response.status);
1332
- this.logger.warn('Failed to fetch CSRF token:', response.status);
1333
- } catch (error) {
1334
- this.logger.debug('CSRF fetch error:', error);
1335
- this.logger.warn('CSRF token fetch error:', error);
1336
- }
1337
- // Brief backoff before the next attempt.
1338
- if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
1339
- await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
1340
- }
1341
- }
1342
- return null;
1343
- })().finally(() => {
1344
- this.tokenStore.setCsrfTokenFetchPromise(null);
1345
- });
1346
-
1347
- this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
1348
- return fetchPromise;
1349
- }
1350
-
1351
1188
  /**
1352
1189
  * Get auth header with automatic token refresh
1353
1190
  */
@@ -1413,12 +1250,21 @@ export class HttpService {
1413
1250
  // by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
1414
1251
  // on the way out would discard the flag it just set.
1415
1252
  this.lastRefreshWasRateLimited = false;
1253
+ const epoch = this.sessionEpoch;
1416
1254
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1417
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
+ }
1418
1262
  if (!newToken) {
1419
1263
  this.lastRefreshFailureAt = Date.now();
1420
1264
  return null;
1421
1265
  }
1266
+ // A token is planted again, so there is a session again.
1267
+ this.sessionEnded = false;
1422
1268
  if (this.tokenStore.getAccessToken() !== newToken) {
1423
1269
  this.tokenStore.setTokens(newToken);
1424
1270
  this.notifyTokenChange();
@@ -1639,7 +1485,6 @@ export class HttpService {
1639
1485
  }
1640
1486
 
1641
1487
  this.tokenStore.clearTokens();
1642
- this.tokenStore.clearCsrfToken();
1643
1488
  this.notifyTokenChange();
1644
1489
  }
1645
1490
 
@@ -1680,10 +1525,38 @@ export class HttpService {
1680
1525
 
1681
1526
  // Token management
1682
1527
  setTokens(accessToken: string): void {
1528
+ this.sessionEnded = false;
1683
1529
  this.tokenStore.setTokens(accessToken);
1684
1530
  this.notifyTokenChange();
1685
1531
  }
1686
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
+
1687
1560
  setAuthRefreshHandler(handler: AuthRefreshHandler | null): void {
1688
1561
  this.authRefreshHandler = handler;
1689
1562
  }
@@ -1694,7 +1567,6 @@ export class HttpService {
1694
1567
 
1695
1568
  clearTokens(): void {
1696
1569
  this.tokenStore.clearTokens();
1697
- this.tokenStore.clearCsrfToken();
1698
1570
  // Drop the response cache on logout. The cache is identity-scoped, so a
1699
1571
  // different user could never read these entries, but a logged-out client
1700
1572
  // must not keep the previous session's personalized data resident in
@@ -1819,7 +1691,7 @@ export class HttpService {
1819
1691
 
1820
1692
  // Test-only utility — clears tokens on this instance
1821
1693
  __resetTokensForTests(): void {
1694
+ this.sessionEnded = false;
1822
1695
  this.tokenStore.clearTokens();
1823
- this.tokenStore.clearCsrfToken();
1824
1696
  }
1825
1697
  }
@@ -212,10 +212,12 @@ export class OxyServicesBase {
212
212
  }
213
213
 
214
214
  /**
215
- * Clear stored authentication tokens
215
+ * Clear stored authentication tokens and end the local session: a re-mint
216
+ * already in flight plants nothing, and no new one runs until a token is set
217
+ * again (see `HttpService.endSession`).
216
218
  */
217
219
  public clearTokens(): void {
218
- this.httpService.clearTokens();
220
+ this.httpService.endSession();
219
221
  this._cachedUserId = undefined;
220
222
  this._cachedAccessToken = null;
221
223
  }
@@ -46,8 +46,8 @@ describe('HttpService form-urlencoded bodies', () => {
46
46
  });
47
47
 
48
48
  /**
49
- * POST a form body and return the token-endpoint call. Selected by URL
50
- * because an unauthenticated write is preceded by a `GET /csrf-token`.
49
+ * POST a form body and return the token-endpoint call. Selected by URL so
50
+ * any preflight a future change adds cannot shift the call under test.
51
51
  */
52
52
  async function postForm(body: URLSearchParams): Promise<FetchCall> {
53
53
  const calls: FetchCall[] = [];
@@ -111,9 +111,6 @@ describe('HttpService form-urlencoded bodies', () => {
111
111
  it('prefers OAuth error_description over the bare error code', async () => {
112
112
  globalThis.fetch = async (input) => {
113
113
  const url = String(input);
114
- if (url.endsWith('/csrf-token')) {
115
- return jsonResponse({ csrfToken: 'csrf-test' });
116
- }
117
114
  return new Response(
118
115
  JSON.stringify({
119
116
  error: 'invalid_grant',