@oxyhq/core 5.5.0 → 6.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 (181) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +6 -3
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +47 -44
  12. package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
  15. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  16. package/dist/cjs/mixins/index.js +13 -17
  17. package/dist/cjs/session/SessionClient.js +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/utils/accountUtils.js +1 -55
  21. package/dist/cjs/utils/authWebUrl.js +6 -15
  22. package/dist/cjs/utils/fapiAutoDetect.js +16 -64
  23. package/dist/cjs/utils/platform.js +19 -0
  24. package/dist/cjs/utils/ssoBounce.js +15 -362
  25. package/dist/cjs/utils/validationUtils.js +57 -0
  26. package/dist/esm/.tsbuildinfo +1 -1
  27. package/dist/esm/HttpService.js +6 -3
  28. package/dist/esm/OxyServices.base.js +7 -102
  29. package/dist/esm/boot/coldBootV2.js +344 -0
  30. package/dist/esm/boot/deviceBootReturn.js +146 -0
  31. package/dist/esm/crypto/keyManager.js +95 -0
  32. package/dist/esm/i18n/locales/en-US.json +13 -1
  33. package/dist/esm/i18n/locales/es-ES.json +13 -1
  34. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  36. package/dist/esm/index.js +28 -18
  37. package/dist/esm/mixins/OxyServices.accounts.js +6 -13
  38. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  39. package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
  40. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  41. package/dist/esm/mixins/index.js +13 -17
  42. package/dist/esm/session/SessionClient.js +40 -1
  43. package/dist/esm/session/authStateStore.js +278 -0
  44. package/dist/esm/session/refresh.js +257 -0
  45. package/dist/esm/utils/accountUtils.js +0 -53
  46. package/dist/esm/utils/authWebUrl.js +6 -14
  47. package/dist/esm/utils/fapiAutoDetect.js +16 -63
  48. package/dist/esm/utils/platform.js +18 -0
  49. package/dist/esm/utils/ssoBounce.js +14 -345
  50. package/dist/esm/utils/validationUtils.js +56 -0
  51. package/dist/types/.tsbuildinfo +1 -1
  52. package/dist/types/HttpService.d.ts +13 -0
  53. package/dist/types/OxyServices.base.d.ts +0 -52
  54. package/dist/types/OxyServices.d.ts +0 -25
  55. package/dist/types/boot/coldBootV2.d.ts +76 -0
  56. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  57. package/dist/types/crypto/keyManager.d.ts +21 -0
  58. package/dist/types/index.d.ts +15 -21
  59. package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
  60. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  61. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  62. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  63. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  64. package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
  65. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  68. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  69. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  72. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  83. package/dist/types/mixins/index.d.ts +6 -9
  84. package/dist/types/models/interfaces.d.ts +0 -67
  85. package/dist/types/session/SessionClient.d.ts +25 -0
  86. package/dist/types/session/authStateStore.d.ts +119 -0
  87. package/dist/types/session/refresh.d.ts +93 -0
  88. package/dist/types/utils/accountUtils.d.ts +0 -14
  89. package/dist/types/utils/authWebUrl.d.ts +6 -12
  90. package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
  91. package/dist/types/utils/platform.d.ts +14 -0
  92. package/dist/types/utils/ssoBounce.d.ts +14 -280
  93. package/dist/types/utils/validationUtils.d.ts +15 -0
  94. package/package.json +2 -2
  95. package/src/HttpService.ts +19 -3
  96. package/src/OxyServices.base.ts +7 -112
  97. package/src/OxyServices.ts +0 -38
  98. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  99. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  100. package/src/boot/coldBootV2.ts +426 -0
  101. package/src/boot/deviceBootReturn.ts +195 -0
  102. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  103. package/src/crypto/keyManager.ts +101 -0
  104. package/src/i18n/locales/en-US.json +13 -1
  105. package/src/i18n/locales/es-ES.json +13 -1
  106. package/src/index.ts +74 -65
  107. package/src/mixins/OxyServices.accounts.ts +6 -13
  108. package/src/mixins/OxyServices.auth.ts +78 -253
  109. package/src/mixins/OxyServices.authorizedApps.ts +75 -0
  110. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  111. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  112. package/src/mixins/__tests__/accounts.test.ts +17 -44
  113. package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
  114. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  115. package/src/mixins/index.ts +16 -22
  116. package/src/models/interfaces.ts +0 -79
  117. package/src/session/SessionClient.ts +52 -1
  118. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  119. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  120. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  121. package/src/session/__tests__/authStateStore.test.ts +209 -0
  122. package/src/session/__tests__/refresh.test.ts +256 -0
  123. package/src/session/authStateStore.ts +335 -0
  124. package/src/session/refresh.ts +334 -0
  125. package/src/utils/__tests__/authWebUrl.test.ts +5 -29
  126. package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
  127. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  128. package/src/utils/accountUtils.ts +0 -62
  129. package/src/utils/authWebUrl.ts +6 -15
  130. package/src/utils/fapiAutoDetect.ts +16 -60
  131. package/src/utils/platform.ts +21 -0
  132. package/src/utils/ssoBounce.ts +14 -393
  133. package/src/utils/validationUtils.ts +62 -0
  134. package/dist/cjs/AuthManager.js +0 -1110
  135. package/dist/cjs/AuthManagerTypes.js +0 -13
  136. package/dist/cjs/CrossDomainAuth.js +0 -206
  137. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  138. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  139. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  140. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  141. package/dist/cjs/utils/ssoEstablish.js +0 -110
  142. package/dist/cjs/utils/ssoReturn.js +0 -275
  143. package/dist/esm/AuthManager.js +0 -1105
  144. package/dist/esm/AuthManagerTypes.js +0 -12
  145. package/dist/esm/CrossDomainAuth.js +0 -201
  146. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  147. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  148. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  149. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  150. package/dist/esm/utils/ssoEstablish.js +0 -107
  151. package/dist/esm/utils/ssoReturn.js +0 -271
  152. package/dist/types/AuthManager.d.ts +0 -380
  153. package/dist/types/AuthManagerTypes.d.ts +0 -81
  154. package/dist/types/CrossDomainAuth.d.ts +0 -164
  155. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  156. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  157. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  158. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  159. package/dist/types/utils/ssoReturn.d.ts +0 -156
  160. package/src/AuthManager.ts +0 -1269
  161. package/src/AuthManagerTypes.ts +0 -86
  162. package/src/CrossDomainAuth.ts +0 -243
  163. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  164. package/src/__tests__/authManager.security.test.ts +0 -377
  165. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  166. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  167. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  168. package/src/mixins/OxyServices.redirect.ts +0 -122
  169. package/src/mixins/OxyServices.silent.ts +0 -272
  170. package/src/mixins/OxyServices.sso.ts +0 -261
  171. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  172. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  173. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  174. package/src/mixins/__tests__/silent.test.ts +0 -102
  175. package/src/mixins/__tests__/sso.test.ts +0 -228
  176. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  177. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  178. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  179. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  180. package/src/utils/ssoEstablish.ts +0 -174
  181. package/src/utils/ssoReturn.ts +0 -389
@@ -1,1110 +0,0 @@
1
- "use strict";
2
- /**
3
- * AuthManager - Centralized Authentication Manager
4
- *
5
- * Provides a unified authentication interface for all platforms.
6
- * Handles token storage, session management, and auth state changes.
7
- *
8
- * @module core/AuthManager
9
- */
10
- Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.AuthManager = void 0;
12
- exports.createAuthManager = createAuthManager;
13
- const jwt_decode_1 = require("jwt-decode");
14
- const STORAGE_KEYS = {
15
- /**
16
- * Persisted active `authuser` slot index for the cookie path. Stores ONLY
17
- * the integer slot index (e.g. `"0"`, `"1"`), never a token or session
18
- * id — that lives in the httpOnly `oxy_rt_${n}` cookie. Used so that a
19
- * cold-boot `restoreFromCookies()` lands on the user's last-chosen slot
20
- * instead of always defaulting to the lowest authuser.
21
- */
22
- ACTIVE_AUTHUSER: 'oxy_active_authuser',
23
- };
24
- /**
25
- * Default in-memory storage for non-browser environments.
26
- */
27
- class MemoryStorage {
28
- constructor() {
29
- this.store = new Map();
30
- }
31
- getItem(key) {
32
- return this.store.get(key) ?? null;
33
- }
34
- setItem(key, value) {
35
- this.store.set(key, value);
36
- }
37
- removeItem(key) {
38
- this.store.delete(key);
39
- }
40
- }
41
- /**
42
- * Browser localStorage adapter.
43
- */
44
- class LocalStorageAdapter {
45
- getItem(key) {
46
- if (typeof window === 'undefined')
47
- return null;
48
- try {
49
- return localStorage.getItem(key);
50
- }
51
- catch {
52
- return null;
53
- }
54
- }
55
- setItem(key, value) {
56
- if (typeof window === 'undefined')
57
- return;
58
- try {
59
- localStorage.setItem(key, value);
60
- }
61
- catch {
62
- // Storage full or blocked
63
- }
64
- }
65
- removeItem(key) {
66
- if (typeof window === 'undefined')
67
- return;
68
- try {
69
- localStorage.removeItem(key);
70
- }
71
- catch {
72
- // Ignore errors
73
- }
74
- }
75
- }
76
- /**
77
- * AuthManager - Centralized authentication management.
78
- *
79
- * Provides a single point of control for:
80
- * - Token storage and retrieval
81
- * - Session management
82
- * - Auth state change notifications
83
- * - Multiple auth method support
84
- *
85
- * @example
86
- * ```typescript
87
- * const authManager = new AuthManager(oxyServices);
88
- *
89
- * // Listen for auth changes
90
- * authManager.onAuthStateChange((user) => {
91
- * console.log('Auth state changed:', user);
92
- * });
93
- *
94
- * // Handle successful auth
95
- * await authManager.handleAuthSuccess(session);
96
- *
97
- * // Sign out
98
- * await authManager.signOut();
99
- * ```
100
- */
101
- class AuthManager {
102
- constructor(oxyServices, config = {}) {
103
- this.listeners = new Set();
104
- this.currentUser = null;
105
- this.currentAuthMethod = null;
106
- this.refreshTimer = null;
107
- this.refreshPromise = null;
108
- /** Tracks the access token this instance last knew about, for cross-tab adoption. */
109
- this._lastKnownAccessToken = null;
110
- /** BroadcastChannel for coordinating token refreshes across browser tabs. */
111
- this._broadcastChannel = null;
112
- /**
113
- * Identifier for this AuthManager instance (≈ "this tab"). Random hex
114
- * generated at construction; advertised in every outgoing broadcast and
115
- * used as the lookup key in `_knownPeerNonces`.
116
- */
117
- this._tabId = AuthManager._randomHex(16);
118
- /**
119
- * Per-tab nonce, advertised in every outgoing broadcast. Receivers record
120
- * the first (tabId, nonce) pair they see from a given peer; subsequent
121
- * messages from the same tabId MUST carry the same nonce or they're
122
- * ignored.
123
- *
124
- * Threat model: a same-origin XSS payload can post to the channel but can
125
- * NOT read this instance's private `_broadcastNonce` field (it lives in
126
- * closure, not on `window`). Forged broadcasts from XSS therefore can't
127
- * impersonate this tab. A new attacker-controlled tabId trips the
128
- * "first message from a new peer" branch, which is by definition trusted
129
- * — so the gate raises the bar but is not a complete defence (a perfect
130
- * mitigation would require message signing with a server-issued key).
131
- */
132
- this._broadcastNonce = AuthManager._randomHex(16);
133
- /**
134
- * Bounded LRU of `(tabId → nonce)` pairs seen on inbound broadcasts. First
135
- * sighting of a new tabId records its nonce; later messages from that
136
- * tabId are rejected if the nonce doesn't match.
137
- */
138
- this._knownPeerNonces = new Map();
139
- /**
140
- * In-flight `switchAuthuser` promise. Deduplicates concurrent calls so two
141
- * near-simultaneous switches don't both fire refresh requests and rotate
142
- * the slot twice. Mirrors the `refreshPromise` pattern used by
143
- * `refreshToken`.
144
- */
145
- this._switchPromise = null;
146
- /**
147
- * Last `restoreFromCookies()` completion timestamp, keyed by the
148
- * AuthManager's active authuser at the time of completion. Used to gate
149
- * cross-tab cascade: a flurry of BroadcastChannel events from sibling
150
- * tabs can otherwise trigger N back-to-back snapshots and rotate every
151
- * slot's access token N times.
152
- */
153
- this._lastRestoreAt = new Map();
154
- /**
155
- * In-memory registry of every device-local account the AuthManager knows
156
- * about, keyed by `authuser` slot index. Populated by:
157
- * - `restoreFromCookies()` (cold boot)
158
- * - `switchAuthuser()` (per-account rotation)
159
- * - `handleAuthSuccess()` (fresh login when the server response carries
160
- * an `authuser` field)
161
- * Access tokens live ONLY here in the cookie path — they are never
162
- * persisted to localStorage.
163
- */
164
- this.accounts = new Map();
165
- /**
166
- * Currently-active `authuser` slot in the cookie path. `null` means either
167
- * the cookie path hasn't been initialised yet, or no slots are signed in.
168
- */
169
- this.activeAuthuser = null;
170
- this.oxyServices = oxyServices;
171
- const crossTabSync = config.crossTabSync ?? (typeof BroadcastChannel !== 'undefined');
172
- this.config = {
173
- storage: config.storage ?? this.getDefaultStorage(),
174
- autoRefresh: config.autoRefresh ?? true,
175
- refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
176
- crossTabSync,
177
- };
178
- this.storage = this.config.storage;
179
- this.oxyServices.httpService.setAuthRefreshHandler(async () => {
180
- const refreshed = await this.refreshToken();
181
- return refreshed ? this._lastKnownAccessToken : null;
182
- });
183
- // Setup cross-tab coordination in browser environments
184
- if (this.config.crossTabSync) {
185
- this._initBroadcastChannel();
186
- }
187
- }
188
- /**
189
- * Initialize BroadcastChannel for cross-tab token refresh coordination.
190
- * Only called in browser environments where BroadcastChannel is available.
191
- */
192
- _initBroadcastChannel() {
193
- if (typeof BroadcastChannel === 'undefined')
194
- return;
195
- try {
196
- this._broadcastChannel = new BroadcastChannel('oxy_auth_sync');
197
- this._broadcastChannel.onmessage = (event) => {
198
- this._handleCrossTabMessage(event.data);
199
- };
200
- }
201
- catch {
202
- // BroadcastChannel not supported or blocked (e.g., opaque origins)
203
- this._broadcastChannel = null;
204
- }
205
- }
206
- /**
207
- * Handle messages from other tabs about token refresh activity.
208
- */
209
- async _handleCrossTabMessage(message) {
210
- if (!message || !message.type)
211
- return;
212
- if (!this._acceptBroadcast(message))
213
- return;
214
- switch (message.type) {
215
- case 'accounts_restored':
216
- case 'authuser_switched':
217
- case 'authuser_signed_out': {
218
- // Another tab restored/switched/dropped a slot. The authoritative
219
- // state lives in the httpOnly cookies which we can't read from JS,
220
- // so the cleanest reaction is to re-run `restoreFromCookies()` on
221
- // a microtask and re-sync our in-memory registry. We swallow
222
- // failures: a transient network error must not bring down a tab
223
- // that already had a valid session.
224
- //
225
- // The restoreFromCookies() body owns the per-slot debounce so a
226
- // burst of N broadcasts only costs one /auth/refresh-all rotation
227
- // (instead of N back-to-back rotations of every cookie slot).
228
- Promise.resolve().then(() => {
229
- this.restoreFromCookies().catch(() => {
230
- // Best-effort; existing accounts (if any) remain intact.
231
- });
232
- });
233
- break;
234
- }
235
- case 'all_signed_out': {
236
- // Wipe the cookie-path registry after another tab signed every slot out.
237
- if (this.refreshTimer) {
238
- clearTimeout(this.refreshTimer);
239
- this.refreshTimer = null;
240
- }
241
- this.refreshPromise = null;
242
- this.accounts.clear();
243
- this.activeAuthuser = null;
244
- this._lastKnownAccessToken = null;
245
- this.oxyServices.httpService.setTokens('');
246
- this.currentUser = null;
247
- this.currentAuthMethod = null;
248
- this.notifyListeners();
249
- break;
250
- }
251
- }
252
- }
253
- /**
254
- * Broadcast a message to other tabs. Always stamps this tab's `tabId` and
255
- * `nonce` onto the message so receivers can run the cross-tab nonce gate.
256
- */
257
- _broadcast(message) {
258
- const stamped = {
259
- ...message,
260
- tabId: this._tabId,
261
- nonce: this._broadcastNonce,
262
- };
263
- try {
264
- this._broadcastChannel?.postMessage(stamped);
265
- }
266
- catch {
267
- // Channel closed or unavailable
268
- }
269
- }
270
- /**
271
- * Generate `bytes` bytes of cryptographic randomness encoded as lowercase
272
- * hex. Prefers Web Crypto's `getRandomValues` when available (browser /
273
- * modern Node); falls back to `Math.random` ONLY in environments without
274
- * Web Crypto (the resulting nonce is still unguessable to a same-origin
275
- * XSS payload — the goal is unforgeability across tabs, not cryptographic
276
- * secrecy across the network).
277
- */
278
- static _randomHex(bytes) {
279
- const buffer = new Uint8Array(bytes);
280
- const gcrypto = typeof globalThis !== 'undefined'
281
- ? globalThis.crypto
282
- : undefined;
283
- if (gcrypto && typeof gcrypto.getRandomValues === 'function') {
284
- gcrypto.getRandomValues(buffer);
285
- }
286
- else {
287
- for (let i = 0; i < buffer.length; i++) {
288
- buffer[i] = Math.floor(Math.random() * 256);
289
- }
290
- }
291
- let hex = '';
292
- for (const byte of buffer) {
293
- hex += byte.toString(16).padStart(2, '0');
294
- }
295
- return hex;
296
- }
297
- /**
298
- * Validate an inbound broadcast against the cross-tab nonce gate.
299
- *
300
- * Returns `true` when the message should be honoured, `false` when it
301
- * MUST be ignored:
302
- * - Message is missing `tabId` or `nonce` → ignore (forged or
303
- * mismatched-version sibling tab).
304
- * - First sighting of `tabId` → record the nonce and honour the message
305
- * (trust-on-first-use, the best we can do without a shared secret).
306
- * - Subsequent message from the same `tabId` with the SAME nonce →
307
- * honour.
308
- * - Subsequent message from the same `tabId` with a DIFFERENT nonce →
309
- * ignore (the canonical "forged broadcast" case — a same-origin XSS
310
- * payload can't read the real tab's `_broadcastNonce`).
311
- *
312
- * Echoes of this tab's own broadcasts (same `tabId`) are also dropped so
313
- * we don't react to our own messages.
314
- */
315
- _acceptBroadcast(message) {
316
- if (!message || typeof message.tabId !== 'string' || typeof message.nonce !== 'string') {
317
- return false;
318
- }
319
- if (message.tabId === this._tabId) {
320
- // Same-tab echo. Some BroadcastChannel implementations deliver our own
321
- // posts back to us; never act on those.
322
- return false;
323
- }
324
- const seen = this._knownPeerNonces.get(message.tabId);
325
- if (seen === undefined) {
326
- // Trust-on-first-use. Bound the map to avoid unbounded growth from a
327
- // tab-id sprayer.
328
- if (this._knownPeerNonces.size >= AuthManager._MAX_KNOWN_PEERS) {
329
- const oldest = this._knownPeerNonces.keys().next().value;
330
- if (oldest !== undefined) {
331
- this._knownPeerNonces.delete(oldest);
332
- }
333
- }
334
- this._knownPeerNonces.set(message.tabId, message.nonce);
335
- return true;
336
- }
337
- return seen === message.nonce;
338
- }
339
- /**
340
- * Get default storage based on environment.
341
- */
342
- getDefaultStorage() {
343
- try {
344
- if (typeof window !== 'undefined' && window.localStorage) {
345
- return new LocalStorageAdapter();
346
- }
347
- }
348
- catch {
349
- // Accessing window.localStorage can throw in opaque-origin/sandboxed
350
- // browser contexts or when storage is disabled. Fall back to memory so
351
- // AuthManager construction remains safe during provider render.
352
- }
353
- return new MemoryStorage();
354
- }
355
- /**
356
- * Subscribe to auth state changes.
357
- *
358
- * @param callback - Function called when auth state changes
359
- * @returns Unsubscribe function
360
- */
361
- onAuthStateChange(callback) {
362
- this.listeners.add(callback);
363
- // Call immediately with current state
364
- callback(this.currentUser);
365
- return () => {
366
- this.listeners.delete(callback);
367
- };
368
- }
369
- /**
370
- * Notify all listeners of auth state change.
371
- */
372
- notifyListeners() {
373
- for (const listener of this.listeners) {
374
- try {
375
- listener(this.currentUser);
376
- }
377
- catch {
378
- // Ignore listener errors
379
- }
380
- }
381
- }
382
- /**
383
- * Handle successful authentication.
384
- *
385
- * @param session - Session response from auth
386
- * @param method - Auth method used
387
- */
388
- async handleAuthSuccess(session, method = 'credentials') {
389
- // Access tokens are memory-only. Fresh login responses plant the token on
390
- // the HTTP client and the AuthManager registry, but never write it to JS
391
- // storage. Durable web refresh lives in the httpOnly cookie set by the API.
392
- if (session.accessToken) {
393
- this._lastKnownAccessToken = session.accessToken;
394
- this.oxyServices.httpService.setTokens(session.accessToken);
395
- }
396
- if (session.user && typeof session.user.id === 'string' && session.user.id.length > 0) {
397
- this.currentUser = session.user;
398
- }
399
- this.currentAuthMethod = method;
400
- // Register this primary session in the device's first-party multi-account
401
- // refresh-cookie set (web only). Every web primary commit funnels through
402
- // here (FedCM, central `/sso` return, credentials), but only a same-apex
403
- // `/fedcm/exchange` plants an `oxy_rt_<authuser>` slot as a side effect — the
404
- // cross-origin/credential-less restores (`/sso/exchange`, the IdP
405
- // `/auth/silent` postMessage) cannot set an `api.oxy.so` cookie. WITHOUT this
406
- // call those primaries never join the device set, so `refresh-all` returns no
407
- // accounts and account-switch persistence has no foundation. The shared
408
- // `POST /auth/session` primitive plants/reuses this user's slot where the
409
- // cookies ARE visible, re-plants the rotated access token, and returns the
410
- // AUTHORITATIVE `authuser` slot (the one actually written) — preferred over the
411
- // JWT-decoded guess, which may not correspond to a real cookie. No-op on native
412
- // (returns `null`); best-effort on transient failure.
413
- const establishedAuthuser = await this.oxyServices.establishDeviceRefreshSlot();
414
- // The slot mint rotated the access token; pick it up so the registry and the
415
- // refresh authority track the cookie just written. Falls back to the original
416
- // session token when the slot was a no-op (native) or failed.
417
- const rotatedToken = (establishedAuthuser !== null ? this.oxyServices.getAccessToken() : null) ??
418
- session.accessToken;
419
- if (rotatedToken) {
420
- this._lastKnownAccessToken = rotatedToken;
421
- }
422
- const decodedAuthuser = rotatedToken
423
- ? AuthManager.decodeAuthuserFromAccessToken(rotatedToken)
424
- : null;
425
- const authuser = establishedAuthuser ?? decodedAuthuser ?? 0;
426
- if (rotatedToken && session.sessionId) {
427
- this.accounts.set(authuser, {
428
- authuser,
429
- sessionId: session.sessionId,
430
- user: {
431
- id: session.user.id,
432
- username: session.user.username,
433
- name: session.user.name,
434
- avatar: session.user.avatar ?? null,
435
- },
436
- accessToken: rotatedToken,
437
- expiresAt: session.expiresAt,
438
- });
439
- this.activeAuthuser = authuser;
440
- await this.writeActiveAuthuser(authuser);
441
- }
442
- // Setup auto-refresh if enabled
443
- if (this.config.autoRefresh && session.expiresAt) {
444
- this.setupCookieRefresh(session.expiresAt, authuser);
445
- }
446
- // Notify listeners
447
- this.notifyListeners();
448
- }
449
- /**
450
- * Refresh the access token. Deduplicates concurrent calls so only one
451
- * refresh request is in-flight at a time. The only refresh authority is the
452
- * active httpOnly refresh-cookie slot; this method never reads access tokens
453
- * from storage.
454
- */
455
- async refreshToken() {
456
- // If a refresh is already in-flight, return the same promise
457
- if (this.refreshPromise) {
458
- return this.refreshPromise;
459
- }
460
- this.refreshPromise = this._doRefreshToken();
461
- try {
462
- return await this.refreshPromise;
463
- }
464
- finally {
465
- this.refreshPromise = null;
466
- }
467
- }
468
- async _doRefreshToken() {
469
- try {
470
- if (this.activeAuthuser !== null) {
471
- await this.switchAuthuser(this.activeAuthuser);
472
- return true;
473
- }
474
- const restored = await this.restoreFromCookies();
475
- return restored.accounts.length > 0;
476
- }
477
- catch {
478
- await this.clearSession();
479
- this.currentUser = null;
480
- this.accounts.clear();
481
- this.activeAuthuser = null;
482
- this._lastKnownAccessToken = null;
483
- this.oxyServices.httpService.setTokens('');
484
- this.notifyListeners();
485
- return false;
486
- }
487
- }
488
- /**
489
- * Sign out and clear all auth data.
490
- */
491
- async signOut() {
492
- // Clear refresh timer and cancel any in-flight refresh
493
- if (this.refreshTimer) {
494
- clearTimeout(this.refreshTimer);
495
- this.refreshTimer = null;
496
- }
497
- this.refreshPromise = null;
498
- // Invalidate current cookie-backed sessions on the server (best-effort)
499
- try {
500
- await this.signOutAllViaCookies();
501
- }
502
- catch {
503
- // Best-effort: don't block local signout on network failure
504
- }
505
- // Revoke FedCM credential if supported
506
- try {
507
- const services = this.oxyServices;
508
- if (services.revokeFedCMCredential) {
509
- await services.revokeFedCMCredential();
510
- }
511
- }
512
- catch {
513
- // Ignore FedCM errors
514
- }
515
- // Clear HTTP client tokens
516
- this.oxyServices.httpService.setTokens('');
517
- this._lastKnownAccessToken = null;
518
- // Clear storage
519
- await this.clearSession();
520
- // Update state and notify
521
- this.currentUser = null;
522
- this.notifyListeners();
523
- }
524
- /**
525
- * Clear local cookie-path state. The only persisted AuthManager value is the
526
- * active numeric slot; tokens and user objects are intentionally memory-only.
527
- */
528
- async clearSession() {
529
- await this.clearActiveAuthuser();
530
- this.currentAuthMethod = null;
531
- }
532
- /**
533
- * Get current user.
534
- */
535
- getCurrentUser() {
536
- return this.currentUser;
537
- }
538
- /**
539
- * Check if user is authenticated.
540
- */
541
- isAuthenticated() {
542
- return this.currentUser !== null;
543
- }
544
- /**
545
- * Get a valid access token, refreshing automatically if expired or expiring
546
- * soon. The token is read from memory only.
547
- */
548
- async getAccessToken() {
549
- const token = this._lastKnownAccessToken;
550
- if (!token)
551
- return null;
552
- try {
553
- const decoded = (0, jwt_decode_1.jwtDecode)(token);
554
- if (decoded.exp) {
555
- const now = Math.floor(Date.now() / 1000);
556
- const buffer = 60; // refresh 60 seconds before expiry
557
- if (decoded.exp - now < buffer) {
558
- const refreshed = await this.refreshToken();
559
- if (refreshed) {
560
- return this._lastKnownAccessToken;
561
- }
562
- }
563
- }
564
- }
565
- catch {
566
- // Decode failed — return token as-is, let the server decide
567
- }
568
- return token;
569
- }
570
- /**
571
- * Get the auth method used for current session.
572
- */
573
- async getAuthMethod() {
574
- return this.currentAuthMethod;
575
- }
576
- /**
577
- * Initialize auth state on app startup.
578
- *
579
- * Only the cookie path is authoritative. `restoreFromCookies()` refreshes
580
- * the httpOnly `oxy_rt_${authuser}` slots through `/auth/refresh-all`,
581
- * plants the active access token in memory, and returns the active user.
582
- * No access token, refresh token, or session JSON is read from localStorage.
583
- *
584
- * Returns the active user on success, or `null` when no cookie-backed
585
- * account was restored.
586
- */
587
- async initialize(options = {}) {
588
- const cookieResult = await this.restoreFromCookies(options);
589
- if (cookieResult.accounts.length > 0) {
590
- return this.currentUser;
591
- }
592
- return null;
593
- }
594
- // -------------------------------------------------------------------------
595
- // Multi-account cookie path (Google-style multi-sign-in).
596
- // -------------------------------------------------------------------------
597
- // The cookie path is web-only. It never touches the retired
598
- // `oxy_access_token` / `oxy_refresh_token` /
599
- // `oxy_session` localStorage keys, because the refresh token lives in the
600
- // httpOnly `oxy_rt_${authuser}` cookies and access tokens live in
601
- // `this.accounts` (in-memory only). The only localStorage key the cookie
602
- // path writes is `STORAGE_KEYS.ACTIVE_AUTHUSER` — a small integer that is
603
- // explicitly NOT a secret.
604
- //
605
- // `initialize()` only uses the cookie path.
606
- // -------------------------------------------------------------------------
607
- /**
608
- * Read the persisted active `authuser` slot index. Returns `null` when
609
- * none is persisted, the value is corrupt, or the storage adapter has no
610
- * record. Storage failures are non-fatal: the cookie path falls back to
611
- * "lowest authuser" deterministic selection.
612
- */
613
- async readActiveAuthuser() {
614
- try {
615
- const raw = await this.storage.getItem(STORAGE_KEYS.ACTIVE_AUTHUSER);
616
- if (raw === null || raw === undefined)
617
- return null;
618
- const parsed = Number.parseInt(raw, 10);
619
- if (!Number.isFinite(parsed) || parsed < 0)
620
- return null;
621
- return parsed;
622
- }
623
- catch {
624
- return null;
625
- }
626
- }
627
- /**
628
- * Persist the active `authuser` slot index. No-ops on storage failure
629
- * (e.g. Safari private mode, native SecureStore unavailable) — this is
630
- * best-effort UX persistence, not authoritative state.
631
- */
632
- async writeActiveAuthuser(authuser) {
633
- if (!Number.isFinite(authuser) || authuser < 0)
634
- return;
635
- try {
636
- await this.storage.setItem(STORAGE_KEYS.ACTIVE_AUTHUSER, String(authuser));
637
- }
638
- catch {
639
- // Best-effort.
640
- }
641
- }
642
- /**
643
- * Clear the persisted active `authuser` so the next cold boot starts from
644
- * a clean slate (used on full sign-out).
645
- */
646
- async clearActiveAuthuser() {
647
- try {
648
- await this.storage.removeItem(STORAGE_KEYS.ACTIVE_AUTHUSER);
649
- }
650
- catch {
651
- // Best-effort.
652
- }
653
- }
654
- /**
655
- * Build a `MinimalUserData` from a `RefreshAllAccount`. Returns `null` when
656
- * the wire entry has no user shape; the AuthManager's caller is expected to
657
- * hydrate via `/users/me` in that case.
658
- */
659
- static toMinimalUser(account) {
660
- if (!account.user)
661
- return null;
662
- return {
663
- id: account.user.id,
664
- username: account.user.username,
665
- name: account.user.name,
666
- avatar: account.user.avatar ?? undefined,
667
- };
668
- }
669
- /**
670
- * Hydrate the user shape for a slot whose AuthManagerAccount currently has
671
- * `user: null` (for example, a switch onto a previously unknown slot). Calls
672
- * `/users/me` with the slot's freshly-planted access
673
- * token already on the HTTP client; merges the result back into the
674
- * registry entry. Network failures are non-fatal — the slot remains with
675
- * `user: null` and the UI is expected to render the public-key fallback
676
- * handle until a later restore picks the real user shape up.
677
- */
678
- async _hydrateUnknownUser(authuser) {
679
- const oxy = this.oxyServices;
680
- if (typeof oxy.getCurrentUser !== 'function')
681
- return;
682
- let me;
683
- try {
684
- me = await oxy.getCurrentUser();
685
- }
686
- catch {
687
- // Best-effort: keep `user: null` and let the UI fall back to the
688
- // public-key handle.
689
- return;
690
- }
691
- const existing = this.accounts.get(authuser);
692
- if (!existing)
693
- return;
694
- const hydrated = {
695
- id: me.id,
696
- username: me.username,
697
- // `User.name` and `RefreshAllAccountUser.name` are the same canonical
698
- // structured `UserNameResponse` shape, so forward it verbatim.
699
- name: me.name,
700
- avatar: me.avatar ?? null,
701
- email: me.email,
702
- color: me.color ?? null,
703
- };
704
- this.accounts.set(authuser, { ...existing, user: hydrated });
705
- // Mirror onto `currentUser` if this is the active slot.
706
- if (this.activeAuthuser === authuser) {
707
- this.currentUser = {
708
- id: hydrated.id,
709
- username: hydrated.username,
710
- name: hydrated.name,
711
- avatar: hydrated.avatar ?? undefined,
712
- };
713
- this.notifyListeners();
714
- }
715
- }
716
- /**
717
- * Snapshot of the registered cookie-path accounts, sorted by `authuser`
718
- * ascending (canonical order). Mutating the returned array does not
719
- * affect AuthManager state.
720
- */
721
- getAccounts() {
722
- return Array.from(this.accounts.values()).sort((a, b) => a.authuser - b.authuser);
723
- }
724
- /**
725
- * The slot index that is currently active in the cookie path, or `null`
726
- * if the cookie path hasn't been initialised or no slots are signed in.
727
- */
728
- getActiveAuthuser() {
729
- return this.activeAuthuser;
730
- }
731
- /**
732
- * Convenience: the AuthManagerAccount currently flagged active.
733
- */
734
- getActiveAccount() {
735
- if (this.activeAuthuser === null)
736
- return null;
737
- return this.accounts.get(this.activeAuthuser) ?? null;
738
- }
739
- /**
740
- * Restore every device-local account from the httpOnly refresh cookies.
741
- *
742
- * Calls `oxyServices.refreshAllSessions()` (`POST /auth/refresh-all` with
743
- * `credentials: 'include'`). The server rotates every presented
744
- * `oxy_rt_${authuser}` cookie in parallel and returns one entry per
745
- * valid slot.
746
- *
747
- * Plants the active account's access token on the shared HTTP client;
748
- * sibling slots' tokens stay in the in-memory registry so a later
749
- * `switchAuthuser()` can hot-swap them without a network round-trip.
750
- *
751
- * The persisted `oxy_active_authuser` slot wins when it matches a
752
- * returned account; otherwise the lowest returned `authuser` is chosen
753
- * deterministically.
754
- *
755
- * Returns `{ accounts: [], activeAuthuser: null }` on any failure or
756
- * empty snapshot — callers treat that as "no signed-in accounts" and
757
- * proceed unauthenticated. State is NOT cleared on failure; existing
758
- * accounts (if any) remain intact.
759
- */
760
- async restoreFromCookies(options = {}) {
761
- // Cross-tab cascade debounce. If we restored within the last
762
- // _RESTORE_DEBOUNCE_MS for the currently-active slot, skip the network
763
- // round-trip and return the cached registry verbatim. A burst of N
764
- // BroadcastChannel events from sibling tabs therefore costs at most one
765
- // /auth/refresh-all rotation. Cold-boot calls (activeAuthuser still
766
- // null) always run because the cache hasn't been seeded yet.
767
- if (this.activeAuthuser !== null) {
768
- const last = this._lastRestoreAt.get(this.activeAuthuser);
769
- if (last !== undefined && Date.now() - last < AuthManager._RESTORE_DEBOUNCE_MS) {
770
- return {
771
- accounts: this.getAccounts(),
772
- activeAuthuser: this.activeAuthuser,
773
- };
774
- }
775
- }
776
- let snapshot;
777
- try {
778
- // Forward the optional cold-boot fail-fast timeout. Undefined (the warm
779
- // cross-tab cascade default) preserves the wait-indefinitely behaviour.
780
- snapshot = await this.oxyServices.refreshAllSessions({ timeout: options.timeout });
781
- }
782
- catch {
783
- return { accounts: [], activeAuthuser: null };
784
- }
785
- if (snapshot.accounts.length === 0) {
786
- return { accounts: [], activeAuthuser: null };
787
- }
788
- // Replace the registry wholesale: the server's snapshot is authoritative.
789
- this.accounts.clear();
790
- for (const account of snapshot.accounts) {
791
- this.accounts.set(account.authuser, {
792
- authuser: account.authuser,
793
- sessionId: account.sessionId,
794
- user: account.user,
795
- accessToken: account.accessToken,
796
- expiresAt: account.expiresAt,
797
- });
798
- }
799
- // Pick the active slot: persisted `oxy_active_authuser` wins if it
800
- // matches a returned account; otherwise the lowest returned authuser
801
- // (the snapshot is already sorted ascending, so accounts[0] is the
802
- // lowest).
803
- const persisted = await this.readActiveAuthuser();
804
- const active = (persisted !== null && this.accounts.has(persisted))
805
- ? persisted
806
- : snapshot.accounts[0].authuser;
807
- this.activeAuthuser = active;
808
- const activeAccount = this.accounts.get(active);
809
- const slotsNeedingHydration = [];
810
- if (activeAccount) {
811
- this._lastKnownAccessToken = activeAccount.accessToken;
812
- this.oxyServices.httpService.setTokens(activeAccount.accessToken);
813
- this.currentUser = AuthManager.toMinimalUser({
814
- authuser: activeAccount.authuser,
815
- accessToken: activeAccount.accessToken,
816
- expiresAt: activeAccount.expiresAt,
817
- sessionId: activeAccount.sessionId,
818
- user: activeAccount.user,
819
- });
820
- await this.writeActiveAuthuser(active);
821
- // Schedule auto-refresh on the active slot so the in-memory access
822
- // token doesn't silently expire under the user.
823
- if (this.config.autoRefresh) {
824
- this.setupCookieRefresh(activeAccount.expiresAt, active);
825
- }
826
- // If the active slot has no user shape, schedule a /users/me hydration so
827
- // the chooser isn't stuck on the public-key handle. Hydration is
828
- // fire-and-forget — the snapshot is already considered "restored" once
829
- // the access token is planted.
830
- if (activeAccount.user === null) {
831
- slotsNeedingHydration.push(activeAccount.authuser);
832
- }
833
- }
834
- this._lastRestoreAt.set(active, Date.now());
835
- this._broadcast({ type: 'accounts_restored', timestamp: Date.now() });
836
- this.notifyListeners();
837
- for (const slot of slotsNeedingHydration) {
838
- void this._hydrateUnknownUser(slot);
839
- }
840
- return {
841
- accounts: this.getAccounts(),
842
- activeAuthuser: this.activeAuthuser,
843
- };
844
- }
845
- /**
846
- * Switch the active account to a different device-local slot.
847
- *
848
- * Calls `oxyServices.refreshTokenViaCookie({ authuser })` to mint a fresh
849
- * access token from the slot's httpOnly cookie, updates the in-memory
850
- * registry entry, plants the token on the HTTP client, persists the new
851
- * active slot, and broadcasts cross-tab.
852
- *
853
- * Throws when the slot's refresh cookie is missing / expired / reused
854
- * (the SDK returns `null` from `refreshTokenViaCookie` in that case, and
855
- * we surface it as an `Error` so callers can clean up the slot from
856
- * their UI).
857
- */
858
- async switchAuthuser(authuser) {
859
- // Concurrency gate. Two near-simultaneous switchAuthuser calls would
860
- // otherwise both POST /auth/refresh?authuser=N, rotating the slot's
861
- // refresh-token family twice and racing on the registry update. The
862
- // gate is keyed only by "any switch in flight" — switching to a
863
- // DIFFERENT slot while a switch is in flight returns the in-flight
864
- // promise (callers can re-issue once it settles if they really meant a
865
- // different slot).
866
- if (this._switchPromise) {
867
- return this._switchPromise;
868
- }
869
- this._switchPromise = this._doSwitchAuthuser(authuser);
870
- try {
871
- return await this._switchPromise;
872
- }
873
- finally {
874
- this._switchPromise = null;
875
- }
876
- }
877
- async _doSwitchAuthuser(authuser) {
878
- const refreshed = await this.oxyServices.refreshTokenViaCookie({ authuser });
879
- if (refreshed === null) {
880
- // Drop the dead slot from our registry so the chooser doesn't keep
881
- // offering it; callers can drive a `restoreFromCookies()` to
882
- // re-sync.
883
- this.accounts.delete(authuser);
884
- if (this.activeAuthuser === authuser) {
885
- this.activeAuthuser = null;
886
- }
887
- throw new Error(`Refresh cookie for authuser=${authuser} is missing or expired`);
888
- }
889
- // Update (or insert) the slot in the registry. We preserve any user
890
- // metadata we already knew from a prior `restoreFromCookies` — the
891
- // single-slot refresh endpoint does NOT re-project the user shape. When
892
- // we have no prior metadata, we leave `user: null` and schedule a
893
- // /users/me hydration below.
894
- const existing = this.accounts.get(authuser);
895
- const decoded = AuthManager.decodeSessionIdFromAccessToken(refreshed.accessToken);
896
- const sessionId = decoded ?? existing?.sessionId ?? '';
897
- const updated = {
898
- authuser,
899
- sessionId,
900
- user: existing?.user ?? null,
901
- accessToken: refreshed.accessToken,
902
- expiresAt: refreshed.expiresAt,
903
- };
904
- this.accounts.set(authuser, updated);
905
- this.activeAuthuser = authuser;
906
- this._lastKnownAccessToken = refreshed.accessToken;
907
- this.oxyServices.httpService.setTokens(refreshed.accessToken);
908
- this.currentUser = updated.user
909
- ? {
910
- id: updated.user.id,
911
- username: updated.user.username,
912
- name: updated.user.name,
913
- avatar: updated.user.avatar ?? undefined,
914
- }
915
- : null;
916
- await this.writeActiveAuthuser(authuser);
917
- if (this.config.autoRefresh) {
918
- this.setupCookieRefresh(refreshed.expiresAt, authuser);
919
- }
920
- this._broadcast({ type: 'authuser_switched', authuser, timestamp: Date.now() });
921
- this.notifyListeners();
922
- if (updated.user === null) {
923
- // Fire-and-forget hydration: the switch is considered complete once
924
- // the token is planted, the UI uses getAccountFallbackHandle (public-
925
- // key fallback) until /users/me resolves.
926
- void this._hydrateUnknownUser(authuser);
927
- }
928
- return {
929
- accessToken: refreshed.accessToken,
930
- expiresAt: refreshed.expiresAt,
931
- authuser,
932
- };
933
- }
934
- /**
935
- * Sign out a single device-local slot.
936
- *
937
- * Calls `oxyServices.logoutSessionByAuthuser(authuser)`: server-side
938
- * revokes the slot's refresh-token family and clears the
939
- * `oxy_rt_${authuser}` cookie via `Set-Cookie`. The slot is removed from
940
- * the in-memory registry. If the slot was active, the next lowest
941
- * remaining authuser becomes active (or `null` when none remain).
942
- */
943
- async signOutAuthuser(authuser) {
944
- try {
945
- await this.oxyServices.logoutSessionByAuthuser(authuser);
946
- }
947
- catch {
948
- // Best-effort: the server-side logout is idempotent on unknown
949
- // tokens, and we'd rather drop the slot locally than leave dead
950
- // state on a network blip.
951
- }
952
- this.accounts.delete(authuser);
953
- if (this.activeAuthuser === authuser) {
954
- const remaining = this.getAccounts();
955
- if (remaining.length > 0) {
956
- // Pick the lowest remaining authuser as the new active. We don't
957
- // proactively refresh its token here — callers can drive
958
- // `switchAuthuser` if they need a fresh bearer. This keeps the
959
- // method's network footprint to exactly one request.
960
- const next = remaining[0];
961
- this.activeAuthuser = next.authuser;
962
- this._lastKnownAccessToken = next.accessToken;
963
- this.oxyServices.httpService.setTokens(next.accessToken);
964
- this.currentUser = next.user
965
- ? {
966
- id: next.user.id,
967
- username: next.user.username,
968
- name: next.user.name,
969
- avatar: next.user.avatar ?? undefined,
970
- }
971
- : null;
972
- await this.writeActiveAuthuser(next.authuser);
973
- if (next.user === null) {
974
- void this._hydrateUnknownUser(next.authuser);
975
- }
976
- }
977
- else {
978
- this.activeAuthuser = null;
979
- this._lastKnownAccessToken = null;
980
- this.oxyServices.httpService.setTokens('');
981
- this.currentUser = null;
982
- this.currentAuthMethod = null;
983
- await this.clearActiveAuthuser();
984
- }
985
- }
986
- this._broadcast({ type: 'authuser_signed_out', authuser, timestamp: Date.now() });
987
- this.notifyListeners();
988
- }
989
- /**
990
- * Sign out EVERY device-local account on this device.
991
- *
992
- * Calls `oxyServices.logoutAllSessionsViaCookie()`: server-side revokes
993
- * every presented family and `Set-Cookie`s an immediate expiry for every
994
- * recognised `oxy_rt_${n}` slot. The in-memory registry is wiped, the active
995
- * slot is cleared, and the persisted `oxy_active_authuser` is removed so the
996
- * next cold boot starts fresh.
997
- */
998
- async signOutAllViaCookies() {
999
- try {
1000
- await this.oxyServices.logoutAllSessionsViaCookie();
1001
- }
1002
- catch {
1003
- // Best-effort; server-side endpoint is idempotent.
1004
- }
1005
- this.accounts.clear();
1006
- this.activeAuthuser = null;
1007
- this._lastKnownAccessToken = null;
1008
- this.oxyServices.httpService.setTokens('');
1009
- this.currentUser = null;
1010
- this.currentAuthMethod = null;
1011
- this._lastRestoreAt.clear();
1012
- await this.clearActiveAuthuser();
1013
- // Also clear the refresh timer that the cookie path may have scheduled.
1014
- if (this.refreshTimer) {
1015
- clearTimeout(this.refreshTimer);
1016
- this.refreshTimer = null;
1017
- }
1018
- this._broadcast({ type: 'all_signed_out', timestamp: Date.now() });
1019
- this.notifyListeners();
1020
- }
1021
- /**
1022
- * Schedule an auto-refresh for the cookie path on the active slot. The
1023
- * AuthManager has exactly one active slot at a time, so one timer suffices.
1024
- */
1025
- setupCookieRefresh(expiresAt, authuser) {
1026
- if (this.refreshTimer) {
1027
- clearTimeout(this.refreshTimer);
1028
- }
1029
- const expiresAtMs = new Date(expiresAt).getTime();
1030
- if (!Number.isFinite(expiresAtMs))
1031
- return;
1032
- const refreshAt = expiresAtMs - this.config.refreshBuffer;
1033
- const delay = Math.max(0, refreshAt - Date.now());
1034
- this.refreshTimer = setTimeout(() => {
1035
- // Only refresh if this slot is still the active one when the timer
1036
- // fires (the user might have switched in the meantime).
1037
- if (this.activeAuthuser !== authuser)
1038
- return;
1039
- this.switchAuthuser(authuser).catch(() => {
1040
- // A failed cookie refresh on the active slot means the user must
1041
- // re-auth; surface via `notifyListeners` indirectly when the slot
1042
- // is dropped from the registry by `switchAuthuser`.
1043
- });
1044
- }, delay);
1045
- }
1046
- /**
1047
- * Decode the session id from an unverified JWT access token. Decode-only
1048
- * (no signature verification) — the server already verified the
1049
- * signature when minting the token. Returns `null` on malformed input.
1050
- */
1051
- static decodeSessionIdFromAccessToken(token) {
1052
- try {
1053
- const decoded = (0, jwt_decode_1.jwtDecode)(token);
1054
- return typeof decoded.sessionId === 'string' && decoded.sessionId.length > 0
1055
- ? decoded.sessionId
1056
- : null;
1057
- }
1058
- catch {
1059
- return null;
1060
- }
1061
- }
1062
- static decodeAuthuserFromAccessToken(token) {
1063
- try {
1064
- const decoded = (0, jwt_decode_1.jwtDecode)(token);
1065
- return typeof decoded.authuser === 'number' && Number.isFinite(decoded.authuser) && decoded.authuser >= 0
1066
- ? decoded.authuser
1067
- : null;
1068
- }
1069
- catch {
1070
- return null;
1071
- }
1072
- }
1073
- /**
1074
- * Destroy the auth manager and clean up resources.
1075
- */
1076
- destroy() {
1077
- if (this.refreshTimer) {
1078
- clearTimeout(this.refreshTimer);
1079
- this.refreshTimer = null;
1080
- }
1081
- this.listeners.clear();
1082
- this._knownPeerNonces.clear();
1083
- this._lastRestoreAt.clear();
1084
- this._switchPromise = null;
1085
- this.refreshPromise = null;
1086
- // Close BroadcastChannel
1087
- if (this._broadcastChannel) {
1088
- try {
1089
- this._broadcastChannel.close();
1090
- }
1091
- catch {
1092
- // Ignore close errors
1093
- }
1094
- this._broadcastChannel = null;
1095
- }
1096
- }
1097
- }
1098
- exports.AuthManager = AuthManager;
1099
- AuthManager._MAX_KNOWN_PEERS = 32;
1100
- AuthManager._RESTORE_DEBOUNCE_MS = 2000;
1101
- /**
1102
- * Create an AuthManager instance.
1103
- *
1104
- * @param oxyServices - OxyServices instance
1105
- * @param config - Optional configuration
1106
- * @returns AuthManager instance
1107
- */
1108
- function createAuthManager(oxyServices, config) {
1109
- return new AuthManager(oxyServices, config);
1110
- }