@oxyhq/core 1.11.24 → 2.1.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 (150) hide show
  1. package/README.md +5 -6
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/AuthManager.js +678 -4
  4. package/dist/cjs/AuthManagerTypes.js +13 -0
  5. package/dist/cjs/CrossDomainAuth.js +45 -3
  6. package/dist/cjs/OxyServices.base.js +16 -0
  7. package/dist/cjs/i18n/locales/ar-SA.json +83 -0
  8. package/dist/cjs/i18n/locales/ca-ES.json +83 -0
  9. package/dist/cjs/i18n/locales/de-DE.json +83 -0
  10. package/dist/cjs/i18n/locales/en-US.json +83 -0
  11. package/dist/cjs/i18n/locales/es-ES.json +99 -4
  12. package/dist/cjs/i18n/locales/fr-FR.json +83 -0
  13. package/dist/cjs/i18n/locales/it-IT.json +83 -0
  14. package/dist/cjs/i18n/locales/ja-JP.json +83 -0
  15. package/dist/cjs/i18n/locales/ko-KR.json +83 -0
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +83 -1
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +83 -1
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +83 -1
  19. package/dist/cjs/i18n/locales/locales/en-US.json +83 -0
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +99 -4
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +83 -1
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +83 -1
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +200 -117
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +83 -1
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +83 -1
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +83 -1
  27. package/dist/cjs/i18n/locales/pt-PT.json +83 -0
  28. package/dist/cjs/i18n/locales/zh-CN.json +83 -0
  29. package/dist/cjs/index.js +121 -57
  30. package/dist/cjs/mixins/OxyServices.auth.js +235 -0
  31. package/dist/cjs/mixins/OxyServices.fedcm.js +36 -0
  32. package/dist/cjs/mixins/OxyServices.popup.js +61 -1
  33. package/dist/cjs/mixins/OxyServices.user.js +18 -0
  34. package/dist/cjs/utils/accountUtils.js +64 -1
  35. package/dist/cjs/utils/coldBoot.js +71 -0
  36. package/dist/cjs/utils/fapiAutoDetect.js +88 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/AuthManager.js +678 -4
  39. package/dist/esm/AuthManagerTypes.js +12 -0
  40. package/dist/esm/CrossDomainAuth.js +45 -3
  41. package/dist/esm/OxyServices.base.js +16 -0
  42. package/dist/esm/i18n/locales/ar-SA.json +83 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +83 -0
  44. package/dist/esm/i18n/locales/de-DE.json +83 -0
  45. package/dist/esm/i18n/locales/en-US.json +83 -0
  46. package/dist/esm/i18n/locales/es-ES.json +99 -4
  47. package/dist/esm/i18n/locales/fr-FR.json +83 -0
  48. package/dist/esm/i18n/locales/it-IT.json +83 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +83 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +83 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +83 -1
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +83 -1
  53. package/dist/esm/i18n/locales/locales/de-DE.json +83 -1
  54. package/dist/esm/i18n/locales/locales/en-US.json +83 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +99 -4
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +83 -1
  57. package/dist/esm/i18n/locales/locales/it-IT.json +83 -1
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +200 -117
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +83 -1
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +83 -1
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +83 -1
  62. package/dist/esm/i18n/locales/pt-PT.json +83 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +83 -0
  64. package/dist/esm/index.js +74 -26
  65. package/dist/esm/mixins/OxyServices.auth.js +235 -0
  66. package/dist/esm/mixins/OxyServices.fedcm.js +36 -0
  67. package/dist/esm/mixins/OxyServices.popup.js +61 -1
  68. package/dist/esm/mixins/OxyServices.user.js +18 -0
  69. package/dist/esm/utils/accountUtils.js +61 -0
  70. package/dist/esm/utils/coldBoot.js +68 -0
  71. package/dist/esm/utils/fapiAutoDetect.js +85 -0
  72. package/dist/types/.tsbuildinfo +1 -1
  73. package/dist/types/AuthManager.d.ts +243 -3
  74. package/dist/types/AuthManagerTypes.d.ts +68 -0
  75. package/dist/types/CrossDomainAuth.d.ts +23 -0
  76. package/dist/types/OxyServices.base.d.ts +14 -0
  77. package/dist/types/OxyServices.d.ts +7 -0
  78. package/dist/types/index.d.ts +31 -17
  79. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.assets.d.ts +4 -1
  82. package/dist/types/mixins/OxyServices.auth.d.ts +73 -1
  83. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.features.d.ts +2 -5
  87. package/dist/types/mixins/OxyServices.fedcm.d.ts +34 -0
  88. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  90. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  92. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  93. package/dist/types/mixins/OxyServices.popup.d.ts +40 -0
  94. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  95. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  96. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  97. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  98. package/dist/types/mixins/OxyServices.user.d.ts +16 -1
  99. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  100. package/dist/types/models/interfaces.d.ts +98 -0
  101. package/dist/types/models/session.d.ts +8 -0
  102. package/dist/types/utils/accountUtils.d.ts +33 -0
  103. package/dist/types/utils/coldBoot.d.ts +102 -0
  104. package/dist/types/utils/fapiAutoDetect.d.ts +37 -0
  105. package/package.json +9 -18
  106. package/src/AuthManager.ts +776 -7
  107. package/src/AuthManagerTypes.ts +72 -0
  108. package/src/CrossDomainAuth.ts +54 -3
  109. package/src/OxyServices.base.ts +17 -0
  110. package/src/OxyServices.ts +7 -0
  111. package/src/__tests__/authManager.cookiePath.test.ts +339 -0
  112. package/src/__tests__/authManager.security.test.ts +342 -0
  113. package/src/__tests__/crossDomainAuth.test.ts +191 -0
  114. package/src/i18n/locales/ar-SA.json +83 -1
  115. package/src/i18n/locales/ca-ES.json +83 -1
  116. package/src/i18n/locales/de-DE.json +83 -1
  117. package/src/i18n/locales/en-US.json +83 -0
  118. package/src/i18n/locales/es-ES.json +99 -4
  119. package/src/i18n/locales/fr-FR.json +83 -1
  120. package/src/i18n/locales/it-IT.json +83 -1
  121. package/src/i18n/locales/ja-JP.json +200 -117
  122. package/src/i18n/locales/ko-KR.json +83 -1
  123. package/src/i18n/locales/pt-PT.json +83 -1
  124. package/src/i18n/locales/zh-CN.json +83 -1
  125. package/src/index.ts +309 -112
  126. package/src/mixins/OxyServices.auth.ts +268 -1
  127. package/src/mixins/OxyServices.fedcm.ts +63 -0
  128. package/src/mixins/OxyServices.popup.ts +79 -1
  129. package/src/mixins/OxyServices.user.ts +33 -1
  130. package/src/mixins/__tests__/popup.test.ts +307 -0
  131. package/src/mixins/__tests__/sessionBaseUrl.test.ts +61 -0
  132. package/src/models/interfaces.ts +116 -0
  133. package/src/models/session.ts +8 -0
  134. package/src/utils/__tests__/coldBoot.test.ts +226 -0
  135. package/src/utils/__tests__/fapiAutoDetect.test.ts +93 -0
  136. package/src/utils/accountUtils.ts +84 -0
  137. package/src/utils/coldBoot.ts +136 -0
  138. package/src/utils/fapiAutoDetect.ts +82 -0
  139. package/dist/cjs/crypto/index.js +0 -22
  140. package/dist/cjs/shared/index.js +0 -70
  141. package/dist/cjs/utils/index.js +0 -26
  142. package/dist/esm/crypto/index.js +0 -13
  143. package/dist/esm/shared/index.js +0 -31
  144. package/dist/esm/utils/index.js +0 -7
  145. package/dist/types/crypto/index.d.ts +0 -11
  146. package/dist/types/shared/index.d.ts +0 -28
  147. package/dist/types/utils/index.d.ts +0 -6
  148. package/src/crypto/index.ts +0 -30
  149. package/src/shared/index.ts +0 -82
  150. package/src/utils/index.ts +0 -21
@@ -10,6 +10,18 @@
10
10
  import type { OxyServices } from './OxyServices';
11
11
  import type { HttpService } from './HttpService';
12
12
  import type { SessionLoginResponse, MinimalUserData } from './models/session';
13
+ import type {
14
+ RefreshAllAccount,
15
+ RefreshAllAccountUser,
16
+ RefreshAllResponse,
17
+ RefreshCookieResponse,
18
+ User,
19
+ } from './models/interfaces';
20
+ import type {
21
+ AuthManagerAccount,
22
+ RestoreFromCookiesResult,
23
+ SwitchAuthuserResult,
24
+ } from './AuthManagerTypes';
13
25
  import { retryAsync } from './utils/asyncUtils';
14
26
  import { jwtDecode } from 'jwt-decode';
15
27
 
@@ -50,15 +62,54 @@ export interface AuthManagerConfig {
50
62
  refreshBuffer?: number;
51
63
  /** Enable cross-tab coordination via BroadcastChannel (default: true in browsers) */
52
64
  crossTabSync?: boolean;
65
+ /**
66
+ * "Cookie-only" mode for web apps that rely exclusively on the
67
+ * `oxy_rt_${authuser}` httpOnly refresh cookies and refuse to fall back
68
+ * to the legacy localStorage token/refresh-token path.
69
+ *
70
+ * - `false` (default): `initialize()` tries `restoreFromCookies()` first;
71
+ * if no accounts are restored it falls back to the legacy localStorage
72
+ * path (`oxy_access_token` / `oxy_session`).
73
+ * - `true`: `initialize()` ONLY uses `restoreFromCookies()`. No token /
74
+ * refresh-token / session JSON is read from or written to localStorage.
75
+ * This is the secure default for apps that ship the cookie path end-to-
76
+ * end and want to guarantee no tokens leak to JS-accessible storage.
77
+ */
78
+ cookieOnly?: boolean;
53
79
  }
54
80
 
55
81
  /**
56
82
  * Messages sent between tabs via BroadcastChannel for token refresh coordination.
83
+ *
84
+ * Legacy bearer-path messages (`refresh_starting`, `tokens_refreshed`,
85
+ * `signed_out`) coexist with the multi-account cookie-path messages
86
+ * (`accounts_restored`, `authuser_switched`, `authuser_signed_out`,
87
+ * `all_signed_out`). The handler ignores unknown types defensively so a
88
+ * mismatched-version sibling tab can't crash this one.
89
+ *
90
+ * Every outgoing message also carries the sender tab's `tabId` and `nonce`
91
+ * (see `_broadcastNonce` / `_tabId` on AuthManager). The receiver records the
92
+ * first (tabId, nonce) pair it sees from each tab and rejects any subsequent
93
+ * message from the same tabId that does not present the same nonce — a
94
+ * best-effort gate against forged broadcasts from a same-origin XSS payload.
57
95
  */
58
96
  interface CrossTabMessage {
59
- type: 'refresh_starting' | 'tokens_refreshed' | 'signed_out';
97
+ type:
98
+ | 'refresh_starting'
99
+ | 'tokens_refreshed'
100
+ | 'signed_out'
101
+ | 'accounts_restored'
102
+ | 'authuser_switched'
103
+ | 'authuser_signed_out'
104
+ | 'all_signed_out';
60
105
  sessionId?: string;
106
+ /** Slot index for `authuser_*` events; absent on legacy bearer events. */
107
+ authuser?: number;
61
108
  timestamp: number;
109
+ /** Sender-tab identifier (random hex, generated at AuthManager construction). */
110
+ tabId: string;
111
+ /** Sender-tab nonce (random hex, generated at AuthManager construction). */
112
+ nonce: string;
62
113
  }
63
114
 
64
115
  /**
@@ -71,6 +122,14 @@ const STORAGE_KEYS = {
71
122
  USER: 'oxy_user',
72
123
  AUTH_METHOD: 'oxy_auth_method',
73
124
  FEDCM_LOGIN_HINT: 'oxy_fedcm_login_hint',
125
+ /**
126
+ * Persisted active `authuser` slot index for the cookie path. Stores ONLY
127
+ * the integer slot index (e.g. `"0"`, `"1"`), never a token or session
128
+ * id — that lives in the httpOnly `oxy_rt_${n}` cookie. Used so that a
129
+ * cold-boot `restoreFromCookies()` lands on the user's last-chosen slot
130
+ * instead of always defaulting to the lowest authuser.
131
+ */
132
+ ACTIVE_AUTHUSER: 'oxy_active_authuser',
74
133
  } as const;
75
134
 
76
135
  /**
@@ -156,7 +215,10 @@ export class AuthManager {
156
215
  private currentUser: MinimalUserData | null = null;
157
216
  private refreshTimer: ReturnType<typeof setTimeout> | null = null;
158
217
  private refreshPromise: Promise<boolean> | null = null;
159
- private config: Required<Omit<AuthManagerConfig, 'crossTabSync'>> & { crossTabSync: boolean };
218
+ private config: Required<Omit<AuthManagerConfig, 'crossTabSync' | 'cookieOnly'>> & {
219
+ crossTabSync: boolean;
220
+ cookieOnly: boolean;
221
+ };
160
222
 
161
223
  /** Tracks the access token this instance last knew about, for cross-tab adoption. */
162
224
  private _lastKnownAccessToken: string | null = null;
@@ -167,6 +229,73 @@ export class AuthManager {
167
229
  /** Set to true when another tab broadcasts a successful refresh, so this tab can skip its own. */
168
230
  private _otherTabRefreshed = false;
169
231
 
232
+ /**
233
+ * Identifier for this AuthManager instance (≈ "this tab"). Random hex
234
+ * generated at construction; advertised in every outgoing broadcast and
235
+ * used as the lookup key in `_knownPeerNonces`.
236
+ */
237
+ private readonly _tabId: string = AuthManager._randomHex(16);
238
+
239
+ /**
240
+ * Per-tab nonce, advertised in every outgoing broadcast. Receivers record
241
+ * the first (tabId, nonce) pair they see from a given peer; subsequent
242
+ * messages from the same tabId MUST carry the same nonce or they're
243
+ * ignored.
244
+ *
245
+ * Threat model: a same-origin XSS payload can post to the channel but can
246
+ * NOT read this instance's private `_broadcastNonce` field (it lives in
247
+ * closure, not on `window`). Forged broadcasts from XSS therefore can't
248
+ * impersonate this tab. A new attacker-controlled tabId trips the
249
+ * "first message from a new peer" branch, which is by definition trusted
250
+ * — so the gate raises the bar but is not a complete defence (a perfect
251
+ * mitigation would require message signing with a server-issued key).
252
+ */
253
+ private readonly _broadcastNonce: string = AuthManager._randomHex(16);
254
+
255
+ /**
256
+ * Bounded LRU of `(tabId → nonce)` pairs seen on inbound broadcasts. First
257
+ * sighting of a new tabId records its nonce; later messages from that
258
+ * tabId are rejected if the nonce doesn't match.
259
+ */
260
+ private readonly _knownPeerNonces: Map<string, string> = new Map();
261
+ private static readonly _MAX_KNOWN_PEERS = 32;
262
+
263
+ /**
264
+ * In-flight `switchAuthuser` promise. Deduplicates concurrent calls so two
265
+ * near-simultaneous switches don't both fire refresh requests and rotate
266
+ * the slot twice. Mirrors the `refreshPromise` pattern used by
267
+ * `refreshToken`.
268
+ */
269
+ private _switchPromise: Promise<SwitchAuthuserResult> | null = null;
270
+
271
+ /**
272
+ * Last `restoreFromCookies()` completion timestamp, keyed by the
273
+ * AuthManager's active authuser at the time of completion. Used to gate
274
+ * cross-tab cascade: a flurry of BroadcastChannel events from sibling
275
+ * tabs can otherwise trigger N back-to-back snapshots and rotate every
276
+ * slot's access token N times.
277
+ */
278
+ private readonly _lastRestoreAt: Map<number, number> = new Map();
279
+ private static readonly _RESTORE_DEBOUNCE_MS = 2000;
280
+
281
+ /**
282
+ * In-memory registry of every device-local account the AuthManager knows
283
+ * about, keyed by `authuser` slot index. Populated by:
284
+ * - `restoreFromCookies()` (cold boot)
285
+ * - `switchAuthuser()` (per-account rotation)
286
+ * - `handleAuthSuccess()` (fresh login when the server response carries
287
+ * an `authuser` field)
288
+ * Access tokens live ONLY here in the cookie path — they are never
289
+ * persisted to localStorage.
290
+ */
291
+ private accounts: Map<number, AuthManagerAccount> = new Map();
292
+
293
+ /**
294
+ * Currently-active `authuser` slot in the cookie path. `null` means either
295
+ * the cookie path hasn't been initialised yet, or no slots are signed in.
296
+ */
297
+ private activeAuthuser: number | null = null;
298
+
170
299
  constructor(oxyServices: OxyServices, config: AuthManagerConfig = {}) {
171
300
  this.oxyServices = oxyServices;
172
301
  const crossTabSync = config.crossTabSync ?? (typeof BroadcastChannel !== 'undefined');
@@ -175,6 +304,7 @@ export class AuthManager {
175
304
  autoRefresh: config.autoRefresh ?? true,
176
305
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
177
306
  crossTabSync,
307
+ cookieOnly: config.cookieOnly ?? false,
178
308
  };
179
309
  this.storage = this.config.storage;
180
310
 
@@ -213,6 +343,7 @@ export class AuthManager {
213
343
  */
214
344
  private async _handleCrossTabMessage(message: CrossTabMessage): Promise<void> {
215
345
  if (!message || !message.type) return;
346
+ if (!this._acceptBroadcast(message)) return;
216
347
 
217
348
  switch (message.type) {
218
349
  case 'tokens_refreshed': {
@@ -254,21 +385,136 @@ export class AuthManager {
254
385
  this.notifyListeners();
255
386
  break;
256
387
  }
388
+
389
+ case 'accounts_restored':
390
+ case 'authuser_switched':
391
+ case 'authuser_signed_out': {
392
+ // Another tab restored/switched/dropped a slot. The authoritative
393
+ // state lives in the httpOnly cookies which we can't read from JS,
394
+ // so the cleanest reaction is to re-run `restoreFromCookies()` on
395
+ // a microtask and re-sync our in-memory registry. We swallow
396
+ // failures: a transient network error must not bring down a tab
397
+ // that already had a valid session.
398
+ //
399
+ // The restoreFromCookies() body owns the per-slot debounce so a
400
+ // burst of N broadcasts only costs one /auth/refresh-all rotation
401
+ // (instead of N back-to-back rotations of every cookie slot).
402
+ Promise.resolve().then(() => {
403
+ this.restoreFromCookies().catch(() => {
404
+ // Best-effort; existing accounts (if any) remain intact.
405
+ });
406
+ });
407
+ break;
408
+ }
409
+
410
+ case 'all_signed_out': {
411
+ // Mirror `signed_out` but also wipe the cookie-path registry.
412
+ if (this.refreshTimer) {
413
+ clearTimeout(this.refreshTimer);
414
+ this.refreshTimer = null;
415
+ }
416
+ this.refreshPromise = null;
417
+ this.accounts.clear();
418
+ this.activeAuthuser = null;
419
+ this._lastKnownAccessToken = null;
420
+ this.oxyServices.httpService.setTokens('');
421
+ this.currentUser = null;
422
+ this.notifyListeners();
423
+ break;
424
+ }
425
+
257
426
  // 'refresh_starting' is informational; we don't need to act on it currently
258
427
  }
259
428
  }
260
429
 
261
430
  /**
262
- * Broadcast a message to other tabs.
431
+ * Broadcast a message to other tabs. Always stamps this tab's `tabId` and
432
+ * `nonce` onto the message so receivers can run the cross-tab nonce gate.
263
433
  */
264
- private _broadcast(message: CrossTabMessage): void {
434
+ private _broadcast(message: Omit<CrossTabMessage, 'tabId' | 'nonce'>): void {
435
+ const stamped: CrossTabMessage = {
436
+ ...message,
437
+ tabId: this._tabId,
438
+ nonce: this._broadcastNonce,
439
+ };
265
440
  try {
266
- this._broadcastChannel?.postMessage(message);
441
+ this._broadcastChannel?.postMessage(stamped);
267
442
  } catch {
268
443
  // Channel closed or unavailable
269
444
  }
270
445
  }
271
446
 
447
+ /**
448
+ * Generate `bytes` bytes of cryptographic randomness encoded as lowercase
449
+ * hex. Prefers Web Crypto's `getRandomValues` when available (browser /
450
+ * modern Node); falls back to `Math.random` ONLY in environments without
451
+ * Web Crypto (the resulting nonce is still unguessable to a same-origin
452
+ * XSS payload — the goal is unforgeability across tabs, not cryptographic
453
+ * secrecy across the network).
454
+ */
455
+ private static _randomHex(bytes: number): string {
456
+ const buffer = new Uint8Array(bytes);
457
+ const gcrypto: Crypto | undefined =
458
+ typeof globalThis !== 'undefined'
459
+ ? (globalThis as { crypto?: Crypto }).crypto
460
+ : undefined;
461
+ if (gcrypto && typeof gcrypto.getRandomValues === 'function') {
462
+ gcrypto.getRandomValues(buffer);
463
+ } else {
464
+ for (let i = 0; i < buffer.length; i++) {
465
+ buffer[i] = Math.floor(Math.random() * 256);
466
+ }
467
+ }
468
+ let hex = '';
469
+ for (const byte of buffer) {
470
+ hex += byte.toString(16).padStart(2, '0');
471
+ }
472
+ return hex;
473
+ }
474
+
475
+ /**
476
+ * Validate an inbound broadcast against the cross-tab nonce gate.
477
+ *
478
+ * Returns `true` when the message should be honoured, `false` when it
479
+ * MUST be ignored:
480
+ * - Message is missing `tabId` or `nonce` → ignore (forged or
481
+ * mismatched-version sibling tab).
482
+ * - First sighting of `tabId` → record the nonce and honour the message
483
+ * (trust-on-first-use, the best we can do without a shared secret).
484
+ * - Subsequent message from the same `tabId` with the SAME nonce →
485
+ * honour.
486
+ * - Subsequent message from the same `tabId` with a DIFFERENT nonce →
487
+ * ignore (the canonical "forged broadcast" case — a same-origin XSS
488
+ * payload can't read the real tab's `_broadcastNonce`).
489
+ *
490
+ * Echoes of this tab's own broadcasts (same `tabId`) are also dropped so
491
+ * we don't react to our own messages.
492
+ */
493
+ private _acceptBroadcast(message: CrossTabMessage | null | undefined): boolean {
494
+ if (!message || typeof message.tabId !== 'string' || typeof message.nonce !== 'string') {
495
+ return false;
496
+ }
497
+ if (message.tabId === this._tabId) {
498
+ // Same-tab echo. Some BroadcastChannel implementations deliver our own
499
+ // posts back to us; never act on those.
500
+ return false;
501
+ }
502
+ const seen = this._knownPeerNonces.get(message.tabId);
503
+ if (seen === undefined) {
504
+ // Trust-on-first-use. Bound the map to avoid unbounded growth from a
505
+ // tab-id sprayer.
506
+ if (this._knownPeerNonces.size >= AuthManager._MAX_KNOWN_PEERS) {
507
+ const oldest = this._knownPeerNonces.keys().next().value;
508
+ if (oldest !== undefined) {
509
+ this._knownPeerNonces.delete(oldest);
510
+ }
511
+ }
512
+ this._knownPeerNonces.set(message.tabId, message.nonce);
513
+ return true;
514
+ }
515
+ return seen === message.nonce;
516
+ }
517
+
272
518
  /**
273
519
  * Get default storage based on environment.
274
520
  */
@@ -635,11 +881,36 @@ export class AuthManager {
635
881
  }
636
882
 
637
883
  /**
638
- * Initialize auth state from storage.
884
+ * Initialize auth state on app startup.
639
885
  *
640
- * Call this on app startup to restore previous session.
886
+ * Order of operations:
887
+ * 1. Try the cookie path via `restoreFromCookies()`. This is the
888
+ * preferred path because the httpOnly refresh cookies are
889
+ * cross-tab, persist across hard reloads, and don't expose any
890
+ * refresh-token material to JS.
891
+ * 2. If the cookie path yielded zero accounts AND `cookieOnly` is
892
+ * `false`, fall back to the legacy localStorage path
893
+ * (`oxy_access_token` / `oxy_session`) for backwards compatibility
894
+ * with apps that haven't migrated to the cookie endpoint yet.
895
+ * 3. If `cookieOnly` is `true`, skip the legacy fallback entirely.
896
+ * This guarantees no tokens or refresh tokens are ever read from
897
+ * or written to JS-accessible storage.
898
+ *
899
+ * Returns the active user on success, or `null` when neither path
900
+ * restored a session.
641
901
  */
642
902
  async initialize(): Promise<MinimalUserData | null> {
903
+ // 1. Cookie path (preferred).
904
+ const cookieResult = await this.restoreFromCookies();
905
+ if (cookieResult.accounts.length > 0) {
906
+ return this.currentUser;
907
+ }
908
+
909
+ // 2. Legacy localStorage path (opt-out via `cookieOnly`).
910
+ if (this.config.cookieOnly) {
911
+ return null;
912
+ }
913
+
643
914
  try {
644
915
  // Try to restore user from storage
645
916
  const userJson = await this.storage.getItem(STORAGE_KEYS.USER);
@@ -683,6 +954,500 @@ export class AuthManager {
683
954
  }
684
955
  }
685
956
 
957
+ // -------------------------------------------------------------------------
958
+ // Multi-account cookie path (Google-style multi-sign-in).
959
+ // -------------------------------------------------------------------------
960
+ // The cookie path is web-only and orthogonal to the legacy bearer path
961
+ // above: it never touches the `oxy_access_token` / `oxy_refresh_token` /
962
+ // `oxy_session` localStorage keys, because the refresh token lives in the
963
+ // httpOnly `oxy_rt_${authuser}` cookies and access tokens live in
964
+ // `this.accounts` (in-memory only). The only localStorage key the cookie
965
+ // path writes is `STORAGE_KEYS.ACTIVE_AUTHUSER` — a small integer that is
966
+ // explicitly NOT a secret.
967
+ //
968
+ // Apps that want to opt out of the legacy localStorage path entirely
969
+ // (recommended for new web apps) pass `cookieOnly: true` to the
970
+ // AuthManager config; in that mode `initialize()` ONLY uses the cookie
971
+ // path.
972
+ // -------------------------------------------------------------------------
973
+
974
+ /**
975
+ * Read the persisted active `authuser` slot index. Returns `null` when
976
+ * none is persisted, the value is corrupt, or the storage adapter has no
977
+ * record. Storage failures are non-fatal: the cookie path falls back to
978
+ * "lowest authuser" deterministic selection.
979
+ */
980
+ private async readActiveAuthuser(): Promise<number | null> {
981
+ try {
982
+ const raw = await this.storage.getItem(STORAGE_KEYS.ACTIVE_AUTHUSER);
983
+ if (raw === null || raw === undefined) return null;
984
+ const parsed = Number.parseInt(raw, 10);
985
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
986
+ return parsed;
987
+ } catch {
988
+ return null;
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Persist the active `authuser` slot index. No-ops on storage failure
994
+ * (e.g. Safari private mode, native SecureStore unavailable) — this is
995
+ * best-effort UX persistence, not authoritative state.
996
+ */
997
+ private async writeActiveAuthuser(authuser: number): Promise<void> {
998
+ if (!Number.isFinite(authuser) || authuser < 0) return;
999
+ try {
1000
+ await this.storage.setItem(STORAGE_KEYS.ACTIVE_AUTHUSER, String(authuser));
1001
+ } catch {
1002
+ // Best-effort.
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * Clear the persisted active `authuser` so the next cold boot starts from
1008
+ * a clean slate (used on full sign-out).
1009
+ */
1010
+ private async clearActiveAuthuser(): Promise<void> {
1011
+ try {
1012
+ await this.storage.removeItem(STORAGE_KEYS.ACTIVE_AUTHUSER);
1013
+ } catch {
1014
+ // Best-effort.
1015
+ }
1016
+ }
1017
+
1018
+ /**
1019
+ * Build a `MinimalUserData` from a `RefreshAllAccount`. Returns `null` when
1020
+ * the wire entry has no user shape (legacy `/auth/refresh` fallback) — the
1021
+ * AuthManager's caller is expected to hydrate via `/users/me` in that
1022
+ * case.
1023
+ */
1024
+ private static toMinimalUser(account: RefreshAllAccount): MinimalUserData | null {
1025
+ if (!account.user) return null;
1026
+ return {
1027
+ id: account.user.id,
1028
+ username: account.user.username,
1029
+ avatar: account.user.avatar ?? undefined,
1030
+ };
1031
+ }
1032
+
1033
+ /**
1034
+ * Hydrate the user shape for a slot whose AuthManagerAccount currently has
1035
+ * `user: null` (legacy refresh fallback, or a switch onto a previously
1036
+ * unknown slot). Calls `/users/me` with the slot's freshly-planted access
1037
+ * token already on the HTTP client; merges the result back into the
1038
+ * registry entry. Network failures are non-fatal — the slot remains with
1039
+ * `user: null` and the UI is expected to render the public-key fallback
1040
+ * handle until a later restore picks the real user shape up.
1041
+ */
1042
+ private async _hydrateUnknownUser(authuser: number): Promise<void> {
1043
+ const oxy = this.oxyServices as OxyServices & {
1044
+ getCurrentUser?: () => Promise<User>;
1045
+ };
1046
+ if (typeof oxy.getCurrentUser !== 'function') return;
1047
+
1048
+ let me: User;
1049
+ try {
1050
+ me = await oxy.getCurrentUser();
1051
+ } catch {
1052
+ // Best-effort: keep `user: null` and let the UI fall back to the
1053
+ // public-key handle.
1054
+ return;
1055
+ }
1056
+
1057
+ const existing = this.accounts.get(authuser);
1058
+ if (!existing) return;
1059
+
1060
+ const hydrated: RefreshAllAccountUser = {
1061
+ id: me.id,
1062
+ username: me.username,
1063
+ name: typeof me.name === 'string' ? me.name : undefined,
1064
+ avatar: me.avatar ?? null,
1065
+ email: me.email,
1066
+ color: me.color ?? null,
1067
+ };
1068
+ this.accounts.set(authuser, { ...existing, user: hydrated });
1069
+
1070
+ // Mirror onto `currentUser` if this is the active slot.
1071
+ if (this.activeAuthuser === authuser) {
1072
+ this.currentUser = {
1073
+ id: hydrated.id,
1074
+ username: hydrated.username,
1075
+ avatar: hydrated.avatar ?? undefined,
1076
+ };
1077
+ this.notifyListeners();
1078
+ }
1079
+ }
1080
+
1081
+ /**
1082
+ * Snapshot of the registered cookie-path accounts, sorted by `authuser`
1083
+ * ascending (canonical order). Mutating the returned array does not
1084
+ * affect AuthManager state.
1085
+ */
1086
+ getAccounts(): AuthManagerAccount[] {
1087
+ return Array.from(this.accounts.values()).sort((a, b) => a.authuser - b.authuser);
1088
+ }
1089
+
1090
+ /**
1091
+ * The slot index that is currently active in the cookie path, or `null`
1092
+ * if the cookie path hasn't been initialised or no slots are signed in.
1093
+ */
1094
+ getActiveAuthuser(): number | null {
1095
+ return this.activeAuthuser;
1096
+ }
1097
+
1098
+ /**
1099
+ * Convenience: the AuthManagerAccount currently flagged active.
1100
+ */
1101
+ getActiveAccount(): AuthManagerAccount | null {
1102
+ if (this.activeAuthuser === null) return null;
1103
+ return this.accounts.get(this.activeAuthuser) ?? null;
1104
+ }
1105
+
1106
+ /**
1107
+ * Restore every device-local account from the httpOnly refresh cookies.
1108
+ *
1109
+ * Calls `oxyServices.refreshAllSessions()` (`POST /auth/refresh-all` with
1110
+ * `credentials: 'include'`). The server rotates every presented
1111
+ * `oxy_rt_${authuser}` cookie in parallel and returns one entry per
1112
+ * VALID slot. The SDK transparently falls back to the legacy single-slot
1113
+ * `/auth/refresh` against older servers (handled inside
1114
+ * `refreshAllSessions`).
1115
+ *
1116
+ * Plants the active account's access token on the shared HTTP client;
1117
+ * sibling slots' tokens stay in the in-memory registry so a later
1118
+ * `switchAuthuser()` can hot-swap them without a network round-trip.
1119
+ *
1120
+ * The persisted `oxy_active_authuser` slot wins when it matches a
1121
+ * returned account; otherwise the lowest returned `authuser` is chosen
1122
+ * deterministically.
1123
+ *
1124
+ * Returns `{ accounts: [], activeAuthuser: null }` on any failure or
1125
+ * empty snapshot — callers treat that as "no signed-in accounts" and
1126
+ * proceed unauthenticated. State is NOT cleared on failure; existing
1127
+ * accounts (if any) remain intact.
1128
+ */
1129
+ async restoreFromCookies(): Promise<RestoreFromCookiesResult> {
1130
+ // Cross-tab cascade debounce. If we restored within the last
1131
+ // _RESTORE_DEBOUNCE_MS for the currently-active slot, skip the network
1132
+ // round-trip and return the cached registry verbatim. A burst of N
1133
+ // BroadcastChannel events from sibling tabs therefore costs at most one
1134
+ // /auth/refresh-all rotation. Cold-boot calls (activeAuthuser still
1135
+ // null) always run because the cache hasn't been seeded yet.
1136
+ if (this.activeAuthuser !== null) {
1137
+ const last = this._lastRestoreAt.get(this.activeAuthuser);
1138
+ if (last !== undefined && Date.now() - last < AuthManager._RESTORE_DEBOUNCE_MS) {
1139
+ return {
1140
+ accounts: this.getAccounts(),
1141
+ activeAuthuser: this.activeAuthuser,
1142
+ };
1143
+ }
1144
+ }
1145
+
1146
+ let snapshot: RefreshAllResponse;
1147
+ try {
1148
+ snapshot = await this.oxyServices.refreshAllSessions();
1149
+ } catch {
1150
+ return { accounts: [], activeAuthuser: null };
1151
+ }
1152
+
1153
+ if (snapshot.accounts.length === 0) {
1154
+ return { accounts: [], activeAuthuser: null };
1155
+ }
1156
+
1157
+ // Replace the registry wholesale: the server's snapshot is authoritative.
1158
+ this.accounts.clear();
1159
+ for (const account of snapshot.accounts) {
1160
+ this.accounts.set(account.authuser, {
1161
+ authuser: account.authuser,
1162
+ sessionId: account.sessionId,
1163
+ user: account.user,
1164
+ accessToken: account.accessToken,
1165
+ expiresAt: account.expiresAt,
1166
+ });
1167
+ }
1168
+
1169
+ // Pick the active slot: persisted `oxy_active_authuser` wins if it
1170
+ // matches a returned account; otherwise the lowest returned authuser
1171
+ // (the snapshot is already sorted ascending, so accounts[0] is the
1172
+ // lowest).
1173
+ const persisted = await this.readActiveAuthuser();
1174
+ const active = (persisted !== null && this.accounts.has(persisted))
1175
+ ? persisted
1176
+ : snapshot.accounts[0].authuser;
1177
+
1178
+ this.activeAuthuser = active;
1179
+ const activeAccount = this.accounts.get(active);
1180
+ const slotsNeedingHydration: number[] = [];
1181
+ if (activeAccount) {
1182
+ this._lastKnownAccessToken = activeAccount.accessToken;
1183
+ this.oxyServices.httpService.setTokens(activeAccount.accessToken);
1184
+ this.currentUser = AuthManager.toMinimalUser({
1185
+ authuser: activeAccount.authuser,
1186
+ accessToken: activeAccount.accessToken,
1187
+ expiresAt: activeAccount.expiresAt,
1188
+ sessionId: activeAccount.sessionId,
1189
+ user: activeAccount.user,
1190
+ });
1191
+ await this.writeActiveAuthuser(active);
1192
+
1193
+ // Schedule auto-refresh on the active slot so the in-memory access
1194
+ // token doesn't silently expire under the user.
1195
+ if (this.config.autoRefresh) {
1196
+ this.setupCookieRefresh(activeAccount.expiresAt, active);
1197
+ }
1198
+
1199
+ // The legacy /auth/refresh fallback yields user=null for the active
1200
+ // slot. Schedule a /users/me hydration so the chooser isn't stuck on
1201
+ // the public-key handle. Hydration is fire-and-forget — the snapshot
1202
+ // is already considered "restored" once the access token is planted.
1203
+ if (activeAccount.user === null) {
1204
+ slotsNeedingHydration.push(activeAccount.authuser);
1205
+ }
1206
+ }
1207
+
1208
+ this._lastRestoreAt.set(active, Date.now());
1209
+ this._broadcast({ type: 'accounts_restored', timestamp: Date.now() });
1210
+ this.notifyListeners();
1211
+
1212
+ for (const slot of slotsNeedingHydration) {
1213
+ void this._hydrateUnknownUser(slot);
1214
+ }
1215
+
1216
+ return {
1217
+ accounts: this.getAccounts(),
1218
+ activeAuthuser: this.activeAuthuser,
1219
+ };
1220
+ }
1221
+
1222
+ /**
1223
+ * Switch the active account to a different device-local slot.
1224
+ *
1225
+ * Calls `oxyServices.refreshTokenViaCookie({ authuser })` to mint a fresh
1226
+ * access token from the slot's httpOnly cookie, updates the in-memory
1227
+ * registry entry, plants the token on the HTTP client, persists the new
1228
+ * active slot, and broadcasts cross-tab.
1229
+ *
1230
+ * Throws when the slot's refresh cookie is missing / expired / reused
1231
+ * (the SDK returns `null` from `refreshTokenViaCookie` in that case, and
1232
+ * we surface it as an `Error` so callers can clean up the slot from
1233
+ * their UI).
1234
+ */
1235
+ async switchAuthuser(authuser: number): Promise<SwitchAuthuserResult> {
1236
+ // Concurrency gate. Two near-simultaneous switchAuthuser calls would
1237
+ // otherwise both POST /auth/refresh?authuser=N, rotating the slot's
1238
+ // refresh-token family twice and racing on the registry update. The
1239
+ // gate is keyed only by "any switch in flight" — switching to a
1240
+ // DIFFERENT slot while a switch is in flight returns the in-flight
1241
+ // promise (callers can re-issue once it settles if they really meant a
1242
+ // different slot).
1243
+ if (this._switchPromise) {
1244
+ return this._switchPromise;
1245
+ }
1246
+ this._switchPromise = this._doSwitchAuthuser(authuser);
1247
+ try {
1248
+ return await this._switchPromise;
1249
+ } finally {
1250
+ this._switchPromise = null;
1251
+ }
1252
+ }
1253
+
1254
+ private async _doSwitchAuthuser(authuser: number): Promise<SwitchAuthuserResult> {
1255
+ const refreshed: RefreshCookieResponse | null = await this.oxyServices.refreshTokenViaCookie({ authuser });
1256
+ if (refreshed === null) {
1257
+ // Drop the dead slot from our registry so the chooser doesn't keep
1258
+ // offering it; callers can drive a `restoreFromCookies()` to
1259
+ // re-sync.
1260
+ this.accounts.delete(authuser);
1261
+ if (this.activeAuthuser === authuser) {
1262
+ this.activeAuthuser = null;
1263
+ }
1264
+ throw new Error(`Refresh cookie for authuser=${authuser} is missing or expired`);
1265
+ }
1266
+
1267
+ // Update (or insert) the slot in the registry. We preserve any user
1268
+ // metadata we already knew from a prior `restoreFromCookies` — the
1269
+ // single-slot refresh endpoint does NOT re-project the user shape. When
1270
+ // we have no prior metadata, we leave `user: null` and schedule a
1271
+ // /users/me hydration below.
1272
+ const existing = this.accounts.get(authuser);
1273
+ const decoded = AuthManager.decodeSessionIdFromAccessToken(refreshed.accessToken);
1274
+ const sessionId = decoded ?? existing?.sessionId ?? '';
1275
+ const updated: AuthManagerAccount = {
1276
+ authuser,
1277
+ sessionId,
1278
+ user: existing?.user ?? null,
1279
+ accessToken: refreshed.accessToken,
1280
+ expiresAt: refreshed.expiresAt,
1281
+ };
1282
+ this.accounts.set(authuser, updated);
1283
+
1284
+ this.activeAuthuser = authuser;
1285
+ this._lastKnownAccessToken = refreshed.accessToken;
1286
+ this.oxyServices.httpService.setTokens(refreshed.accessToken);
1287
+ this.currentUser = updated.user
1288
+ ? {
1289
+ id: updated.user.id,
1290
+ username: updated.user.username,
1291
+ avatar: updated.user.avatar ?? undefined,
1292
+ }
1293
+ : null;
1294
+ await this.writeActiveAuthuser(authuser);
1295
+
1296
+ if (this.config.autoRefresh) {
1297
+ this.setupCookieRefresh(refreshed.expiresAt, authuser);
1298
+ }
1299
+
1300
+ this._broadcast({ type: 'authuser_switched', authuser, timestamp: Date.now() });
1301
+ this.notifyListeners();
1302
+
1303
+ if (updated.user === null) {
1304
+ // Fire-and-forget hydration: the switch is considered complete once
1305
+ // the token is planted, the UI uses getAccountFallbackHandle (public-
1306
+ // key fallback) until /users/me resolves.
1307
+ void this._hydrateUnknownUser(authuser);
1308
+ }
1309
+
1310
+ return {
1311
+ accessToken: refreshed.accessToken,
1312
+ expiresAt: refreshed.expiresAt,
1313
+ authuser,
1314
+ };
1315
+ }
1316
+
1317
+ /**
1318
+ * Sign out a single device-local slot.
1319
+ *
1320
+ * Calls `oxyServices.logoutSessionByAuthuser(authuser)`: server-side
1321
+ * revokes the slot's refresh-token family and clears the
1322
+ * `oxy_rt_${authuser}` cookie via `Set-Cookie`. The slot is removed from
1323
+ * the in-memory registry. If the slot was active, the next lowest
1324
+ * remaining authuser becomes active (or `null` when none remain).
1325
+ */
1326
+ async signOutAuthuser(authuser: number): Promise<void> {
1327
+ try {
1328
+ await this.oxyServices.logoutSessionByAuthuser(authuser);
1329
+ } catch {
1330
+ // Best-effort: the server-side logout is idempotent on unknown
1331
+ // tokens, and we'd rather drop the slot locally than leave dead
1332
+ // state on a network blip.
1333
+ }
1334
+
1335
+ this.accounts.delete(authuser);
1336
+
1337
+ if (this.activeAuthuser === authuser) {
1338
+ const remaining = this.getAccounts();
1339
+ if (remaining.length > 0) {
1340
+ // Pick the lowest remaining authuser as the new active. We don't
1341
+ // proactively refresh its token here — callers can drive
1342
+ // `switchAuthuser` if they need a fresh bearer. This keeps the
1343
+ // method's network footprint to exactly one request.
1344
+ const next = remaining[0];
1345
+ this.activeAuthuser = next.authuser;
1346
+ this._lastKnownAccessToken = next.accessToken;
1347
+ this.oxyServices.httpService.setTokens(next.accessToken);
1348
+ this.currentUser = next.user
1349
+ ? {
1350
+ id: next.user.id,
1351
+ username: next.user.username,
1352
+ avatar: next.user.avatar ?? undefined,
1353
+ }
1354
+ : null;
1355
+ await this.writeActiveAuthuser(next.authuser);
1356
+ if (next.user === null) {
1357
+ void this._hydrateUnknownUser(next.authuser);
1358
+ }
1359
+ } else {
1360
+ this.activeAuthuser = null;
1361
+ this._lastKnownAccessToken = null;
1362
+ this.oxyServices.httpService.setTokens('');
1363
+ this.currentUser = null;
1364
+ await this.clearActiveAuthuser();
1365
+ }
1366
+ }
1367
+
1368
+ this._broadcast({ type: 'authuser_signed_out', authuser, timestamp: Date.now() });
1369
+ this.notifyListeners();
1370
+ }
1371
+
1372
+ /**
1373
+ * Sign out EVERY device-local account on this device.
1374
+ *
1375
+ * Calls `oxyServices.logoutAllSessionsViaCookie()`: server-side revokes
1376
+ * every presented family and `Set-Cookie`s an immediate expiry for every
1377
+ * recognised `oxy_rt_${n}` slot AND the legacy `oxy_rt` cookie. The
1378
+ * in-memory registry is wiped, the active slot is cleared, and the
1379
+ * persisted `oxy_active_authuser` is removed so the next cold boot
1380
+ * starts fresh.
1381
+ */
1382
+ async signOutAllViaCookies(): Promise<void> {
1383
+ try {
1384
+ await this.oxyServices.logoutAllSessionsViaCookie();
1385
+ } catch {
1386
+ // Best-effort; server-side endpoint is idempotent.
1387
+ }
1388
+
1389
+ this.accounts.clear();
1390
+ this.activeAuthuser = null;
1391
+ this._lastKnownAccessToken = null;
1392
+ this.oxyServices.httpService.setTokens('');
1393
+ this.currentUser = null;
1394
+ this._lastRestoreAt.clear();
1395
+ await this.clearActiveAuthuser();
1396
+
1397
+ // Also clear the refresh timer that the cookie path may have scheduled.
1398
+ if (this.refreshTimer) {
1399
+ clearTimeout(this.refreshTimer);
1400
+ this.refreshTimer = null;
1401
+ }
1402
+
1403
+ this._broadcast({ type: 'all_signed_out', timestamp: Date.now() });
1404
+ this.notifyListeners();
1405
+ }
1406
+
1407
+ /**
1408
+ * Schedule an auto-refresh for the cookie path on the active slot. Reuses
1409
+ * the same single `refreshTimer` as the legacy path (the AuthManager has
1410
+ * exactly ONE active slot at a time, so one timer suffices).
1411
+ */
1412
+ private setupCookieRefresh(expiresAt: string, authuser: number): void {
1413
+ if (this.refreshTimer) {
1414
+ clearTimeout(this.refreshTimer);
1415
+ }
1416
+
1417
+ const expiresAtMs = new Date(expiresAt).getTime();
1418
+ if (!Number.isFinite(expiresAtMs)) return;
1419
+
1420
+ const refreshAt = expiresAtMs - this.config.refreshBuffer;
1421
+ const delay = Math.max(0, refreshAt - Date.now());
1422
+
1423
+ this.refreshTimer = setTimeout(() => {
1424
+ // Only refresh if this slot is still the active one when the timer
1425
+ // fires (the user might have switched in the meantime).
1426
+ if (this.activeAuthuser !== authuser) return;
1427
+ this.switchAuthuser(authuser).catch(() => {
1428
+ // A failed cookie refresh on the active slot means the user must
1429
+ // re-auth; surface via `notifyListeners` indirectly when the slot
1430
+ // is dropped from the registry by `switchAuthuser`.
1431
+ });
1432
+ }, delay);
1433
+ }
1434
+
1435
+ /**
1436
+ * Decode the session id from an unverified JWT access token. Decode-only
1437
+ * (no signature verification) — the server already verified the
1438
+ * signature when minting the token. Returns `null` on malformed input.
1439
+ */
1440
+ private static decodeSessionIdFromAccessToken(token: string): string | null {
1441
+ try {
1442
+ const decoded = jwtDecode<{ sessionId?: string }>(token);
1443
+ return typeof decoded.sessionId === 'string' && decoded.sessionId.length > 0
1444
+ ? decoded.sessionId
1445
+ : null;
1446
+ } catch {
1447
+ return null;
1448
+ }
1449
+ }
1450
+
686
1451
  /**
687
1452
  * Destroy the auth manager and clean up resources.
688
1453
  */
@@ -692,6 +1457,10 @@ export class AuthManager {
692
1457
  this.refreshTimer = null;
693
1458
  }
694
1459
  this.listeners.clear();
1460
+ this._knownPeerNonces.clear();
1461
+ this._lastRestoreAt.clear();
1462
+ this._switchPromise = null;
1463
+ this.refreshPromise = null;
695
1464
 
696
1465
  // Close BroadcastChannel
697
1466
  if (this._broadcastChannel) {