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