@oxyhq/core 1.11.24 → 2.0.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 (140) 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 +114 -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/esm/.tsbuildinfo +1 -1
  36. package/dist/esm/AuthManager.js +678 -4
  37. package/dist/esm/AuthManagerTypes.js +12 -0
  38. package/dist/esm/CrossDomainAuth.js +45 -3
  39. package/dist/esm/OxyServices.base.js +16 -0
  40. package/dist/esm/i18n/locales/ar-SA.json +83 -0
  41. package/dist/esm/i18n/locales/ca-ES.json +83 -0
  42. package/dist/esm/i18n/locales/de-DE.json +83 -0
  43. package/dist/esm/i18n/locales/en-US.json +83 -0
  44. package/dist/esm/i18n/locales/es-ES.json +99 -4
  45. package/dist/esm/i18n/locales/fr-FR.json +83 -0
  46. package/dist/esm/i18n/locales/it-IT.json +83 -0
  47. package/dist/esm/i18n/locales/ja-JP.json +83 -0
  48. package/dist/esm/i18n/locales/ko-KR.json +83 -0
  49. package/dist/esm/i18n/locales/locales/ar-SA.json +83 -1
  50. package/dist/esm/i18n/locales/locales/ca-ES.json +83 -1
  51. package/dist/esm/i18n/locales/locales/de-DE.json +83 -1
  52. package/dist/esm/i18n/locales/locales/en-US.json +83 -0
  53. package/dist/esm/i18n/locales/locales/es-ES.json +99 -4
  54. package/dist/esm/i18n/locales/locales/fr-FR.json +83 -1
  55. package/dist/esm/i18n/locales/locales/it-IT.json +83 -1
  56. package/dist/esm/i18n/locales/locales/ja-JP.json +200 -117
  57. package/dist/esm/i18n/locales/locales/ko-KR.json +83 -1
  58. package/dist/esm/i18n/locales/locales/pt-PT.json +83 -1
  59. package/dist/esm/i18n/locales/locales/zh-CN.json +83 -1
  60. package/dist/esm/i18n/locales/pt-PT.json +83 -0
  61. package/dist/esm/i18n/locales/zh-CN.json +83 -0
  62. package/dist/esm/index.js +69 -26
  63. package/dist/esm/mixins/OxyServices.auth.js +235 -0
  64. package/dist/esm/mixins/OxyServices.fedcm.js +36 -0
  65. package/dist/esm/mixins/OxyServices.popup.js +61 -1
  66. package/dist/esm/mixins/OxyServices.user.js +18 -0
  67. package/dist/esm/utils/accountUtils.js +61 -0
  68. package/dist/types/.tsbuildinfo +1 -1
  69. package/dist/types/AuthManager.d.ts +243 -3
  70. package/dist/types/AuthManagerTypes.d.ts +68 -0
  71. package/dist/types/CrossDomainAuth.d.ts +23 -0
  72. package/dist/types/OxyServices.base.d.ts +14 -0
  73. package/dist/types/OxyServices.d.ts +7 -0
  74. package/dist/types/index.d.ts +28 -17
  75. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  76. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  77. package/dist/types/mixins/OxyServices.assets.d.ts +4 -1
  78. package/dist/types/mixins/OxyServices.auth.d.ts +73 -1
  79. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.features.d.ts +2 -5
  83. package/dist/types/mixins/OxyServices.fedcm.d.ts +34 -0
  84. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.popup.d.ts +40 -0
  90. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  92. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  93. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  94. package/dist/types/mixins/OxyServices.user.d.ts +16 -1
  95. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  96. package/dist/types/models/interfaces.d.ts +98 -0
  97. package/dist/types/models/session.d.ts +8 -0
  98. package/dist/types/utils/accountUtils.d.ts +33 -0
  99. package/package.json +9 -18
  100. package/src/AuthManager.ts +776 -7
  101. package/src/AuthManagerTypes.ts +72 -0
  102. package/src/CrossDomainAuth.ts +54 -3
  103. package/src/OxyServices.base.ts +17 -0
  104. package/src/OxyServices.ts +7 -0
  105. package/src/__tests__/authManager.cookiePath.test.ts +339 -0
  106. package/src/__tests__/authManager.security.test.ts +342 -0
  107. package/src/__tests__/crossDomainAuth.test.ts +191 -0
  108. package/src/i18n/locales/ar-SA.json +83 -1
  109. package/src/i18n/locales/ca-ES.json +83 -1
  110. package/src/i18n/locales/de-DE.json +83 -1
  111. package/src/i18n/locales/en-US.json +83 -0
  112. package/src/i18n/locales/es-ES.json +99 -4
  113. package/src/i18n/locales/fr-FR.json +83 -1
  114. package/src/i18n/locales/it-IT.json +83 -1
  115. package/src/i18n/locales/ja-JP.json +200 -117
  116. package/src/i18n/locales/ko-KR.json +83 -1
  117. package/src/i18n/locales/pt-PT.json +83 -1
  118. package/src/i18n/locales/zh-CN.json +83 -1
  119. package/src/index.ts +295 -112
  120. package/src/mixins/OxyServices.auth.ts +268 -1
  121. package/src/mixins/OxyServices.fedcm.ts +63 -0
  122. package/src/mixins/OxyServices.popup.ts +79 -1
  123. package/src/mixins/OxyServices.user.ts +33 -1
  124. package/src/mixins/__tests__/popup.test.ts +307 -0
  125. package/src/mixins/__tests__/sessionBaseUrl.test.ts +61 -0
  126. package/src/models/interfaces.ts +116 -0
  127. package/src/models/session.ts +8 -0
  128. package/src/utils/accountUtils.ts +84 -0
  129. package/dist/cjs/crypto/index.js +0 -22
  130. package/dist/cjs/shared/index.js +0 -70
  131. package/dist/cjs/utils/index.js +0 -26
  132. package/dist/esm/crypto/index.js +0 -13
  133. package/dist/esm/shared/index.js +0 -31
  134. package/dist/esm/utils/index.js +0 -7
  135. package/dist/types/crypto/index.d.ts +0 -11
  136. package/dist/types/shared/index.d.ts +0 -28
  137. package/dist/types/utils/index.d.ts +0 -6
  138. package/src/crypto/index.ts +0 -30
  139. package/src/shared/index.ts +0 -82
  140. package/src/utils/index.ts +0 -21
@@ -18,6 +18,14 @@ const STORAGE_KEYS = {
18
18
  USER: 'oxy_user',
19
19
  AUTH_METHOD: 'oxy_auth_method',
20
20
  FEDCM_LOGIN_HINT: 'oxy_fedcm_login_hint',
21
+ /**
22
+ * Persisted active `authuser` slot index for the cookie path. Stores ONLY
23
+ * the integer slot index (e.g. `"0"`, `"1"`), never a token or session
24
+ * id — that lives in the httpOnly `oxy_rt_${n}` cookie. Used so that a
25
+ * cold-boot `restoreFromCookies()` lands on the user's last-chosen slot
26
+ * instead of always defaulting to the lowest authuser.
27
+ */
28
+ ACTIVE_AUTHUSER: 'oxy_active_authuser',
21
29
  };
22
30
  /**
23
31
  * Default in-memory storage for non-browser environments.
@@ -108,6 +116,64 @@ export class AuthManager {
108
116
  this._broadcastChannel = null;
109
117
  /** Set to true when another tab broadcasts a successful refresh, so this tab can skip its own. */
110
118
  this._otherTabRefreshed = false;
119
+ /**
120
+ * Identifier for this AuthManager instance (≈ "this tab"). Random hex
121
+ * generated at construction; advertised in every outgoing broadcast and
122
+ * used as the lookup key in `_knownPeerNonces`.
123
+ */
124
+ this._tabId = AuthManager._randomHex(16);
125
+ /**
126
+ * Per-tab nonce, advertised in every outgoing broadcast. Receivers record
127
+ * the first (tabId, nonce) pair they see from a given peer; subsequent
128
+ * messages from the same tabId MUST carry the same nonce or they're
129
+ * ignored.
130
+ *
131
+ * Threat model: a same-origin XSS payload can post to the channel but can
132
+ * NOT read this instance's private `_broadcastNonce` field (it lives in
133
+ * closure, not on `window`). Forged broadcasts from XSS therefore can't
134
+ * impersonate this tab. A new attacker-controlled tabId trips the
135
+ * "first message from a new peer" branch, which is by definition trusted
136
+ * — so the gate raises the bar but is not a complete defence (a perfect
137
+ * mitigation would require message signing with a server-issued key).
138
+ */
139
+ this._broadcastNonce = AuthManager._randomHex(16);
140
+ /**
141
+ * Bounded LRU of `(tabId → nonce)` pairs seen on inbound broadcasts. First
142
+ * sighting of a new tabId records its nonce; later messages from that
143
+ * tabId are rejected if the nonce doesn't match.
144
+ */
145
+ this._knownPeerNonces = new Map();
146
+ /**
147
+ * In-flight `switchAuthuser` promise. Deduplicates concurrent calls so two
148
+ * near-simultaneous switches don't both fire refresh requests and rotate
149
+ * the slot twice. Mirrors the `refreshPromise` pattern used by
150
+ * `refreshToken`.
151
+ */
152
+ this._switchPromise = null;
153
+ /**
154
+ * Last `restoreFromCookies()` completion timestamp, keyed by the
155
+ * AuthManager's active authuser at the time of completion. Used to gate
156
+ * cross-tab cascade: a flurry of BroadcastChannel events from sibling
157
+ * tabs can otherwise trigger N back-to-back snapshots and rotate every
158
+ * slot's access token N times.
159
+ */
160
+ this._lastRestoreAt = new Map();
161
+ /**
162
+ * In-memory registry of every device-local account the AuthManager knows
163
+ * about, keyed by `authuser` slot index. Populated by:
164
+ * - `restoreFromCookies()` (cold boot)
165
+ * - `switchAuthuser()` (per-account rotation)
166
+ * - `handleAuthSuccess()` (fresh login when the server response carries
167
+ * an `authuser` field)
168
+ * Access tokens live ONLY here in the cookie path — they are never
169
+ * persisted to localStorage.
170
+ */
171
+ this.accounts = new Map();
172
+ /**
173
+ * Currently-active `authuser` slot in the cookie path. `null` means either
174
+ * the cookie path hasn't been initialised yet, or no slots are signed in.
175
+ */
176
+ this.activeAuthuser = null;
111
177
  this.oxyServices = oxyServices;
112
178
  const crossTabSync = config.crossTabSync ?? (typeof BroadcastChannel !== 'undefined');
113
179
  this.config = {
@@ -115,6 +181,7 @@ export class AuthManager {
115
181
  autoRefresh: config.autoRefresh ?? true,
116
182
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
117
183
  crossTabSync,
184
+ cookieOnly: config.cookieOnly ?? false,
118
185
  };
119
186
  this.storage = this.config.storage;
120
187
  // Persist tokens to storage when HttpService refreshes them automatically
@@ -151,6 +218,8 @@ export class AuthManager {
151
218
  async _handleCrossTabMessage(message) {
152
219
  if (!message || !message.type)
153
220
  return;
221
+ if (!this._acceptBroadcast(message))
222
+ return;
154
223
  switch (message.type) {
155
224
  case 'tokens_refreshed': {
156
225
  // Another tab successfully refreshed. Signal to cancel our pending refresh.
@@ -189,20 +258,130 @@ export class AuthManager {
189
258
  this.notifyListeners();
190
259
  break;
191
260
  }
261
+ case 'accounts_restored':
262
+ case 'authuser_switched':
263
+ case 'authuser_signed_out': {
264
+ // Another tab restored/switched/dropped a slot. The authoritative
265
+ // state lives in the httpOnly cookies which we can't read from JS,
266
+ // so the cleanest reaction is to re-run `restoreFromCookies()` on
267
+ // a microtask and re-sync our in-memory registry. We swallow
268
+ // failures: a transient network error must not bring down a tab
269
+ // that already had a valid session.
270
+ //
271
+ // The restoreFromCookies() body owns the per-slot debounce so a
272
+ // burst of N broadcasts only costs one /auth/refresh-all rotation
273
+ // (instead of N back-to-back rotations of every cookie slot).
274
+ Promise.resolve().then(() => {
275
+ this.restoreFromCookies().catch(() => {
276
+ // Best-effort; existing accounts (if any) remain intact.
277
+ });
278
+ });
279
+ break;
280
+ }
281
+ case 'all_signed_out': {
282
+ // Mirror `signed_out` but also wipe the cookie-path registry.
283
+ if (this.refreshTimer) {
284
+ clearTimeout(this.refreshTimer);
285
+ this.refreshTimer = null;
286
+ }
287
+ this.refreshPromise = null;
288
+ this.accounts.clear();
289
+ this.activeAuthuser = null;
290
+ this._lastKnownAccessToken = null;
291
+ this.oxyServices.httpService.setTokens('');
292
+ this.currentUser = null;
293
+ this.notifyListeners();
294
+ break;
295
+ }
192
296
  // 'refresh_starting' is informational; we don't need to act on it currently
193
297
  }
194
298
  }
195
299
  /**
196
- * Broadcast a message to other tabs.
300
+ * Broadcast a message to other tabs. Always stamps this tab's `tabId` and
301
+ * `nonce` onto the message so receivers can run the cross-tab nonce gate.
197
302
  */
198
303
  _broadcast(message) {
304
+ const stamped = {
305
+ ...message,
306
+ tabId: this._tabId,
307
+ nonce: this._broadcastNonce,
308
+ };
199
309
  try {
200
- this._broadcastChannel?.postMessage(message);
310
+ this._broadcastChannel?.postMessage(stamped);
201
311
  }
202
312
  catch {
203
313
  // Channel closed or unavailable
204
314
  }
205
315
  }
316
+ /**
317
+ * Generate `bytes` bytes of cryptographic randomness encoded as lowercase
318
+ * hex. Prefers Web Crypto's `getRandomValues` when available (browser /
319
+ * modern Node); falls back to `Math.random` ONLY in environments without
320
+ * Web Crypto (the resulting nonce is still unguessable to a same-origin
321
+ * XSS payload — the goal is unforgeability across tabs, not cryptographic
322
+ * secrecy across the network).
323
+ */
324
+ static _randomHex(bytes) {
325
+ const buffer = new Uint8Array(bytes);
326
+ const gcrypto = typeof globalThis !== 'undefined'
327
+ ? globalThis.crypto
328
+ : undefined;
329
+ if (gcrypto && typeof gcrypto.getRandomValues === 'function') {
330
+ gcrypto.getRandomValues(buffer);
331
+ }
332
+ else {
333
+ for (let i = 0; i < buffer.length; i++) {
334
+ buffer[i] = Math.floor(Math.random() * 256);
335
+ }
336
+ }
337
+ let hex = '';
338
+ for (const byte of buffer) {
339
+ hex += byte.toString(16).padStart(2, '0');
340
+ }
341
+ return hex;
342
+ }
343
+ /**
344
+ * Validate an inbound broadcast against the cross-tab nonce gate.
345
+ *
346
+ * Returns `true` when the message should be honoured, `false` when it
347
+ * MUST be ignored:
348
+ * - Message is missing `tabId` or `nonce` → ignore (forged or
349
+ * mismatched-version sibling tab).
350
+ * - First sighting of `tabId` → record the nonce and honour the message
351
+ * (trust-on-first-use, the best we can do without a shared secret).
352
+ * - Subsequent message from the same `tabId` with the SAME nonce →
353
+ * honour.
354
+ * - Subsequent message from the same `tabId` with a DIFFERENT nonce →
355
+ * ignore (the canonical "forged broadcast" case — a same-origin XSS
356
+ * payload can't read the real tab's `_broadcastNonce`).
357
+ *
358
+ * Echoes of this tab's own broadcasts (same `tabId`) are also dropped so
359
+ * we don't react to our own messages.
360
+ */
361
+ _acceptBroadcast(message) {
362
+ if (!message || typeof message.tabId !== 'string' || typeof message.nonce !== 'string') {
363
+ return false;
364
+ }
365
+ if (message.tabId === this._tabId) {
366
+ // Same-tab echo. Some BroadcastChannel implementations deliver our own
367
+ // posts back to us; never act on those.
368
+ return false;
369
+ }
370
+ const seen = this._knownPeerNonces.get(message.tabId);
371
+ if (seen === undefined) {
372
+ // Trust-on-first-use. Bound the map to avoid unbounded growth from a
373
+ // tab-id sprayer.
374
+ if (this._knownPeerNonces.size >= AuthManager._MAX_KNOWN_PEERS) {
375
+ const oldest = this._knownPeerNonces.keys().next().value;
376
+ if (oldest !== undefined) {
377
+ this._knownPeerNonces.delete(oldest);
378
+ }
379
+ }
380
+ this._knownPeerNonces.set(message.tabId, message.nonce);
381
+ return true;
382
+ }
383
+ return seen === message.nonce;
384
+ }
206
385
  /**
207
386
  * Get default storage based on environment.
208
387
  */
@@ -532,11 +711,34 @@ export class AuthManager {
532
711
  return method;
533
712
  }
534
713
  /**
535
- * Initialize auth state from storage.
714
+ * Initialize auth state on app startup.
715
+ *
716
+ * Order of operations:
717
+ * 1. Try the cookie path via `restoreFromCookies()`. This is the
718
+ * preferred path because the httpOnly refresh cookies are
719
+ * cross-tab, persist across hard reloads, and don't expose any
720
+ * refresh-token material to JS.
721
+ * 2. If the cookie path yielded zero accounts AND `cookieOnly` is
722
+ * `false`, fall back to the legacy localStorage path
723
+ * (`oxy_access_token` / `oxy_session`) for backwards compatibility
724
+ * with apps that haven't migrated to the cookie endpoint yet.
725
+ * 3. If `cookieOnly` is `true`, skip the legacy fallback entirely.
726
+ * This guarantees no tokens or refresh tokens are ever read from
727
+ * or written to JS-accessible storage.
536
728
  *
537
- * Call this on app startup to restore previous session.
729
+ * Returns the active user on success, or `null` when neither path
730
+ * restored a session.
538
731
  */
539
732
  async initialize() {
733
+ // 1. Cookie path (preferred).
734
+ const cookieResult = await this.restoreFromCookies();
735
+ if (cookieResult.accounts.length > 0) {
736
+ return this.currentUser;
737
+ }
738
+ // 2. Legacy localStorage path (opt-out via `cookieOnly`).
739
+ if (this.config.cookieOnly) {
740
+ return null;
741
+ }
540
742
  try {
541
743
  // Try to restore user from storage
542
744
  const userJson = await this.storage.getItem(STORAGE_KEYS.USER);
@@ -578,6 +780,472 @@ export class AuthManager {
578
780
  return null;
579
781
  }
580
782
  }
783
+ // -------------------------------------------------------------------------
784
+ // Multi-account cookie path (Google-style multi-sign-in).
785
+ // -------------------------------------------------------------------------
786
+ // The cookie path is web-only and orthogonal to the legacy bearer path
787
+ // above: it never touches the `oxy_access_token` / `oxy_refresh_token` /
788
+ // `oxy_session` localStorage keys, because the refresh token lives in the
789
+ // httpOnly `oxy_rt_${authuser}` cookies and access tokens live in
790
+ // `this.accounts` (in-memory only). The only localStorage key the cookie
791
+ // path writes is `STORAGE_KEYS.ACTIVE_AUTHUSER` — a small integer that is
792
+ // explicitly NOT a secret.
793
+ //
794
+ // Apps that want to opt out of the legacy localStorage path entirely
795
+ // (recommended for new web apps) pass `cookieOnly: true` to the
796
+ // AuthManager config; in that mode `initialize()` ONLY uses the cookie
797
+ // path.
798
+ // -------------------------------------------------------------------------
799
+ /**
800
+ * Read the persisted active `authuser` slot index. Returns `null` when
801
+ * none is persisted, the value is corrupt, or the storage adapter has no
802
+ * record. Storage failures are non-fatal: the cookie path falls back to
803
+ * "lowest authuser" deterministic selection.
804
+ */
805
+ async readActiveAuthuser() {
806
+ try {
807
+ const raw = await this.storage.getItem(STORAGE_KEYS.ACTIVE_AUTHUSER);
808
+ if (raw === null || raw === undefined)
809
+ return null;
810
+ const parsed = Number.parseInt(raw, 10);
811
+ if (!Number.isFinite(parsed) || parsed < 0)
812
+ return null;
813
+ return parsed;
814
+ }
815
+ catch {
816
+ return null;
817
+ }
818
+ }
819
+ /**
820
+ * Persist the active `authuser` slot index. No-ops on storage failure
821
+ * (e.g. Safari private mode, native SecureStore unavailable) — this is
822
+ * best-effort UX persistence, not authoritative state.
823
+ */
824
+ async writeActiveAuthuser(authuser) {
825
+ if (!Number.isFinite(authuser) || authuser < 0)
826
+ return;
827
+ try {
828
+ await this.storage.setItem(STORAGE_KEYS.ACTIVE_AUTHUSER, String(authuser));
829
+ }
830
+ catch {
831
+ // Best-effort.
832
+ }
833
+ }
834
+ /**
835
+ * Clear the persisted active `authuser` so the next cold boot starts from
836
+ * a clean slate (used on full sign-out).
837
+ */
838
+ async clearActiveAuthuser() {
839
+ try {
840
+ await this.storage.removeItem(STORAGE_KEYS.ACTIVE_AUTHUSER);
841
+ }
842
+ catch {
843
+ // Best-effort.
844
+ }
845
+ }
846
+ /**
847
+ * Build a `MinimalUserData` from a `RefreshAllAccount`. Returns `null` when
848
+ * the wire entry has no user shape (legacy `/auth/refresh` fallback) — the
849
+ * AuthManager's caller is expected to hydrate via `/users/me` in that
850
+ * case.
851
+ */
852
+ static toMinimalUser(account) {
853
+ if (!account.user)
854
+ return null;
855
+ return {
856
+ id: account.user.id,
857
+ username: account.user.username,
858
+ avatar: account.user.avatar ?? undefined,
859
+ };
860
+ }
861
+ /**
862
+ * Hydrate the user shape for a slot whose AuthManagerAccount currently has
863
+ * `user: null` (legacy refresh fallback, or a switch onto a previously
864
+ * unknown slot). Calls `/users/me` with the slot's freshly-planted access
865
+ * token already on the HTTP client; merges the result back into the
866
+ * registry entry. Network failures are non-fatal — the slot remains with
867
+ * `user: null` and the UI is expected to render the public-key fallback
868
+ * handle until a later restore picks the real user shape up.
869
+ */
870
+ async _hydrateUnknownUser(authuser) {
871
+ const oxy = this.oxyServices;
872
+ if (typeof oxy.getCurrentUser !== 'function')
873
+ return;
874
+ let me;
875
+ try {
876
+ me = await oxy.getCurrentUser();
877
+ }
878
+ catch {
879
+ // Best-effort: keep `user: null` and let the UI fall back to the
880
+ // public-key handle.
881
+ return;
882
+ }
883
+ const existing = this.accounts.get(authuser);
884
+ if (!existing)
885
+ return;
886
+ const hydrated = {
887
+ id: me.id,
888
+ username: me.username,
889
+ name: typeof me.name === 'string' ? me.name : undefined,
890
+ avatar: me.avatar ?? null,
891
+ email: me.email,
892
+ color: me.color ?? null,
893
+ };
894
+ this.accounts.set(authuser, { ...existing, user: hydrated });
895
+ // Mirror onto `currentUser` if this is the active slot.
896
+ if (this.activeAuthuser === authuser) {
897
+ this.currentUser = {
898
+ id: hydrated.id,
899
+ username: hydrated.username,
900
+ avatar: hydrated.avatar ?? undefined,
901
+ };
902
+ this.notifyListeners();
903
+ }
904
+ }
905
+ /**
906
+ * Snapshot of the registered cookie-path accounts, sorted by `authuser`
907
+ * ascending (canonical order). Mutating the returned array does not
908
+ * affect AuthManager state.
909
+ */
910
+ getAccounts() {
911
+ return Array.from(this.accounts.values()).sort((a, b) => a.authuser - b.authuser);
912
+ }
913
+ /**
914
+ * The slot index that is currently active in the cookie path, or `null`
915
+ * if the cookie path hasn't been initialised or no slots are signed in.
916
+ */
917
+ getActiveAuthuser() {
918
+ return this.activeAuthuser;
919
+ }
920
+ /**
921
+ * Convenience: the AuthManagerAccount currently flagged active.
922
+ */
923
+ getActiveAccount() {
924
+ if (this.activeAuthuser === null)
925
+ return null;
926
+ return this.accounts.get(this.activeAuthuser) ?? null;
927
+ }
928
+ /**
929
+ * Restore every device-local account from the httpOnly refresh cookies.
930
+ *
931
+ * Calls `oxyServices.refreshAllSessions()` (`POST /auth/refresh-all` with
932
+ * `credentials: 'include'`). The server rotates every presented
933
+ * `oxy_rt_${authuser}` cookie in parallel and returns one entry per
934
+ * VALID slot. The SDK transparently falls back to the legacy single-slot
935
+ * `/auth/refresh` against older servers (handled inside
936
+ * `refreshAllSessions`).
937
+ *
938
+ * Plants the active account's access token on the shared HTTP client;
939
+ * sibling slots' tokens stay in the in-memory registry so a later
940
+ * `switchAuthuser()` can hot-swap them without a network round-trip.
941
+ *
942
+ * The persisted `oxy_active_authuser` slot wins when it matches a
943
+ * returned account; otherwise the lowest returned `authuser` is chosen
944
+ * deterministically.
945
+ *
946
+ * Returns `{ accounts: [], activeAuthuser: null }` on any failure or
947
+ * empty snapshot — callers treat that as "no signed-in accounts" and
948
+ * proceed unauthenticated. State is NOT cleared on failure; existing
949
+ * accounts (if any) remain intact.
950
+ */
951
+ async restoreFromCookies() {
952
+ // Cross-tab cascade debounce. If we restored within the last
953
+ // _RESTORE_DEBOUNCE_MS for the currently-active slot, skip the network
954
+ // round-trip and return the cached registry verbatim. A burst of N
955
+ // BroadcastChannel events from sibling tabs therefore costs at most one
956
+ // /auth/refresh-all rotation. Cold-boot calls (activeAuthuser still
957
+ // null) always run because the cache hasn't been seeded yet.
958
+ if (this.activeAuthuser !== null) {
959
+ const last = this._lastRestoreAt.get(this.activeAuthuser);
960
+ if (last !== undefined && Date.now() - last < AuthManager._RESTORE_DEBOUNCE_MS) {
961
+ return {
962
+ accounts: this.getAccounts(),
963
+ activeAuthuser: this.activeAuthuser,
964
+ };
965
+ }
966
+ }
967
+ let snapshot;
968
+ try {
969
+ snapshot = await this.oxyServices.refreshAllSessions();
970
+ }
971
+ catch {
972
+ return { accounts: [], activeAuthuser: null };
973
+ }
974
+ if (snapshot.accounts.length === 0) {
975
+ return { accounts: [], activeAuthuser: null };
976
+ }
977
+ // Replace the registry wholesale: the server's snapshot is authoritative.
978
+ this.accounts.clear();
979
+ for (const account of snapshot.accounts) {
980
+ this.accounts.set(account.authuser, {
981
+ authuser: account.authuser,
982
+ sessionId: account.sessionId,
983
+ user: account.user,
984
+ accessToken: account.accessToken,
985
+ expiresAt: account.expiresAt,
986
+ });
987
+ }
988
+ // Pick the active slot: persisted `oxy_active_authuser` wins if it
989
+ // matches a returned account; otherwise the lowest returned authuser
990
+ // (the snapshot is already sorted ascending, so accounts[0] is the
991
+ // lowest).
992
+ const persisted = await this.readActiveAuthuser();
993
+ const active = (persisted !== null && this.accounts.has(persisted))
994
+ ? persisted
995
+ : snapshot.accounts[0].authuser;
996
+ this.activeAuthuser = active;
997
+ const activeAccount = this.accounts.get(active);
998
+ const slotsNeedingHydration = [];
999
+ if (activeAccount) {
1000
+ this._lastKnownAccessToken = activeAccount.accessToken;
1001
+ this.oxyServices.httpService.setTokens(activeAccount.accessToken);
1002
+ this.currentUser = AuthManager.toMinimalUser({
1003
+ authuser: activeAccount.authuser,
1004
+ accessToken: activeAccount.accessToken,
1005
+ expiresAt: activeAccount.expiresAt,
1006
+ sessionId: activeAccount.sessionId,
1007
+ user: activeAccount.user,
1008
+ });
1009
+ await this.writeActiveAuthuser(active);
1010
+ // Schedule auto-refresh on the active slot so the in-memory access
1011
+ // token doesn't silently expire under the user.
1012
+ if (this.config.autoRefresh) {
1013
+ this.setupCookieRefresh(activeAccount.expiresAt, active);
1014
+ }
1015
+ // The legacy /auth/refresh fallback yields user=null for the active
1016
+ // slot. Schedule a /users/me hydration so the chooser isn't stuck on
1017
+ // the public-key handle. Hydration is fire-and-forget — the snapshot
1018
+ // is already considered "restored" once the access token is planted.
1019
+ if (activeAccount.user === null) {
1020
+ slotsNeedingHydration.push(activeAccount.authuser);
1021
+ }
1022
+ }
1023
+ this._lastRestoreAt.set(active, Date.now());
1024
+ this._broadcast({ type: 'accounts_restored', timestamp: Date.now() });
1025
+ this.notifyListeners();
1026
+ for (const slot of slotsNeedingHydration) {
1027
+ void this._hydrateUnknownUser(slot);
1028
+ }
1029
+ return {
1030
+ accounts: this.getAccounts(),
1031
+ activeAuthuser: this.activeAuthuser,
1032
+ };
1033
+ }
1034
+ /**
1035
+ * Switch the active account to a different device-local slot.
1036
+ *
1037
+ * Calls `oxyServices.refreshTokenViaCookie({ authuser })` to mint a fresh
1038
+ * access token from the slot's httpOnly cookie, updates the in-memory
1039
+ * registry entry, plants the token on the HTTP client, persists the new
1040
+ * active slot, and broadcasts cross-tab.
1041
+ *
1042
+ * Throws when the slot's refresh cookie is missing / expired / reused
1043
+ * (the SDK returns `null` from `refreshTokenViaCookie` in that case, and
1044
+ * we surface it as an `Error` so callers can clean up the slot from
1045
+ * their UI).
1046
+ */
1047
+ async switchAuthuser(authuser) {
1048
+ // Concurrency gate. Two near-simultaneous switchAuthuser calls would
1049
+ // otherwise both POST /auth/refresh?authuser=N, rotating the slot's
1050
+ // refresh-token family twice and racing on the registry update. The
1051
+ // gate is keyed only by "any switch in flight" — switching to a
1052
+ // DIFFERENT slot while a switch is in flight returns the in-flight
1053
+ // promise (callers can re-issue once it settles if they really meant a
1054
+ // different slot).
1055
+ if (this._switchPromise) {
1056
+ return this._switchPromise;
1057
+ }
1058
+ this._switchPromise = this._doSwitchAuthuser(authuser);
1059
+ try {
1060
+ return await this._switchPromise;
1061
+ }
1062
+ finally {
1063
+ this._switchPromise = null;
1064
+ }
1065
+ }
1066
+ async _doSwitchAuthuser(authuser) {
1067
+ const refreshed = await this.oxyServices.refreshTokenViaCookie({ authuser });
1068
+ if (refreshed === null) {
1069
+ // Drop the dead slot from our registry so the chooser doesn't keep
1070
+ // offering it; callers can drive a `restoreFromCookies()` to
1071
+ // re-sync.
1072
+ this.accounts.delete(authuser);
1073
+ if (this.activeAuthuser === authuser) {
1074
+ this.activeAuthuser = null;
1075
+ }
1076
+ throw new Error(`Refresh cookie for authuser=${authuser} is missing or expired`);
1077
+ }
1078
+ // Update (or insert) the slot in the registry. We preserve any user
1079
+ // metadata we already knew from a prior `restoreFromCookies` — the
1080
+ // single-slot refresh endpoint does NOT re-project the user shape. When
1081
+ // we have no prior metadata, we leave `user: null` and schedule a
1082
+ // /users/me hydration below.
1083
+ const existing = this.accounts.get(authuser);
1084
+ const decoded = AuthManager.decodeSessionIdFromAccessToken(refreshed.accessToken);
1085
+ const sessionId = decoded ?? existing?.sessionId ?? '';
1086
+ const updated = {
1087
+ authuser,
1088
+ sessionId,
1089
+ user: existing?.user ?? null,
1090
+ accessToken: refreshed.accessToken,
1091
+ expiresAt: refreshed.expiresAt,
1092
+ };
1093
+ this.accounts.set(authuser, updated);
1094
+ this.activeAuthuser = authuser;
1095
+ this._lastKnownAccessToken = refreshed.accessToken;
1096
+ this.oxyServices.httpService.setTokens(refreshed.accessToken);
1097
+ this.currentUser = updated.user
1098
+ ? {
1099
+ id: updated.user.id,
1100
+ username: updated.user.username,
1101
+ avatar: updated.user.avatar ?? undefined,
1102
+ }
1103
+ : null;
1104
+ await this.writeActiveAuthuser(authuser);
1105
+ if (this.config.autoRefresh) {
1106
+ this.setupCookieRefresh(refreshed.expiresAt, authuser);
1107
+ }
1108
+ this._broadcast({ type: 'authuser_switched', authuser, timestamp: Date.now() });
1109
+ this.notifyListeners();
1110
+ if (updated.user === null) {
1111
+ // Fire-and-forget hydration: the switch is considered complete once
1112
+ // the token is planted, the UI uses getAccountFallbackHandle (public-
1113
+ // key fallback) until /users/me resolves.
1114
+ void this._hydrateUnknownUser(authuser);
1115
+ }
1116
+ return {
1117
+ accessToken: refreshed.accessToken,
1118
+ expiresAt: refreshed.expiresAt,
1119
+ authuser,
1120
+ };
1121
+ }
1122
+ /**
1123
+ * Sign out a single device-local slot.
1124
+ *
1125
+ * Calls `oxyServices.logoutSessionByAuthuser(authuser)`: server-side
1126
+ * revokes the slot's refresh-token family and clears the
1127
+ * `oxy_rt_${authuser}` cookie via `Set-Cookie`. The slot is removed from
1128
+ * the in-memory registry. If the slot was active, the next lowest
1129
+ * remaining authuser becomes active (or `null` when none remain).
1130
+ */
1131
+ async signOutAuthuser(authuser) {
1132
+ try {
1133
+ await this.oxyServices.logoutSessionByAuthuser(authuser);
1134
+ }
1135
+ catch {
1136
+ // Best-effort: the server-side logout is idempotent on unknown
1137
+ // tokens, and we'd rather drop the slot locally than leave dead
1138
+ // state on a network blip.
1139
+ }
1140
+ this.accounts.delete(authuser);
1141
+ if (this.activeAuthuser === authuser) {
1142
+ const remaining = this.getAccounts();
1143
+ if (remaining.length > 0) {
1144
+ // Pick the lowest remaining authuser as the new active. We don't
1145
+ // proactively refresh its token here — callers can drive
1146
+ // `switchAuthuser` if they need a fresh bearer. This keeps the
1147
+ // method's network footprint to exactly one request.
1148
+ const next = remaining[0];
1149
+ this.activeAuthuser = next.authuser;
1150
+ this._lastKnownAccessToken = next.accessToken;
1151
+ this.oxyServices.httpService.setTokens(next.accessToken);
1152
+ this.currentUser = next.user
1153
+ ? {
1154
+ id: next.user.id,
1155
+ username: next.user.username,
1156
+ avatar: next.user.avatar ?? undefined,
1157
+ }
1158
+ : null;
1159
+ await this.writeActiveAuthuser(next.authuser);
1160
+ if (next.user === null) {
1161
+ void this._hydrateUnknownUser(next.authuser);
1162
+ }
1163
+ }
1164
+ else {
1165
+ this.activeAuthuser = null;
1166
+ this._lastKnownAccessToken = null;
1167
+ this.oxyServices.httpService.setTokens('');
1168
+ this.currentUser = null;
1169
+ await this.clearActiveAuthuser();
1170
+ }
1171
+ }
1172
+ this._broadcast({ type: 'authuser_signed_out', authuser, timestamp: Date.now() });
1173
+ this.notifyListeners();
1174
+ }
1175
+ /**
1176
+ * Sign out EVERY device-local account on this device.
1177
+ *
1178
+ * Calls `oxyServices.logoutAllSessionsViaCookie()`: server-side revokes
1179
+ * every presented family and `Set-Cookie`s an immediate expiry for every
1180
+ * recognised `oxy_rt_${n}` slot AND the legacy `oxy_rt` cookie. The
1181
+ * in-memory registry is wiped, the active slot is cleared, and the
1182
+ * persisted `oxy_active_authuser` is removed so the next cold boot
1183
+ * starts fresh.
1184
+ */
1185
+ async signOutAllViaCookies() {
1186
+ try {
1187
+ await this.oxyServices.logoutAllSessionsViaCookie();
1188
+ }
1189
+ catch {
1190
+ // Best-effort; server-side endpoint is idempotent.
1191
+ }
1192
+ this.accounts.clear();
1193
+ this.activeAuthuser = null;
1194
+ this._lastKnownAccessToken = null;
1195
+ this.oxyServices.httpService.setTokens('');
1196
+ this.currentUser = null;
1197
+ this._lastRestoreAt.clear();
1198
+ await this.clearActiveAuthuser();
1199
+ // Also clear the refresh timer that the cookie path may have scheduled.
1200
+ if (this.refreshTimer) {
1201
+ clearTimeout(this.refreshTimer);
1202
+ this.refreshTimer = null;
1203
+ }
1204
+ this._broadcast({ type: 'all_signed_out', timestamp: Date.now() });
1205
+ this.notifyListeners();
1206
+ }
1207
+ /**
1208
+ * Schedule an auto-refresh for the cookie path on the active slot. Reuses
1209
+ * the same single `refreshTimer` as the legacy path (the AuthManager has
1210
+ * exactly ONE active slot at a time, so one timer suffices).
1211
+ */
1212
+ setupCookieRefresh(expiresAt, authuser) {
1213
+ if (this.refreshTimer) {
1214
+ clearTimeout(this.refreshTimer);
1215
+ }
1216
+ const expiresAtMs = new Date(expiresAt).getTime();
1217
+ if (!Number.isFinite(expiresAtMs))
1218
+ return;
1219
+ const refreshAt = expiresAtMs - this.config.refreshBuffer;
1220
+ const delay = Math.max(0, refreshAt - Date.now());
1221
+ this.refreshTimer = setTimeout(() => {
1222
+ // Only refresh if this slot is still the active one when the timer
1223
+ // fires (the user might have switched in the meantime).
1224
+ if (this.activeAuthuser !== authuser)
1225
+ return;
1226
+ this.switchAuthuser(authuser).catch(() => {
1227
+ // A failed cookie refresh on the active slot means the user must
1228
+ // re-auth; surface via `notifyListeners` indirectly when the slot
1229
+ // is dropped from the registry by `switchAuthuser`.
1230
+ });
1231
+ }, delay);
1232
+ }
1233
+ /**
1234
+ * Decode the session id from an unverified JWT access token. Decode-only
1235
+ * (no signature verification) — the server already verified the
1236
+ * signature when minting the token. Returns `null` on malformed input.
1237
+ */
1238
+ static decodeSessionIdFromAccessToken(token) {
1239
+ try {
1240
+ const decoded = jwtDecode(token);
1241
+ return typeof decoded.sessionId === 'string' && decoded.sessionId.length > 0
1242
+ ? decoded.sessionId
1243
+ : null;
1244
+ }
1245
+ catch {
1246
+ return null;
1247
+ }
1248
+ }
581
1249
  /**
582
1250
  * Destroy the auth manager and clean up resources.
583
1251
  */
@@ -587,6 +1255,10 @@ export class AuthManager {
587
1255
  this.refreshTimer = null;
588
1256
  }
589
1257
  this.listeners.clear();
1258
+ this._knownPeerNonces.clear();
1259
+ this._lastRestoreAt.clear();
1260
+ this._switchPromise = null;
1261
+ this.refreshPromise = null;
590
1262
  // Close BroadcastChannel
591
1263
  if (this._broadcastChannel) {
592
1264
  try {
@@ -599,6 +1271,8 @@ export class AuthManager {
599
1271
  }
600
1272
  }
601
1273
  }
1274
+ AuthManager._MAX_KNOWN_PEERS = 32;
1275
+ AuthManager._RESTORE_DEBOUNCE_MS = 2000;
602
1276
  /**
603
1277
  * Create an AuthManager instance.
604
1278
  *