@oxyhq/core 3.4.0 → 3.4.2

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 (174) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +91 -319
  3. package/dist/cjs/CrossDomainAuth.js +19 -106
  4. package/dist/cjs/HttpService.js +49 -73
  5. package/dist/cjs/OxyServices.base.js +2 -2
  6. package/dist/cjs/i18n/index.js +7 -1
  7. package/dist/cjs/i18n/locales/ar-SA.json +18 -2
  8. package/dist/cjs/i18n/locales/ca-ES.json +18 -2
  9. package/dist/cjs/i18n/locales/de-DE.json +18 -2
  10. package/dist/cjs/i18n/locales/en-US.json +16 -2
  11. package/dist/cjs/i18n/locales/es-ES.json +16 -2
  12. package/dist/cjs/i18n/locales/fr-FR.json +18 -2
  13. package/dist/cjs/i18n/locales/it-IT.json +18 -2
  14. package/dist/cjs/i18n/locales/ja-JP.json +18 -2
  15. package/dist/cjs/i18n/locales/ko-KR.json +18 -2
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +18 -2
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +18 -2
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +18 -2
  19. package/dist/cjs/i18n/locales/locales/en-US.json +17 -3
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -2
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +18 -2
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +18 -2
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +18 -2
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +18 -2
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +18 -2
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +18 -2
  27. package/dist/cjs/i18n/locales/pt-PT.json +18 -2
  28. package/dist/cjs/i18n/locales/zh-CN.json +18 -2
  29. package/dist/cjs/mixins/OxyServices.auth.js +20 -63
  30. package/dist/cjs/mixins/OxyServices.fedcm.js +10 -12
  31. package/dist/cjs/mixins/OxyServices.popup.js +50 -299
  32. package/dist/cjs/mixins/OxyServices.redirect.js +84 -348
  33. package/dist/cjs/mixins/OxyServices.silent.js +204 -0
  34. package/dist/cjs/mixins/OxyServices.sso.js +4 -5
  35. package/dist/cjs/mixins/OxyServices.utility.js +6 -15
  36. package/dist/cjs/mixins/index.js +5 -6
  37. package/dist/cjs/server/index.js +21 -0
  38. package/dist/cjs/server/rateLimit.js +77 -0
  39. package/dist/cjs/shared/utils/debugUtils.js +1 -1
  40. package/dist/cjs/utils/accountUtils.js +4 -4
  41. package/dist/cjs/utils/authHelpers.js +21 -15
  42. package/dist/cjs/utils/coldBoot.js +3 -3
  43. package/dist/cjs/utils/fapiAutoDetect.js +1 -1
  44. package/dist/esm/.tsbuildinfo +1 -1
  45. package/dist/esm/AuthManager.js +91 -319
  46. package/dist/esm/CrossDomainAuth.js +19 -106
  47. package/dist/esm/HttpService.js +49 -73
  48. package/dist/esm/OxyServices.base.js +2 -2
  49. package/dist/esm/i18n/index.js +7 -1
  50. package/dist/esm/i18n/locales/ar-SA.json +18 -2
  51. package/dist/esm/i18n/locales/ca-ES.json +18 -2
  52. package/dist/esm/i18n/locales/de-DE.json +18 -2
  53. package/dist/esm/i18n/locales/en-US.json +16 -2
  54. package/dist/esm/i18n/locales/es-ES.json +16 -2
  55. package/dist/esm/i18n/locales/fr-FR.json +18 -2
  56. package/dist/esm/i18n/locales/it-IT.json +18 -2
  57. package/dist/esm/i18n/locales/ja-JP.json +18 -2
  58. package/dist/esm/i18n/locales/ko-KR.json +18 -2
  59. package/dist/esm/i18n/locales/locales/ar-SA.json +18 -2
  60. package/dist/esm/i18n/locales/locales/ca-ES.json +18 -2
  61. package/dist/esm/i18n/locales/locales/de-DE.json +18 -2
  62. package/dist/esm/i18n/locales/locales/en-US.json +17 -3
  63. package/dist/esm/i18n/locales/locales/es-ES.json +16 -2
  64. package/dist/esm/i18n/locales/locales/fr-FR.json +18 -2
  65. package/dist/esm/i18n/locales/locales/it-IT.json +18 -2
  66. package/dist/esm/i18n/locales/locales/ja-JP.json +18 -2
  67. package/dist/esm/i18n/locales/locales/ko-KR.json +18 -2
  68. package/dist/esm/i18n/locales/locales/pt-PT.json +18 -2
  69. package/dist/esm/i18n/locales/locales/zh-CN.json +18 -2
  70. package/dist/esm/i18n/locales/pt-PT.json +18 -2
  71. package/dist/esm/i18n/locales/zh-CN.json +18 -2
  72. package/dist/esm/mixins/OxyServices.auth.js +20 -63
  73. package/dist/esm/mixins/OxyServices.fedcm.js +10 -12
  74. package/dist/esm/mixins/OxyServices.popup.js +52 -301
  75. package/dist/esm/mixins/OxyServices.redirect.js +84 -349
  76. package/dist/esm/mixins/OxyServices.silent.js +202 -0
  77. package/dist/esm/mixins/OxyServices.sso.js +4 -5
  78. package/dist/esm/mixins/OxyServices.utility.js +6 -15
  79. package/dist/esm/mixins/index.js +5 -6
  80. package/dist/esm/server/index.js +17 -0
  81. package/dist/esm/server/rateLimit.js +71 -0
  82. package/dist/esm/shared/utils/debugUtils.js +1 -1
  83. package/dist/esm/utils/accountUtils.js +4 -4
  84. package/dist/esm/utils/authHelpers.js +21 -15
  85. package/dist/esm/utils/coldBoot.js +3 -3
  86. package/dist/esm/utils/fapiAutoDetect.js +1 -1
  87. package/dist/types/.tsbuildinfo +1 -1
  88. package/dist/types/AuthManager.d.ts +26 -53
  89. package/dist/types/AuthManagerTypes.d.ts +5 -9
  90. package/dist/types/CrossDomainAuth.d.ts +13 -52
  91. package/dist/types/HttpService.d.ts +9 -8
  92. package/dist/types/OxyServices.base.d.ts +1 -1
  93. package/dist/types/OxyServices.d.ts +4 -10
  94. package/dist/types/index.d.ts +1 -1
  95. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -1
  96. package/dist/types/mixins/OxyServices.appData.d.ts +1 -1
  97. package/dist/types/mixins/OxyServices.applications.d.ts +1 -1
  98. package/dist/types/mixins/OxyServices.assets.d.ts +1 -1
  99. package/dist/types/mixins/OxyServices.auth.d.ts +10 -31
  100. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -1
  101. package/dist/types/mixins/OxyServices.devices.d.ts +1 -1
  102. package/dist/types/mixins/OxyServices.features.d.ts +1 -1
  103. package/dist/types/mixins/OxyServices.fedcm.d.ts +5 -5
  104. package/dist/types/mixins/OxyServices.language.d.ts +1 -1
  105. package/dist/types/mixins/OxyServices.location.d.ts +1 -1
  106. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -1
  107. package/dist/types/mixins/OxyServices.payment.d.ts +1 -1
  108. package/dist/types/mixins/OxyServices.popup.d.ts +18 -120
  109. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -1
  110. package/dist/types/mixins/OxyServices.redirect.d.ts +13 -174
  111. package/dist/types/mixins/OxyServices.reputation.d.ts +1 -1
  112. package/dist/types/mixins/OxyServices.security.d.ts +1 -1
  113. package/dist/types/mixins/OxyServices.silent.d.ts +131 -0
  114. package/dist/types/mixins/OxyServices.sso.d.ts +4 -5
  115. package/dist/types/mixins/OxyServices.topics.d.ts +1 -1
  116. package/dist/types/mixins/OxyServices.user.d.ts +1 -1
  117. package/dist/types/mixins/OxyServices.utility.d.ts +3 -8
  118. package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -1
  119. package/dist/types/mixins/index.d.ts +3 -3
  120. package/dist/types/models/interfaces.d.ts +5 -16
  121. package/dist/types/models/session.d.ts +0 -2
  122. package/dist/types/server/index.d.ts +18 -0
  123. package/dist/types/server/rateLimit.d.ts +40 -0
  124. package/dist/types/shared/utils/debugUtils.d.ts +1 -1
  125. package/dist/types/utils/authHelpers.d.ts +4 -3
  126. package/dist/types/utils/coldBoot.d.ts +2 -2
  127. package/dist/types/utils/fapiAutoDetect.d.ts +1 -1
  128. package/package.json +25 -3
  129. package/src/AuthManager.ts +100 -370
  130. package/src/AuthManagerTypes.ts +5 -9
  131. package/src/CrossDomainAuth.ts +22 -129
  132. package/src/HttpService.ts +55 -73
  133. package/src/OxyServices.base.ts +2 -3
  134. package/src/OxyServices.ts +9 -11
  135. package/src/__tests__/authManager.cookiePath.test.ts +19 -17
  136. package/src/__tests__/authManager.security.test.ts +7 -3
  137. package/src/__tests__/crossDomainAuth.test.ts +26 -118
  138. package/src/i18n/index.ts +7 -1
  139. package/src/i18n/locales/ar-SA.json +18 -2
  140. package/src/i18n/locales/ca-ES.json +18 -2
  141. package/src/i18n/locales/de-DE.json +18 -2
  142. package/src/i18n/locales/en-US.json +17 -3
  143. package/src/i18n/locales/es-ES.json +16 -2
  144. package/src/i18n/locales/fr-FR.json +18 -2
  145. package/src/i18n/locales/it-IT.json +18 -2
  146. package/src/i18n/locales/ja-JP.json +18 -2
  147. package/src/i18n/locales/ko-KR.json +18 -2
  148. package/src/i18n/locales/pt-PT.json +18 -2
  149. package/src/i18n/locales/zh-CN.json +18 -2
  150. package/src/index.ts +1 -1
  151. package/src/mixins/OxyServices.auth.ts +23 -75
  152. package/src/mixins/OxyServices.fedcm.ts +10 -12
  153. package/src/mixins/OxyServices.redirect.ts +82 -371
  154. package/src/mixins/OxyServices.silent.ts +272 -0
  155. package/src/mixins/OxyServices.sso.ts +5 -6
  156. package/src/mixins/OxyServices.utility.ts +9 -22
  157. package/src/mixins/__tests__/appData.test.ts +1 -1
  158. package/src/mixins/__tests__/onTokensChanged.test.ts +1 -1
  159. package/src/mixins/__tests__/reputation.test.ts +1 -1
  160. package/src/mixins/__tests__/serviceAuth.test.ts +7 -5
  161. package/src/mixins/__tests__/silent.test.ts +102 -0
  162. package/src/mixins/__tests__/verifyChallenge.test.ts +9 -14
  163. package/src/mixins/index.ts +6 -8
  164. package/src/models/interfaces.ts +5 -16
  165. package/src/models/session.ts +1 -3
  166. package/src/server/index.ts +19 -0
  167. package/src/server/rateLimit.ts +170 -0
  168. package/src/shared/utils/debugUtils.ts +1 -1
  169. package/src/utils/accountUtils.ts +4 -4
  170. package/src/utils/authHelpers.ts +23 -15
  171. package/src/utils/coldBoot.ts +4 -4
  172. package/src/utils/fapiAutoDetect.ts +1 -1
  173. package/src/mixins/OxyServices.popup.ts +0 -631
  174. package/src/mixins/__tests__/popup.test.ts +0 -374
@@ -8,7 +8,6 @@
8
8
  */
9
9
 
10
10
  import type { OxyServices } from './OxyServices';
11
- import type { HttpService } from './HttpService';
12
11
  import type { SessionLoginResponse, MinimalUserData } from './models/session';
13
12
  import type {
14
13
  RefreshAllAccount,
@@ -23,7 +22,6 @@ import type {
23
22
  RestoreFromCookiesOptions,
24
23
  SwitchAuthuserResult,
25
24
  } from './AuthManagerTypes';
26
- import { retryAsync } from './utils/asyncUtils';
27
25
  import { jwtDecode } from 'jwt-decode';
28
26
 
29
27
  /**
@@ -49,7 +47,7 @@ export type AuthStateChangeCallback = (user: MinimalUserData | null) => void;
49
47
  /**
50
48
  * Auth method types.
51
49
  */
52
- export type AuthMethod = 'fedcm' | 'popup' | 'redirect' | 'credentials' | 'identity';
50
+ export type AuthMethod = 'fedcm' | 'redirect' | 'credentials' | 'identity';
53
51
 
54
52
  /**
55
53
  * Auth manager configuration.
@@ -63,30 +61,14 @@ export interface AuthManagerConfig {
63
61
  refreshBuffer?: number;
64
62
  /** Enable cross-tab coordination via BroadcastChannel (default: true in browsers) */
65
63
  crossTabSync?: boolean;
66
- /**
67
- * "Cookie-only" mode for web apps that rely exclusively on the
68
- * `oxy_rt_${authuser}` httpOnly refresh cookies and refuse to fall back
69
- * to the legacy localStorage token/refresh-token path.
70
- *
71
- * - `false` (default): `initialize()` tries `restoreFromCookies()` first;
72
- * if no accounts are restored it falls back to the legacy localStorage
73
- * path (`oxy_access_token` / `oxy_session`).
74
- * - `true`: `initialize()` ONLY uses `restoreFromCookies()`. No token /
75
- * refresh-token / session JSON is read from or written to localStorage.
76
- * This is the secure default for apps that ship the cookie path end-to-
77
- * end and want to guarantee no tokens leak to JS-accessible storage.
78
- */
79
- cookieOnly?: boolean;
80
64
  }
81
65
 
82
66
  /**
83
67
  * Messages sent between tabs via BroadcastChannel for token refresh coordination.
84
68
  *
85
- * Legacy bearer-path messages (`refresh_starting`, `tokens_refreshed`,
86
- * `signed_out`) coexist with the multi-account cookie-path messages
87
- * (`accounts_restored`, `authuser_switched`, `authuser_signed_out`,
88
- * `all_signed_out`). The handler ignores unknown types defensively so a
89
- * mismatched-version sibling tab can't crash this one.
69
+ * Multi-account cookie-path messages keep same-origin tabs aligned while the
70
+ * httpOnly refresh cookies remain the authority. The handler ignores unknown
71
+ * types defensively so a mismatched-version sibling tab can't crash this one.
90
72
  *
91
73
  * Every outgoing message also carries the sender tab's `tabId` and `nonce`
92
74
  * (see `_broadcastNonce` / `_tabId` on AuthManager). The receiver records the
@@ -96,15 +78,12 @@ export interface AuthManagerConfig {
96
78
  */
97
79
  interface CrossTabMessage {
98
80
  type:
99
- | 'refresh_starting'
100
- | 'tokens_refreshed'
101
- | 'signed_out'
102
81
  | 'accounts_restored'
103
82
  | 'authuser_switched'
104
83
  | 'authuser_signed_out'
105
84
  | 'all_signed_out';
106
85
  sessionId?: string;
107
- /** Slot index for `authuser_*` events; absent on legacy bearer events. */
86
+ /** Slot index for `authuser_*` events. */
108
87
  authuser?: number;
109
88
  timestamp: number;
110
89
  /** Sender-tab identifier (random hex, generated at AuthManager construction). */
@@ -113,16 +92,7 @@ interface CrossTabMessage {
113
92
  nonce: string;
114
93
  }
115
94
 
116
- /**
117
- * Storage keys used by AuthManager.
118
- */
119
95
  const STORAGE_KEYS = {
120
- ACCESS_TOKEN: 'oxy_access_token',
121
- REFRESH_TOKEN: 'oxy_refresh_token',
122
- SESSION: 'oxy_session',
123
- USER: 'oxy_user',
124
- AUTH_METHOD: 'oxy_auth_method',
125
- FEDCM_LOGIN_HINT: 'oxy_fedcm_login_hint',
126
96
  /**
127
97
  * Persisted active `authuser` slot index for the cookie path. Stores ONLY
128
98
  * the integer slot index (e.g. `"0"`, `"1"`), never a token or session
@@ -214,11 +184,11 @@ export class AuthManager {
214
184
  private storage: StorageAdapter;
215
185
  private listeners: Set<AuthStateChangeCallback> = new Set();
216
186
  private currentUser: MinimalUserData | null = null;
187
+ private currentAuthMethod: AuthMethod | null = null;
217
188
  private refreshTimer: ReturnType<typeof setTimeout> | null = null;
218
189
  private refreshPromise: Promise<boolean> | null = null;
219
- private config: Required<Omit<AuthManagerConfig, 'crossTabSync' | 'cookieOnly'>> & {
190
+ private config: Required<Omit<AuthManagerConfig, 'crossTabSync'>> & {
220
191
  crossTabSync: boolean;
221
- cookieOnly: boolean;
222
192
  };
223
193
 
224
194
  /** Tracks the access token this instance last knew about, for cross-tab adoption. */
@@ -227,9 +197,6 @@ export class AuthManager {
227
197
  /** BroadcastChannel for coordinating token refreshes across browser tabs. */
228
198
  private _broadcastChannel: BroadcastChannel | null = null;
229
199
 
230
- /** Set to true when another tab broadcasts a successful refresh, so this tab can skip its own. */
231
- private _otherTabRefreshed = false;
232
-
233
200
  /**
234
201
  * Identifier for this AuthManager instance (≈ "this tab"). Random hex
235
202
  * generated at construction; advertised in every outgoing broadcast and
@@ -305,15 +272,13 @@ export class AuthManager {
305
272
  autoRefresh: config.autoRefresh ?? true,
306
273
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
307
274
  crossTabSync,
308
- cookieOnly: config.cookieOnly ?? false,
309
275
  };
310
276
  this.storage = this.config.storage;
311
277
 
312
- // Persist tokens to storage when HttpService refreshes them automatically
313
- this.oxyServices.httpService.onTokenRefreshed = (accessToken: string) => {
314
- this._lastKnownAccessToken = accessToken;
315
- this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
316
- };
278
+ this.oxyServices.httpService.setAuthRefreshHandler(async () => {
279
+ const refreshed = await this.refreshToken();
280
+ return refreshed ? this._lastKnownAccessToken : null;
281
+ });
317
282
 
318
283
  // Setup cross-tab coordination in browser environments
319
284
  if (this.config.crossTabSync) {
@@ -347,46 +312,6 @@ export class AuthManager {
347
312
  if (!this._acceptBroadcast(message)) return;
348
313
 
349
314
  switch (message.type) {
350
- case 'tokens_refreshed': {
351
- // Another tab successfully refreshed. Signal to cancel our pending refresh.
352
- this._otherTabRefreshed = true;
353
-
354
- // Adopt the new tokens from shared storage
355
- const newToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
356
- if (newToken && newToken !== this._lastKnownAccessToken) {
357
- this._lastKnownAccessToken = newToken;
358
- this.oxyServices.httpService.setTokens(newToken);
359
-
360
- // Re-read session for updated expiry and schedule next refresh
361
- const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
362
- if (sessionJson) {
363
- try {
364
- const session = JSON.parse(sessionJson);
365
- if (session.expiresAt && this.config.autoRefresh) {
366
- this.setupTokenRefresh(session.expiresAt);
367
- }
368
- } catch {
369
- // Ignore parse errors
370
- }
371
- }
372
- }
373
- break;
374
- }
375
-
376
- case 'signed_out': {
377
- // Another tab signed out. Clear our local state to stay consistent.
378
- if (this.refreshTimer) {
379
- clearTimeout(this.refreshTimer);
380
- this.refreshTimer = null;
381
- }
382
- this.refreshPromise = null;
383
- this._lastKnownAccessToken = null;
384
- this.oxyServices.httpService.setTokens('');
385
- this.currentUser = null;
386
- this.notifyListeners();
387
- break;
388
- }
389
-
390
315
  case 'accounts_restored':
391
316
  case 'authuser_switched':
392
317
  case 'authuser_signed_out': {
@@ -409,7 +334,7 @@ export class AuthManager {
409
334
  }
410
335
 
411
336
  case 'all_signed_out': {
412
- // Mirror `signed_out` but also wipe the cookie-path registry.
337
+ // Wipe the cookie-path registry after another tab signed every slot out.
413
338
  if (this.refreshTimer) {
414
339
  clearTimeout(this.refreshTimer);
415
340
  this.refreshTimer = null;
@@ -420,11 +345,11 @@ export class AuthManager {
420
345
  this._lastKnownAccessToken = null;
421
346
  this.oxyServices.httpService.setTokens('');
422
347
  this.currentUser = null;
348
+ this.currentAuthMethod = null;
423
349
  this.notifyListeners();
424
350
  break;
425
351
  }
426
352
 
427
- // 'refresh_starting' is informational; we don't need to act on it currently
428
353
  }
429
354
  }
430
355
 
@@ -564,68 +489,54 @@ export class AuthManager {
564
489
  session: SessionLoginResponse,
565
490
  method: AuthMethod = 'credentials'
566
491
  ): Promise<void> {
567
- // Store tokens
492
+ // Access tokens are memory-only. Fresh login responses plant the token on
493
+ // the HTTP client and the AuthManager registry, but never write it to JS
494
+ // storage. Durable web refresh lives in the httpOnly cookie set by the API.
568
495
  if (session.accessToken) {
569
496
  this._lastKnownAccessToken = session.accessToken;
570
- await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, session.accessToken);
571
497
  this.oxyServices.httpService.setTokens(session.accessToken);
572
498
  }
573
499
 
574
- // Store refresh token if available
575
- if (session.refreshToken) {
576
- await this.storage.setItem(STORAGE_KEYS.REFRESH_TOKEN, session.refreshToken);
577
- }
578
-
579
- // Store session info
580
- await this.storage.setItem(STORAGE_KEYS.SESSION, JSON.stringify({
581
- sessionId: session.sessionId,
582
- deviceId: session.deviceId,
583
- expiresAt: session.expiresAt,
584
- }));
585
-
586
- // Store user only if it has valid required fields (not an empty placeholder)
587
500
  if (session.user && typeof (session.user as any).id === 'string' && (session.user as any).id.length > 0) {
588
- await this.storage.setItem(STORAGE_KEYS.USER, JSON.stringify(session.user));
589
501
  this.currentUser = session.user;
590
502
  }
591
503
 
592
- // Store auth method
593
- await this.storage.setItem(STORAGE_KEYS.AUTH_METHOD, method);
504
+ this.currentAuthMethod = method;
505
+
506
+ const decodedAuthuser = session.accessToken
507
+ ? AuthManager.decodeAuthuserFromAccessToken(session.accessToken)
508
+ : null;
509
+ const authuser = decodedAuthuser ?? 0;
510
+ if (session.accessToken && session.sessionId) {
511
+ this.accounts.set(authuser, {
512
+ authuser,
513
+ sessionId: session.sessionId,
514
+ user: {
515
+ id: session.user.id,
516
+ username: session.user.username,
517
+ avatar: session.user.avatar ?? null,
518
+ },
519
+ accessToken: session.accessToken,
520
+ expiresAt: session.expiresAt,
521
+ });
522
+ this.activeAuthuser = authuser;
523
+ await this.writeActiveAuthuser(authuser);
524
+ }
594
525
 
595
526
  // Setup auto-refresh if enabled
596
527
  if (this.config.autoRefresh && session.expiresAt) {
597
- this.setupTokenRefresh(session.expiresAt);
528
+ this.setupCookieRefresh(session.expiresAt, authuser);
598
529
  }
599
530
 
600
531
  // Notify listeners
601
532
  this.notifyListeners();
602
533
  }
603
534
 
604
- /**
605
- * Setup automatic token refresh.
606
- */
607
- private setupTokenRefresh(expiresAt: string): void {
608
- if (this.refreshTimer) {
609
- clearTimeout(this.refreshTimer);
610
- }
611
-
612
- const expiresAtMs = new Date(expiresAt).getTime();
613
- const now = Date.now();
614
- const refreshAt = expiresAtMs - this.config.refreshBuffer;
615
- const delay = Math.max(0, refreshAt - now);
616
-
617
- if (delay > 0) {
618
- this.refreshTimer = setTimeout(() => {
619
- this.refreshToken().catch(() => {
620
- // Refresh failed, user will need to re-auth
621
- });
622
- }, delay);
623
- }
624
- }
625
-
626
535
  /**
627
536
  * Refresh the access token. Deduplicates concurrent calls so only one
628
- * refresh request is in-flight at a time.
537
+ * refresh request is in-flight at a time. The only refresh authority is the
538
+ * active httpOnly refresh-cookie slot; this method never reads access tokens
539
+ * from storage.
629
540
  */
630
541
  async refreshToken(): Promise<boolean> {
631
542
  // If a refresh is already in-flight, return the same promise
@@ -642,131 +553,21 @@ export class AuthManager {
642
553
  }
643
554
 
644
555
  private async _doRefreshToken(): Promise<boolean> {
645
- // Reset the cross-tab flag before starting
646
- this._otherTabRefreshed = false;
647
-
648
- // Get session info to find sessionId for token refresh
649
- const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
650
- if (!sessionJson) {
651
- return false;
652
- }
653
-
654
- let sessionId: string;
655
556
  try {
656
- const session = JSON.parse(sessionJson);
657
- sessionId = session.sessionId;
658
- if (!sessionId) return false;
659
- } catch (err) {
660
- console.error('AuthManager: Failed to parse session from storage.', err);
661
- return false;
662
- }
663
-
664
- // Record the token we know about before attempting refresh
665
- const tokenBeforeRefresh = this._lastKnownAccessToken;
666
-
667
- // Broadcast that we're starting a refresh (informational for other tabs)
668
- this._broadcast({ type: 'refresh_starting', sessionId, timestamp: Date.now() });
669
-
670
- try {
671
- await retryAsync(
672
- async () => {
673
- // Before each attempt, check if another tab already refreshed
674
- if (this._otherTabRefreshed) {
675
- const adoptedToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
676
- if (adoptedToken && adoptedToken !== tokenBeforeRefresh) {
677
- // Another tab succeeded. Adopt its tokens and short-circuit.
678
- this._lastKnownAccessToken = adoptedToken;
679
- this.oxyServices.httpService.setTokens(adoptedToken);
680
- return;
681
- }
682
- }
683
-
684
- const httpService = this.oxyServices.httpService as HttpService;
685
- // Use session-based token endpoint which handles auto-refresh server-side
686
- const response = await httpService.request<{ accessToken: string; expiresAt: string }>({
687
- method: 'GET',
688
- url: `/session/token/${sessionId}`,
689
- cache: false,
690
- retry: false,
691
- });
692
-
693
- if (!response.accessToken) {
694
- throw new Error('No access token in refresh response');
695
- }
696
-
697
- // Update access token in storage and HTTP client
698
- this._lastKnownAccessToken = response.accessToken;
699
- await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
700
- this.oxyServices.httpService.setTokens(response.accessToken);
701
-
702
- // Update session expiry and schedule next refresh
703
- if (response.expiresAt) {
704
- try {
705
- const session = JSON.parse(sessionJson);
706
- session.expiresAt = response.expiresAt;
707
- await this.storage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));
708
- } catch (err) {
709
- // Ignore parse errors for session update, but log for debugging.
710
- console.error('AuthManager: Failed to re-save session after token refresh.', err);
711
- }
712
-
713
- if (this.config.autoRefresh) {
714
- this.setupTokenRefresh(response.expiresAt);
715
- }
716
- }
717
-
718
- // Broadcast success so other tabs can adopt these tokens
719
- this._broadcast({ type: 'tokens_refreshed', sessionId, timestamp: Date.now() });
720
- },
721
- 2, // 2 retries = 3 total attempts
722
- 1000, // 1s base delay with exponential backoff + jitter
723
- (error: any) => {
724
- // Don't retry on 4xx client errors (invalid/revoked token)
725
- const status = error?.status ?? error?.response?.status;
726
- if (status && status >= 400 && status < 500) return false;
727
- return true;
728
- }
729
- );
730
- return true;
731
- } catch {
732
- // All retry attempts exhausted. Before clearing the session, check if
733
- // another tab managed to refresh successfully while we were retrying.
734
- // Since all tabs share the same storage (localStorage), a successful
735
- // refresh from another tab will have written a different access token.
736
- const currentStoredToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
737
- if (currentStoredToken && currentStoredToken !== tokenBeforeRefresh) {
738
- // Another tab refreshed successfully. Adopt its tokens instead of logging out.
739
- this._lastKnownAccessToken = currentStoredToken;
740
- this.oxyServices.httpService.setTokens(currentStoredToken);
741
-
742
- // Restore user from storage in case it was updated
743
- const userJson = await this.storage.getItem(STORAGE_KEYS.USER);
744
- if (userJson) {
745
- try {
746
- this.currentUser = JSON.parse(userJson);
747
- } catch {
748
- // Ignore parse errors
749
- }
750
- }
751
-
752
- // Re-read session expiry and schedule next refresh
753
- const updatedSessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
754
- if (updatedSessionJson) {
755
- try {
756
- const session = JSON.parse(updatedSessionJson);
757
- if (session.expiresAt && this.config.autoRefresh) {
758
- this.setupTokenRefresh(session.expiresAt);
759
- }
760
- } catch {
761
- // Ignore parse errors
762
- }
763
- }
557
+ if (this.activeAuthuser !== null) {
558
+ await this.switchAuthuser(this.activeAuthuser);
764
559
  return true;
765
560
  }
766
561
 
767
- // No other tab rescued us -- truly clear the session
562
+ const restored = await this.restoreFromCookies();
563
+ return restored.accounts.length > 0;
564
+ } catch {
768
565
  await this.clearSession();
769
566
  this.currentUser = null;
567
+ this.accounts.clear();
568
+ this.activeAuthuser = null;
569
+ this._lastKnownAccessToken = null;
570
+ this.oxyServices.httpService.setTokens('');
770
571
  this.notifyListeners();
771
572
  return false;
772
573
  }
@@ -783,16 +584,10 @@ export class AuthManager {
783
584
  }
784
585
  this.refreshPromise = null;
785
586
 
786
- // Invalidate current session on the server (best-effort)
787
- try {
788
- const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
789
- if (sessionJson) {
790
- const session = JSON.parse(sessionJson);
791
- if (session.sessionId && typeof (this.oxyServices as any).logoutSession === 'function') {
792
- await (this.oxyServices as any).logoutSession(session.sessionId);
793
- }
794
- }
795
- } catch {
587
+ // Invalidate current cookie-backed sessions on the server (best-effort)
588
+ try {
589
+ await this.signOutAllViaCookies();
590
+ } catch {
796
591
  // Best-effort: don't block local signout on network failure
797
592
  }
798
593
 
@@ -813,24 +608,18 @@ export class AuthManager {
813
608
  // Clear storage
814
609
  await this.clearSession();
815
610
 
816
- // Notify other tabs so they also sign out
817
- this._broadcast({ type: 'signed_out', timestamp: Date.now() });
818
-
819
611
  // Update state and notify
820
612
  this.currentUser = null;
821
613
  this.notifyListeners();
822
614
  }
823
615
 
824
616
  /**
825
- * Clear session data from storage.
617
+ * Clear local cookie-path state. The only persisted AuthManager value is the
618
+ * active numeric slot; tokens and user objects are intentionally memory-only.
826
619
  */
827
620
  private async clearSession(): Promise<void> {
828
- await this.storage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
829
- await this.storage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
830
- await this.storage.removeItem(STORAGE_KEYS.SESSION);
831
- await this.storage.removeItem(STORAGE_KEYS.USER);
832
- await this.storage.removeItem(STORAGE_KEYS.AUTH_METHOD);
833
- await this.storage.removeItem(STORAGE_KEYS.FEDCM_LOGIN_HINT);
621
+ await this.clearActiveAuthuser();
622
+ this.currentAuthMethod = null;
834
623
  }
835
624
 
836
625
  /**
@@ -848,17 +637,11 @@ export class AuthManager {
848
637
  }
849
638
 
850
639
  /**
851
- * Get a valid access token, refreshing automatically if expired or expiring soon.
640
+ * Get a valid access token, refreshing automatically if expired or expiring
641
+ * soon. The token is read from memory only.
852
642
  */
853
643
  async getAccessToken(): Promise<string | null> {
854
- // In cookieOnly / cookie-restore flows the active access token lives only in
855
- // memory (`_lastKnownAccessToken` + httpService) and is intentionally never
856
- // written to JS storage — the cookieOnly contract forbids persisting tokens
857
- // in JS-accessible storage. Fall back to the in-memory token when storage has
858
- // none, otherwise getAccessToken returns null after every cold-boot/reload and
859
- // standalone API clients (e.g. the Console axios client) send no Authorization
860
- // header → 401 on every authed endpoint while `isAuthenticated` is still true.
861
- const token = (await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN)) ?? this._lastKnownAccessToken;
644
+ const token = this._lastKnownAccessToken;
862
645
  if (!token) return null;
863
646
 
864
647
  try {
@@ -869,9 +652,7 @@ export class AuthManager {
869
652
  if (decoded.exp - now < buffer) {
870
653
  const refreshed = await this.refreshToken();
871
654
  if (refreshed) {
872
- // refreshToken() updates both storage and `_lastKnownAccessToken`;
873
- // prefer storage but fall back to memory for the cookieOnly path.
874
- return (await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN)) ?? this._lastKnownAccessToken;
655
+ return this._lastKnownAccessToken;
875
656
  }
876
657
  }
877
658
  }
@@ -886,100 +667,41 @@ export class AuthManager {
886
667
  * Get the auth method used for current session.
887
668
  */
888
669
  async getAuthMethod(): Promise<AuthMethod | null> {
889
- const method = await this.storage.getItem(STORAGE_KEYS.AUTH_METHOD);
890
- return method as AuthMethod | null;
670
+ return this.currentAuthMethod;
891
671
  }
892
672
 
893
673
  /**
894
674
  * Initialize auth state on app startup.
895
675
  *
896
- * Order of operations:
897
- * 1. Try the cookie path via `restoreFromCookies()`. This is the
898
- * preferred path because the httpOnly refresh cookies are
899
- * cross-tab, persist across hard reloads, and don't expose any
900
- * refresh-token material to JS.
901
- * 2. If the cookie path yielded zero accounts AND `cookieOnly` is
902
- * `false`, fall back to the legacy localStorage path
903
- * (`oxy_access_token` / `oxy_session`) for backwards compatibility
904
- * with apps that haven't migrated to the cookie endpoint yet.
905
- * 3. If `cookieOnly` is `true`, skip the legacy fallback entirely.
906
- * This guarantees no tokens or refresh tokens are ever read from
907
- * or written to JS-accessible storage.
676
+ * Only the cookie path is authoritative. `restoreFromCookies()` refreshes
677
+ * the httpOnly `oxy_rt_${authuser}` slots through `/auth/refresh-all`,
678
+ * plants the active access token in memory, and returns the active user.
679
+ * No access token, refresh token, or session JSON is read from localStorage.
908
680
  *
909
- * Returns the active user on success, or `null` when neither path
910
- * restored a session.
681
+ * Returns the active user on success, or `null` when no cookie-backed
682
+ * account was restored.
911
683
  */
912
684
  async initialize(options: RestoreFromCookiesOptions = {}): Promise<MinimalUserData | null> {
913
- // 1. Cookie path (preferred). Forward the optional cold-boot fail-fast
914
- // timeout so a cross-domain stall cannot hang provider init.
915
685
  const cookieResult = await this.restoreFromCookies(options);
916
686
  if (cookieResult.accounts.length > 0) {
917
687
  return this.currentUser;
918
688
  }
919
689
 
920
- // 2. Legacy localStorage path (opt-out via `cookieOnly`).
921
- if (this.config.cookieOnly) {
922
- return null;
923
- }
924
-
925
- try {
926
- // Try to restore user from storage
927
- const userJson = await this.storage.getItem(STORAGE_KEYS.USER);
928
- if (userJson) {
929
- this.currentUser = JSON.parse(userJson);
930
- }
931
-
932
- // Restore token to HTTP client
933
- const token = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
934
- if (token) {
935
- this._lastKnownAccessToken = token;
936
- this.oxyServices.httpService.setTokens(token);
937
- }
938
-
939
- // Check session expiry
940
- const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
941
- if (sessionJson) {
942
- const session = JSON.parse(sessionJson);
943
- if (session.expiresAt) {
944
- const expiresAt = new Date(session.expiresAt).getTime();
945
- if (expiresAt <= Date.now()) {
946
- // Session expired, try refresh
947
- const refreshed = await this.refreshToken();
948
- if (!refreshed) {
949
- await this.clearSession();
950
- this.currentUser = null;
951
- }
952
- } else if (this.config.autoRefresh) {
953
- // Setup refresh timer
954
- this.setupTokenRefresh(session.expiresAt);
955
- }
956
- }
957
- }
958
-
959
- return this.currentUser;
960
- } catch {
961
- // Failed to restore, start fresh
962
- await this.clearSession();
963
- this.currentUser = null;
964
- return null;
965
- }
690
+ return null;
966
691
  }
967
692
 
968
693
  // -------------------------------------------------------------------------
969
694
  // Multi-account cookie path (Google-style multi-sign-in).
970
695
  // -------------------------------------------------------------------------
971
- // The cookie path is web-only and orthogonal to the legacy bearer path
972
- // above: it never touches the `oxy_access_token` / `oxy_refresh_token` /
696
+ // The cookie path is web-only. It never touches the retired
697
+ // `oxy_access_token` / `oxy_refresh_token` /
973
698
  // `oxy_session` localStorage keys, because the refresh token lives in the
974
699
  // httpOnly `oxy_rt_${authuser}` cookies and access tokens live in
975
700
  // `this.accounts` (in-memory only). The only localStorage key the cookie
976
701
  // path writes is `STORAGE_KEYS.ACTIVE_AUTHUSER` — a small integer that is
977
702
  // explicitly NOT a secret.
978
703
  //
979
- // Apps that want to opt out of the legacy localStorage path entirely
980
- // (recommended for new web apps) pass `cookieOnly: true` to the
981
- // AuthManager config; in that mode `initialize()` ONLY uses the cookie
982
- // path.
704
+ // `initialize()` only uses the cookie path.
983
705
  // -------------------------------------------------------------------------
984
706
 
985
707
  /**
@@ -1028,9 +750,8 @@ export class AuthManager {
1028
750
 
1029
751
  /**
1030
752
  * Build a `MinimalUserData` from a `RefreshAllAccount`. Returns `null` when
1031
- * the wire entry has no user shape (legacy `/auth/refresh` fallback) the
1032
- * AuthManager's caller is expected to hydrate via `/users/me` in that
1033
- * case.
753
+ * the wire entry has no user shape; the AuthManager's caller is expected to
754
+ * hydrate via `/users/me` in that case.
1034
755
  */
1035
756
  private static toMinimalUser(account: RefreshAllAccount): MinimalUserData | null {
1036
757
  if (!account.user) return null;
@@ -1043,8 +764,8 @@ export class AuthManager {
1043
764
 
1044
765
  /**
1045
766
  * Hydrate the user shape for a slot whose AuthManagerAccount currently has
1046
- * `user: null` (legacy refresh fallback, or a switch onto a previously
1047
- * unknown slot). Calls `/users/me` with the slot's freshly-planted access
767
+ * `user: null` (for example, a switch onto a previously unknown slot). Calls
768
+ * `/users/me` with the slot's freshly-planted access
1048
769
  * token already on the HTTP client; merges the result back into the
1049
770
  * registry entry. Network failures are non-fatal — the slot remains with
1050
771
  * `user: null` and the UI is expected to render the public-key fallback
@@ -1122,9 +843,7 @@ export class AuthManager {
1122
843
  * Calls `oxyServices.refreshAllSessions()` (`POST /auth/refresh-all` with
1123
844
  * `credentials: 'include'`). The server rotates every presented
1124
845
  * `oxy_rt_${authuser}` cookie in parallel and returns one entry per
1125
- * VALID slot. The SDK transparently falls back to the legacy single-slot
1126
- * `/auth/refresh` against older servers (handled inside
1127
- * `refreshAllSessions`).
846
+ * valid slot.
1128
847
  *
1129
848
  * Plants the active account's access token on the shared HTTP client;
1130
849
  * sibling slots' tokens stay in the in-memory registry so a later
@@ -1211,10 +930,10 @@ export class AuthManager {
1211
930
  this.setupCookieRefresh(activeAccount.expiresAt, active);
1212
931
  }
1213
932
 
1214
- // The legacy /auth/refresh fallback yields user=null for the active
1215
- // slot. Schedule a /users/me hydration so the chooser isn't stuck on
1216
- // the public-key handle. Hydration is fire-and-forget — the snapshot
1217
- // is already considered "restored" once the access token is planted.
933
+ // If the active slot has no user shape, schedule a /users/me hydration so
934
+ // the chooser isn't stuck on the public-key handle. Hydration is
935
+ // fire-and-forget — the snapshot is already considered "restored" once
936
+ // the access token is planted.
1218
937
  if (activeAccount.user === null) {
1219
938
  slotsNeedingHydration.push(activeAccount.authuser);
1220
939
  }
@@ -1376,6 +1095,7 @@ export class AuthManager {
1376
1095
  this._lastKnownAccessToken = null;
1377
1096
  this.oxyServices.httpService.setTokens('');
1378
1097
  this.currentUser = null;
1098
+ this.currentAuthMethod = null;
1379
1099
  await this.clearActiveAuthuser();
1380
1100
  }
1381
1101
  }
@@ -1389,10 +1109,9 @@ export class AuthManager {
1389
1109
  *
1390
1110
  * Calls `oxyServices.logoutAllSessionsViaCookie()`: server-side revokes
1391
1111
  * every presented family and `Set-Cookie`s an immediate expiry for every
1392
- * recognised `oxy_rt_${n}` slot AND the legacy `oxy_rt` cookie. The
1393
- * in-memory registry is wiped, the active slot is cleared, and the
1394
- * persisted `oxy_active_authuser` is removed so the next cold boot
1395
- * starts fresh.
1112
+ * recognised `oxy_rt_${n}` slot. The in-memory registry is wiped, the active
1113
+ * slot is cleared, and the persisted `oxy_active_authuser` is removed so the
1114
+ * next cold boot starts fresh.
1396
1115
  */
1397
1116
  async signOutAllViaCookies(): Promise<void> {
1398
1117
  try {
@@ -1406,6 +1125,7 @@ export class AuthManager {
1406
1125
  this._lastKnownAccessToken = null;
1407
1126
  this.oxyServices.httpService.setTokens('');
1408
1127
  this.currentUser = null;
1128
+ this.currentAuthMethod = null;
1409
1129
  this._lastRestoreAt.clear();
1410
1130
  await this.clearActiveAuthuser();
1411
1131
 
@@ -1420,9 +1140,8 @@ export class AuthManager {
1420
1140
  }
1421
1141
 
1422
1142
  /**
1423
- * Schedule an auto-refresh for the cookie path on the active slot. Reuses
1424
- * the same single `refreshTimer` as the legacy path (the AuthManager has
1425
- * exactly ONE active slot at a time, so one timer suffices).
1143
+ * Schedule an auto-refresh for the cookie path on the active slot. The
1144
+ * AuthManager has exactly one active slot at a time, so one timer suffices.
1426
1145
  */
1427
1146
  private setupCookieRefresh(expiresAt: string, authuser: number): void {
1428
1147
  if (this.refreshTimer) {
@@ -1463,6 +1182,17 @@ export class AuthManager {
1463
1182
  }
1464
1183
  }
1465
1184
 
1185
+ private static decodeAuthuserFromAccessToken(token: string): number | null {
1186
+ try {
1187
+ const decoded = jwtDecode<{ authuser?: number }>(token);
1188
+ return typeof decoded.authuser === 'number' && Number.isFinite(decoded.authuser) && decoded.authuser >= 0
1189
+ ? decoded.authuser
1190
+ : null;
1191
+ } catch {
1192
+ return null;
1193
+ }
1194
+ }
1195
+
1466
1196
  /**
1467
1197
  * Destroy the auth manager and clean up resources.
1468
1198
  */