@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
@@ -17,18 +17,12 @@ import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/request
17
17
  import { retryAsync } from './utils/asyncUtils.js';
18
18
  import { createCancelledError, ErrorCodes, handleHttpError, parseHttpErrorBody } from './utils/errorUtils.js';
19
19
  import { jwtDecode } from 'jwt-decode';
20
- import { isNative, getPlatformOS } from './utils/platform.js';
21
20
  import { isReactNative } from '@oxy.so/protocol';
22
21
  import { computeIdentityTag, fnv1a32 } from './utils/cacheKey.js';
23
22
  import { redactUrlQuery } from './utils/redactUrl.js';
24
23
  import { OxyAuthenticationError } from './OxyServices.errors.js';
25
24
  import { getBrowserEdgeRegionHeader } from './utils/edgeRegion.js';
26
25
  import { getBrowserActivityIdHeader } from './utils/activityId.js';
27
- /**
28
- * Check if we're running in a native app environment (React Native, not web)
29
- * This is used to determine CSRF handling mode
30
- */
31
- const isNativeApp = isNative();
32
26
  /**
33
27
  * Default per-request timeout (ms) when neither the call site nor
34
28
  * {@link OxyConfig.requestTimeout} overrides it. Kept tight so a stalled
@@ -36,24 +30,6 @@ const isNativeApp = isNative();
36
30
  * request queue.
37
31
  */
38
32
  const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
39
- /**
40
- * Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
41
- * regular request timeout: this is a small, fast, unauthenticated call and
42
- * should never inherit a longer per-request budget.
43
- */
44
- const CSRF_FETCH_TIMEOUT_MS = 5000;
45
- /**
46
- * Number of attempts for fetching a CSRF token before giving up. The first
47
- * failure is usually a cold edge/cookie race; a single retry recovers it
48
- * without masking a genuinely broken `/csrf-token` route.
49
- */
50
- const CSRF_FETCH_MAX_ATTEMPTS = 2;
51
- /**
52
- * Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
53
- * fetch sits in the critical path of a state-changing request, so the retry
54
- * must add minimal latency.
55
- */
56
- const CSRF_FETCH_RETRY_DELAY_MS = 500;
57
33
  /**
58
34
  * Cooldown (ms) applied after a failed access-token refresh before another
59
35
  * refresh is attempted while the CURRENT token is still valid (a proactive,
@@ -156,8 +132,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
156
132
  class TokenStore {
157
133
  constructor() {
158
134
  this.accessToken = null;
159
- this.csrfToken = null;
160
- this.csrfTokenFetchPromise = null;
161
135
  }
162
136
  setTokens(accessToken) {
163
137
  this.accessToken = accessToken;
@@ -171,22 +145,6 @@ class TokenStore {
171
145
  hasAccessToken() {
172
146
  return !!this.accessToken;
173
147
  }
174
- setCsrfToken(token) {
175
- this.csrfToken = token;
176
- }
177
- getCsrfToken() {
178
- return this.csrfToken;
179
- }
180
- setCsrfTokenFetchPromise(promise) {
181
- this.csrfTokenFetchPromise = promise;
182
- }
183
- getCsrfTokenFetchPromise() {
184
- return this.csrfTokenFetchPromise;
185
- }
186
- clearCsrfToken() {
187
- this.csrfToken = null;
188
- this.csrfTokenFetchPromise = null;
189
- }
190
148
  }
191
149
  /**
192
150
  * Propagate `parent`'s abort to `child`, returning a disposer.
@@ -276,6 +234,21 @@ export class HttpService {
276
234
  this.authRefreshHandler = null;
277
235
  this.accessTokenProvider = null;
278
236
  this.deviceSecretMintInFlight = null;
237
+ /**
238
+ * Bumped by every {@link endSession}. A re-mint captures it before its first
239
+ * await and plants nothing if it moved: a refresh that was already in flight
240
+ * when the user signed out would otherwise complete a moment later and put a
241
+ * bearer — and through the token listeners, the account — back.
242
+ */
243
+ this.sessionEpoch = 0;
244
+ /**
245
+ * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
246
+ * native shared-keychain re-mint arm: after a sign-out a 401 is the expected
247
+ * answer, not a cue to sign back in with the identity key. The device-secret
248
+ * arm is NOT gated on it — minting from a credential the store still holds is
249
+ * how a signed-out tab joins a sign-in made in another tab.
250
+ */
251
+ this.sessionEnded = false;
279
252
  /**
280
253
  * Epoch (ms) before which a cache-size telemetry warning must not be
281
254
  * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
@@ -429,14 +402,9 @@ export class HttpService {
429
402
  // it before enqueue means auth-blocked requests hold NO slot while the shared
430
403
  // mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
431
404
  // and skip the near-expiry preflight — see RequestOptions.skipAuth. The
432
- // 401/CSRF retry re-enters request() with a fresh config, so it re-resolves
433
- // these here with the refreshed token / cleared CSRF.
434
- const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
405
+ // 401 retry re-enters request() with a fresh config, so it re-resolves
406
+ // this here with the refreshed token.
435
407
  const authHeader = config.skipAuth ? null : await this.getAuthHeader();
436
- // CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
437
- // clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
438
- // should not need to implement a duplicate `/csrf-token` route.
439
- const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
440
408
  const edgeRegionHeader = await getBrowserEdgeRegionHeader();
441
409
  const activityIdHeader = getBrowserActivityIdHeader();
442
410
  // A request the caller has ALREADY abandoned takes no queue slot and makes
@@ -507,29 +475,6 @@ export class HttpService {
507
475
  if (authHeader) {
508
476
  headers['Authorization'] = authHeader;
509
477
  }
510
- // Add CSRF token header for state-changing requests
511
- if (csrfToken) {
512
- headers['X-CSRF-Token'] = csrfToken;
513
- }
514
- // Add native app header for React Native (required for CSRF validation)
515
- // Native apps can't persist cookies like browsers, so the server uses
516
- // header-only CSRF validation when this header is present
517
- if (isNativeApp && isStateChangingMethod) {
518
- headers['X-Native-App'] = 'true';
519
- }
520
- // Debug logging for CSRF issues, routed through SimpleLogger so it only
521
- // fires when consumers opt in via `enableLogging`.
522
- if (isStateChangingMethod) {
523
- this.logger.debug('CSRF Debug:', {
524
- url,
525
- method,
526
- isNativeApp,
527
- platformOS: getPlatformOS(),
528
- hasCsrfToken: !!csrfToken,
529
- csrfTokenLength: csrfToken?.length,
530
- hasNativeAppHeader: headers['X-Native-App'] === 'true',
531
- });
532
- }
533
478
  // Merge custom headers if provided
534
479
  if (config.headers) {
535
480
  Object.entries(config.headers).forEach(([key, value]) => {
@@ -581,34 +526,19 @@ export class HttpService {
581
526
  if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
582
527
  const refreshed = await this.refreshAccessToken('response-401');
583
528
  if (refreshed) {
584
- // `deduplicate: false` is REQUIRED on the retry (mirrors the 403
585
- // CSRF retry below). This re-issue runs while the ORIGINAL request
586
- // is still in-flight under its dedupe key; the refreshed token is
529
+ // `deduplicate: false` is REQUIRED on the retry. This re-issue runs
530
+ // while the ORIGINAL request is still in-flight under its dedupe
531
+ // key; the refreshed token is
587
532
  // for the SAME user, so the identity-scoped key is UNCHANGED — a
588
533
  // deduplicated retry would resolve to the still-pending original
589
534
  // and await itself (deadlock). Opting the retry out of dedupe makes
590
535
  // it a fresh request.
591
536
  return this.request({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
592
537
  }
593
- // Refresh failed or no token — clear tokens and stale CSRF
538
+ // Refresh failed or no token — clear tokens
594
539
  this.tokenStore.clearTokens();
595
- this.tokenStore.clearCsrfToken();
596
540
  this.notifyTokenChange();
597
541
  }
598
- // On 403 with CSRF error, clear cached token and retry once
599
- if (response.status === 403 && !config._isCsrfRetry) {
600
- try {
601
- const clonedResponse = response.clone();
602
- const errBody = await clonedResponse.json();
603
- if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
604
- this.tokenStore.clearCsrfToken();
605
- return this.request({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
606
- }
607
- }
608
- catch {
609
- // Failed to parse error body — not a CSRF error
610
- }
611
- }
612
542
  // Read the error body (may be absent, non-JSON, empty or malformed).
613
543
  // Anything unreadable leaves `errorBody` undefined and degrades to the
614
544
  // status-based message — an error path that throws its own error is
@@ -982,75 +912,6 @@ export class HttpService {
982
912
  return false;
983
913
  }
984
914
  }
985
- /**
986
- * Fetch CSRF token from server (with deduplication)
987
- * Required for state-changing requests (POST, PUT, PATCH, DELETE)
988
- */
989
- async fetchCsrfToken() {
990
- // Return cached token if available
991
- const cachedToken = this.tokenStore.getCsrfToken();
992
- if (cachedToken) {
993
- this.logger.debug('Using cached CSRF token');
994
- return cachedToken;
995
- }
996
- // Deduplicate concurrent CSRF token fetches
997
- const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
998
- if (existingPromise) {
999
- this.logger.debug('Waiting for existing CSRF fetch');
1000
- return existingPromise;
1001
- }
1002
- const fetchPromise = (async () => {
1003
- for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
1004
- try {
1005
- this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
1006
- // Use AbortController for timeout (more compatible than AbortSignal.timeout)
1007
- const controller = new AbortController();
1008
- const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
1009
- const response = await fetch(`${this.baseURL}/csrf-token`, {
1010
- method: 'GET',
1011
- headers: { 'Accept': 'application/json' },
1012
- credentials: 'include', // Required to receive and send cookies
1013
- signal: controller.signal,
1014
- });
1015
- clearTimeout(timeoutId);
1016
- this.logger.debug('CSRF fetch response:', response.status, response.ok);
1017
- if (response.ok) {
1018
- const data = await response.json();
1019
- const token = data.csrfToken || null;
1020
- this.logger.debug('CSRF response data:', {
1021
- hasCsrfToken: typeof token === 'string' && token.length > 0,
1022
- csrfTokenLength: token?.length,
1023
- });
1024
- this.tokenStore.setCsrfToken(token);
1025
- this.logger.debug('CSRF token fetched');
1026
- return token;
1027
- }
1028
- // Also check response header for CSRF token
1029
- const headerToken = response.headers.get('X-CSRF-Token');
1030
- if (headerToken) {
1031
- this.tokenStore.setCsrfToken(headerToken);
1032
- this.logger.debug('CSRF token from header');
1033
- return headerToken;
1034
- }
1035
- this.logger.debug('CSRF fetch failed with status:', response.status);
1036
- this.logger.warn('Failed to fetch CSRF token:', response.status);
1037
- }
1038
- catch (error) {
1039
- this.logger.debug('CSRF fetch error:', error);
1040
- this.logger.warn('CSRF token fetch error:', error);
1041
- }
1042
- // Brief backoff before the next attempt.
1043
- if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
1044
- await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
1045
- }
1046
- }
1047
- return null;
1048
- })().finally(() => {
1049
- this.tokenStore.setCsrfTokenFetchPromise(null);
1050
- });
1051
- this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
1052
- return fetchPromise;
1053
- }
1054
915
  /**
1055
916
  * Get auth header with automatic token refresh
1056
917
  */
@@ -1112,12 +973,21 @@ export class HttpService {
1112
973
  // by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
1113
974
  // on the way out would discard the flag it just set.
1114
975
  this.lastRefreshWasRateLimited = false;
976
+ const epoch = this.sessionEpoch;
1115
977
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1116
978
  .then((newToken) => {
979
+ if (epoch !== this.sessionEpoch) {
980
+ // The session ended while this re-mint was in flight. Not a failure
981
+ // (no cooldown), and nothing to plant.
982
+ this.logger.debug('Discarded a token refresh that outlived its session');
983
+ return null;
984
+ }
1117
985
  if (!newToken) {
1118
986
  this.lastRefreshFailureAt = Date.now();
1119
987
  return null;
1120
988
  }
989
+ // A token is planted again, so there is a session again.
990
+ this.sessionEnded = false;
1121
991
  if (this.tokenStore.getAccessToken() !== newToken) {
1122
992
  this.tokenStore.setTokens(newToken);
1123
993
  this.notifyTokenChange();
@@ -1317,7 +1187,6 @@ export class HttpService {
1317
1187
  return this.requestAuthenticatedResponseAttempt(config, `Bearer ${refreshed}`, true);
1318
1188
  }
1319
1189
  this.tokenStore.clearTokens();
1320
- this.tokenStore.clearCsrfToken();
1321
1190
  this.notifyTokenChange();
1322
1191
  }
1323
1192
  const duration = Date.now() - startTime;
@@ -1352,9 +1221,34 @@ export class HttpService {
1352
1221
  }
1353
1222
  // Token management
1354
1223
  setTokens(accessToken) {
1224
+ this.sessionEnded = false;
1355
1225
  this.tokenStore.setTokens(accessToken);
1356
1226
  this.notifyTokenChange();
1357
1227
  }
1228
+ /**
1229
+ * End the local session: clear the bearer and abandon every re-mint already
1230
+ * in flight, so none of them can plant a token after the sign-out.
1231
+ *
1232
+ * {@link clearTokens} alone only drops the bearer, which is right for a
1233
+ * mirror (a linked client following its parent) but not for a sign-out: a
1234
+ * refresh started a moment earlier would finish and plant a new one.
1235
+ */
1236
+ endSession() {
1237
+ this.sessionEpoch += 1;
1238
+ this.sessionEnded = true;
1239
+ this.clearTokens();
1240
+ }
1241
+ /**
1242
+ * The current session epoch — see {@link endSession}. A re-mint lane that
1243
+ * plants tokens itself (the device-secret arm) compares it across its awaits.
1244
+ */
1245
+ getSessionEpoch() {
1246
+ return this.sessionEpoch;
1247
+ }
1248
+ /** Whether {@link endSession} ran and no token has been planted since. */
1249
+ hasSessionEnded() {
1250
+ return this.sessionEnded;
1251
+ }
1358
1252
  setAuthRefreshHandler(handler) {
1359
1253
  this.authRefreshHandler = handler;
1360
1254
  }
@@ -1363,7 +1257,6 @@ export class HttpService {
1363
1257
  }
1364
1258
  clearTokens() {
1365
1259
  this.tokenStore.clearTokens();
1366
- this.tokenStore.clearCsrfToken();
1367
1260
  // Drop the response cache on logout. The cache is identity-scoped, so a
1368
1261
  // different user could never read these entries, but a logged-out client
1369
1262
  // must not keep the previous session's personalized data resident in
@@ -1479,8 +1372,8 @@ export class HttpService {
1479
1372
  }
1480
1373
  // Test-only utility — clears tokens on this instance
1481
1374
  __resetTokensForTests() {
1375
+ this.sessionEnded = false;
1482
1376
  this.tokenStore.clearTokens();
1483
- this.tokenStore.clearCsrfToken();
1484
1377
  }
1485
1378
  }
1486
1379
  /**
@@ -165,10 +165,12 @@ export class OxyServicesBase {
165
165
  this.httpService.setTokens(accessToken);
166
166
  }
167
167
  /**
168
- * Clear stored authentication tokens
168
+ * Clear stored authentication tokens and end the local session: a re-mint
169
+ * already in flight plants nothing, and no new one runs until a token is set
170
+ * again (see `HttpService.endSession`).
169
171
  */
170
172
  clearTokens() {
171
- this.httpService.clearTokens();
173
+ this.httpService.endSession();
172
174
  this._cachedUserId = undefined;
173
175
  this._cachedAccessToken = null;
174
176
  }
@@ -202,6 +202,9 @@ export async function runSessionColdBoot(opts) {
202
202
  // Network / 5xx: keep the secret; a later attempt can succeed.
203
203
  logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
204
204
  return { kind: 'skip' };
205
+ case 'session-ended':
206
+ // Signed out while the boot was minting; the arm planted nothing.
207
+ return { kind: 'skip' };
205
208
  case 'no-secret':
206
209
  return { kind: 'skip' };
207
210
  }
@@ -285,10 +285,7 @@ export function OxyServicesUserMixin(Base) {
285
285
  * `PostHydrationService`) and is unchanged.
286
286
  * - **Plain client (browser / React Native with a user session):** when no
287
287
  * service credentials are configured, the chunk is fetched via
288
- * `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
289
- * middleware skips bearer-authenticated writes, and `makeRequest` only
290
- * fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
291
- * so the user-bearer POST is sent without CSRF and succeeds. Previously
288
+ * `makeRequest`, which attaches the configured user bearer. Previously
292
289
  * this method always used the service path, so every client-side caller
293
290
  * silently received `[]` because `getServiceToken()` had no credentials.
294
291
  *
@@ -315,7 +312,7 @@ export function OxyServicesUserMixin(Base) {
315
312
  }
316
313
  // A backend that called configureServiceAuth() uses the bearer-service
317
314
  // path; any other caller (browser / RN with a user session) uses the
318
- // user-bearer path. See the method doc for why the user path is CSRF-safe.
315
+ // user-bearer path.
319
316
  const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
320
317
  // Run chunks concurrently; a single chunk failure must not sink the rest.
321
318
  const settled = await Promise.all(chunks.map(async (chunk) => {
@@ -35,7 +35,6 @@ const DEFAULT_ALLOWED_HEADERS = [
35
35
  'X-Requested-With',
36
36
  'X-Oxy-User-Id',
37
37
  'X-Oxy-Internal',
38
- 'X-CSRF-Token',
39
38
  'X-Oxy-Edge-Region',
40
39
  'X-Oxy-Activity-Id',
41
40
  ];
@@ -71,6 +71,7 @@ export async function refreshDeviceSecretArm(deps) {
71
71
  const { oxy, store } = deps;
72
72
  const pin = deps.pin ?? null;
73
73
  return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
74
+ const epoch = oxy.httpService.getSessionEpoch();
74
75
  const persisted = await store.load();
75
76
  if (!persisted?.deviceId || !persisted?.deviceSecret) {
76
77
  return { status: 'no-secret' };
@@ -136,12 +137,26 @@ export async function refreshDeviceSecretArm(deps) {
136
137
  expiresAt: mint.expiresAt,
137
138
  ...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
138
139
  };
140
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
141
+ // Signed out while minting. The server has rotated the secret, so a store
142
+ // that still holds the presented one must learn the rotation (the
143
+ // token-null lane keeps the store so a reload can restore); a store the
144
+ // sign-out cleared must NOT be refilled. Either way, plant nothing.
145
+ const current = await store.load();
146
+ if (current?.deviceId === persisted.deviceId && current.deviceSecret === persisted.deviceSecret) {
147
+ await store.save(next);
148
+ }
149
+ return { status: 'session-ended' };
150
+ }
139
151
  // Persist nextDeviceSecret (read-back-verified) BEFORE planting the token.
140
152
  // A failed durable persist must NOT plant.
141
153
  const persistedOk = await store.save(next);
142
154
  if (!persistedOk) {
143
155
  return { status: 'persist-failed' };
144
156
  }
157
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
158
+ return { status: 'session-ended' };
159
+ }
145
160
  oxy.setTokens(mint.accessToken);
146
161
  return {
147
162
  status: 'ok',
@@ -179,6 +194,7 @@ export async function refreshPersistedSession(deps) {
179
194
  const identity = deps.identity ?? null;
180
195
  // The shared keychain is never an identity-bound client's recovery path.
181
196
  const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? isNative());
197
+ const epoch = oxy.httpService.getSessionEpoch();
182
198
  // Resolved per call: a re-established identity session can move the pin, and a
183
199
  // replaced/removed local key clears it (in which case arm 1 must NOT mint —
184
200
  // an unpinned mint would adopt whatever account the device switched to).
@@ -190,6 +206,8 @@ export async function refreshPersistedSession(deps) {
190
206
  switch (arm1.status) {
191
207
  case 'ok':
192
208
  return arm1.token;
209
+ case 'session-ended':
210
+ return null;
193
211
  case 'transient':
194
212
  logger.debug('Persisted deviceSecret mint failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' });
195
213
  return null;
@@ -228,10 +246,18 @@ export async function refreshPersistedSession(deps) {
228
246
  if (identity) {
229
247
  return recoverIdentitySession(oxy, store, identity);
230
248
  }
231
- if (allowSharedKeyFallback) {
249
+ // Never after a sign-out: the shared keychain holds an identity KEY, not a
250
+ // session, and using it here would sign the user straight back in.
251
+ if (allowSharedKeyFallback && !oxy.httpService.hasSessionEnded()) {
232
252
  try {
233
- const session = await oxy.signInWithSharedIdentity();
253
+ // Planted here, not by the sign-in: a sign-out that lands while the
254
+ // challenge round-trips must win, or the shared keychain signs the user
255
+ // straight back in.
256
+ const session = await oxy.signInWithSharedIdentity({ plantTokens: false });
234
257
  if (session?.accessToken) {
258
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
259
+ return null;
260
+ }
235
261
  // Repopulate the fast device-secret lane from the shared-key re-mint.
236
262
  if (session.deviceId && session.deviceSecret) {
237
263
  await store.save({
@@ -243,6 +269,10 @@ export async function refreshPersistedSession(deps) {
243
269
  expiresAt: session.expiresAt,
244
270
  });
245
271
  }
272
+ if (oxy.httpService.getSessionEpoch() !== epoch) {
273
+ return null;
274
+ }
275
+ oxy.setTokens(session.accessToken);
246
276
  return session.accessToken;
247
277
  }
248
278
  }
@@ -69,7 +69,7 @@ const nonEmptyString = (value) => typeof value === 'string' && value.trim().leng
69
69
  * - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
70
70
  * OAuth token and userinfo endpoints. `error_description` is the human text
71
71
  * and `error` is the machine code, so both survive.
72
- * - `{ message, code }` — e.g. the API's CSRF rejections.
72
+ * - `{ message, code }` — e.g. the API's middleware rejections.
73
73
  * - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
74
74
  * `message`/`error_description` the string is the message, not a code: a bare
75
75
  * `error` string is not machine-readable enough to promote to `code`.