@oxyhq/core 3.4.1 → 3.4.3

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 +24 -2
  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
@@ -30,13 +30,10 @@ export interface AuthManagerAccount {
30
30
  * Projected user shape from the wire (username/avatar/color/email).
31
31
  *
32
32
  * `null` when a refresh-via-cookie planted a fresh access token for a slot
33
- * that the AuthManager has no prior in-memory user metadata for e.g. the
34
- * legacy `/auth/refresh` 404 fallback path inside `refreshAllSessions`, or
35
- * a `switchAuthuser` against a slot that wasn't present in the previous
36
- * `restoreFromCookies` snapshot. Callers (or the AuthManager itself) are
37
- * expected to hydrate the user shape via `getCurrentUser()` after the token
38
- * is planted; the chooser UI must render the public-key fallback handle
39
- * until the hydration completes.
33
+ * that the AuthManager has no prior in-memory user metadata for. Callers (or
34
+ * the AuthManager itself) are expected to hydrate the user shape via
35
+ * `getCurrentUser()` after the token is planted; the chooser UI must render
36
+ * the public-key fallback handle until the hydration completes.
40
37
  */
41
38
  user: RefreshAllAccountUser | null;
42
39
  /** Currently-valid access token for this slot (in-memory only). */
@@ -80,8 +77,7 @@ export interface RestoreFromCookiesOptions {
80
77
  /**
81
78
  * Outcome of `AuthManager.switchAuthuser()`.
82
79
  *
83
- * Mirrors the wire `RefreshCookieResponse` but with `authuser` narrowed to
84
- * `number` (the SDK boundary normalises the legacy `null` slot to `0`).
80
+ * Mirrors the wire `RefreshCookieResponse`.
85
81
  */
86
82
  export interface SwitchAuthuserResult {
87
83
  accessToken: string;
@@ -5,8 +5,7 @@
5
5
  * selects the best authentication method based on browser capabilities:
6
6
  *
7
7
  * 1. FedCM (if supported) - Modern, Google-style browser-native auth
8
- * 2. Popup (fallback) - OAuth2-style popup window
9
- * 3. Redirect (final fallback) - Traditional full-page redirect
8
+ * 2. Redirect (fallback) - Tokenless central SSO full-page redirect
10
9
  *
11
10
  * Usage:
12
11
  * ```typescript
@@ -17,8 +16,8 @@
17
16
  * // Automatic method selection
18
17
  * const session = await auth.signIn();
19
18
  *
20
- * // Or use specific method
21
- * const session = await auth.signInWithPopup();
19
+ * // Or use a specific method
20
+ * auth.signInWithRedirect();
22
21
  * ```
23
22
  */
24
23
 
@@ -31,10 +30,9 @@ export interface CrossDomainAuthOptions {
31
30
  * Preferred authentication method
32
31
  * - 'auto': Automatically select best method (default)
33
32
  * - 'fedcm': Use FedCM (browser-native)
34
- * - 'popup': Use popup window
35
33
  * - 'redirect': Use full-page redirect
36
34
  */
37
- method?: 'auto' | 'fedcm' | 'popup' | 'redirect';
35
+ method?: 'auto' | 'fedcm' | 'redirect';
38
36
 
39
37
  /**
40
38
  * Custom redirect URI (for redirect method)
@@ -46,26 +44,10 @@ export interface CrossDomainAuthOptions {
46
44
  */
47
45
  isSignup?: boolean;
48
46
 
49
- /**
50
- * Popup window dimensions (for popup method)
51
- */
52
- popupDimensions?: {
53
- width?: number;
54
- height?: number;
55
- };
56
-
57
47
  /**
58
48
  * Callback when auth method is selected
59
49
  */
60
- onMethodSelected?: (method: 'fedcm' | 'popup' | 'redirect') => void;
61
-
62
- /**
63
- * A popup window the caller already opened SYNCHRONOUSLY in the user-gesture
64
- * handler. Forwarded to `OxyServices.signInWithPopup` so the popup is not
65
- * blocked by Chrome after any prior `await` (FedCM / silent SSO) has
66
- * consumed the transient user activation. See `OxyServices.openBlankPopup`.
67
- */
68
- popup?: Window | null;
50
+ onMethodSelected?: (method: 'fedcm' | 'redirect') => void;
69
51
  }
70
52
 
71
53
  export class CrossDomainAuth {
@@ -76,8 +58,7 @@ export class CrossDomainAuth {
76
58
  *
77
59
  * Tries methods in this order:
78
60
  * 1. FedCM (if supported and not in private browsing)
79
- * 2. Popup (if not blocked)
80
- * 3. Redirect (always works)
61
+ * 2. Redirect (always works)
81
62
  *
82
63
  * @param options - Authentication options
83
64
  * @returns Session with user data and access token
@@ -85,49 +66,19 @@ export class CrossDomainAuth {
85
66
  async signIn(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse | null> {
86
67
  const method = options.method || 'auto';
87
68
 
88
- // If specific method requested, use it directly. The caller MAY have
89
- // pre-opened a popup on the raw click (the standard pattern in
90
- // WebOxyProvider / services useAuth). For the FedCM and redirect paths
91
- // that popup is unused — close it so it doesn't linger as an orphaned
92
- // blank window. Close in both success and failure paths.
93
69
  if (method === 'fedcm') {
94
- try {
95
- const session = await this.signInWithFedCM(options);
96
- this.closeOrphanPopup(options.popup);
97
- return session;
98
- } catch (error) {
99
- this.closeOrphanPopup(options.popup);
100
- throw error;
101
- }
102
- }
103
-
104
- if (method === 'popup') {
105
- return this.signInWithPopup(options);
70
+ return this.signInWithFedCM(options);
106
71
  }
107
72
 
108
73
  if (method === 'redirect') {
109
- this.closeOrphanPopup(options.popup);
110
74
  this.signInWithRedirect(options);
111
75
  return null; // Redirect doesn't return immediately
112
76
  }
113
77
 
114
- // Auto mode: Try methods in order of preference
78
+ // Auto mode: try methods in order of preference.
115
79
  return this.autoSignIn(options);
116
80
  }
117
81
 
118
- /**
119
- * Close a caller-supplied popup window that is no longer needed (e.g. the
120
- * resolved auth method didn't end up using it). Safe against null / already
121
- * closed handles.
122
- *
123
- * @private
124
- */
125
- private closeOrphanPopup(popup: Window | null | undefined): void {
126
- if (popup && !popup.closed) {
127
- popup.close();
128
- }
129
- }
130
-
131
82
  /**
132
83
  * Automatic sign-in with progressive enhancement
133
84
  *
@@ -138,27 +89,13 @@ export class CrossDomainAuth {
138
89
  if (this.isFedCMSupported()) {
139
90
  try {
140
91
  options.onMethodSelected?.('fedcm');
141
- const session = await this.signInWithFedCM(options);
142
- // FedCM succeeded — close the pre-opened popup so it doesn't linger
143
- // as an orphaned blank window.
144
- this.closeOrphanPopup(options.popup);
145
- return session;
92
+ return await this.signInWithFedCM(options);
146
93
  } catch (error) {
147
- logger.warn('FedCM failed, trying popup', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
94
+ logger.warn('FedCM failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
148
95
  }
149
96
  }
150
97
 
151
- // 2. Try popup (good UX, widely supported)
152
- try {
153
- options.onMethodSelected?.('popup');
154
- return await this.signInWithPopup(options);
155
- } catch (error) {
156
- logger.warn('Popup failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
157
- // Popup path failed — close the pre-opened popup before redirecting.
158
- this.closeOrphanPopup(options.popup);
159
- }
160
-
161
- // 3. Fallback to redirect (always works)
98
+ // 2. Fallback to redirect (always works)
162
99
  options.onMethodSelected?.('redirect');
163
100
  this.signInWithRedirect(options);
164
101
  return null;
@@ -167,7 +104,7 @@ export class CrossDomainAuth {
167
104
  /**
168
105
  * Sign in using FedCM (Federated Credential Management)
169
106
  *
170
- * Best method - browser-native, no popups, Google-like experience
107
+ * Best method - browser-native, Google-like experience
171
108
  */
172
109
  async signInWithFedCM(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse> {
173
110
  return this.oxyServices.signInWithFedCM({
@@ -175,20 +112,6 @@ export class CrossDomainAuth {
175
112
  });
176
113
  }
177
114
 
178
- /**
179
- * Sign in using popup window
180
- *
181
- * Good method - preserves app state, no full page reload
182
- */
183
- async signInWithPopup(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse> {
184
- return this.oxyServices.signInWithPopup({
185
- mode: options.isSignup ? 'signup' : 'login',
186
- width: options.popupDimensions?.width,
187
- height: options.popupDimensions?.height,
188
- popup: options.popup ?? undefined,
189
- });
190
- }
191
-
192
115
  /**
193
116
  * Sign in using full-page redirect
194
117
  *
@@ -214,7 +137,7 @@ export class CrossDomainAuth {
214
137
  * Silent sign-in (check for existing session)
215
138
  *
216
139
  * Tries to automatically sign in without user interaction.
217
- * Works with both FedCM and popup/iframe methods.
140
+ * Works with FedCM and iframe-based silent auth.
218
141
  *
219
142
  * @returns Session if user is already signed in, null otherwise
220
143
  */
@@ -241,23 +164,13 @@ export class CrossDomainAuth {
241
164
  }
242
165
 
243
166
  /**
244
- * Restore session from storage
167
+ * Restore session from storage.
245
168
  *
246
- * For redirect method - restores previously authenticated session from localStorage
169
+ * Access tokens are no longer persisted in browser storage; providers restore
170
+ * through refresh cookies / SSO code exchange instead.
247
171
  */
248
172
  restoreSession(): boolean {
249
- return this.oxyServices.restoreSession?.() || false;
250
- }
251
-
252
- /**
253
- * Open a blank popup SYNCHRONOUSLY (call from a raw user-gesture handler
254
- * BEFORE any `await`). Returns `null` if the popup was blocked. Pass the
255
- * handle into `signIn({ popup })` / `signInWithPopup({ popup })` so the
256
- * popup is not blocked by Chrome after any prior `await` consumed the
257
- * transient user activation. Delegates to `OxyServices.openBlankPopup`.
258
- */
259
- openBlankPopup(width?: number, height?: number): Window | null {
260
- return this.oxyServices.openBlankPopup(width, height);
173
+ return false;
261
174
  }
262
175
 
263
176
  /**
@@ -274,7 +187,7 @@ export class CrossDomainAuth {
274
187
  *
275
188
  * @returns Recommended method name and reason
276
189
  */
277
- getRecommendedMethod(): { method: 'fedcm' | 'popup' | 'redirect'; reason: string } {
190
+ getRecommendedMethod(): { method: 'fedcm' | 'redirect'; reason: string } {
278
191
  if (this.isFedCMSupported()) {
279
192
  return {
280
193
  method: 'fedcm',
@@ -284,8 +197,8 @@ export class CrossDomainAuth {
284
197
 
285
198
  if (typeof window !== 'undefined') {
286
199
  return {
287
- method: 'popup',
288
- reason: 'Browser environment - popup preserves app state',
200
+ method: 'redirect',
201
+ reason: 'Browser environment - redirect SSO works without token callback URLs',
289
202
  };
290
203
  }
291
204
 
@@ -300,8 +213,7 @@ export class CrossDomainAuth {
300
213
  *
301
214
  * This handles:
302
215
  * 1. Redirect callback (if returning from auth.oxy.so)
303
- * 2. Session restoration (from localStorage)
304
- * 3. Silent sign-in (check for existing SSO session)
216
+ * 2. Silent sign-in (check for existing SSO session)
305
217
  *
306
218
  * @returns Session if user is authenticated, null otherwise
307
219
  */
@@ -312,26 +224,7 @@ export class CrossDomainAuth {
312
224
  return callbackSession;
313
225
  }
314
226
 
315
- // 2. Try to restore existing session from storage
316
- const restored = this.restoreSession();
317
- if (restored) {
318
- // Verify session is still valid by fetching user
319
- try {
320
- const user = await this.oxyServices.getCurrentUser();
321
- if (user) {
322
- return {
323
- sessionId: this.oxyServices.getStoredSessionId?.() || '',
324
- deviceId: '',
325
- expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
326
- user,
327
- };
328
- }
329
- } catch (error) {
330
- logger.debug('stored session invalid', { component: 'CrossDomainAuth', method: 'initialize' }, error);
331
- }
332
- }
333
-
334
- // 3. Try silent sign-in (check for SSO session at auth.oxy.so)
227
+ // 2. Try silent sign-in (check for SSO session at auth.oxy.so)
335
228
  return await this.silentSignIn();
336
229
  }
337
230
  }
@@ -35,6 +35,9 @@ interface JwtPayload {
35
35
  [key: string]: any;
36
36
  }
37
37
 
38
+ export type AuthRefreshReason = 'preflight' | 'response-401';
39
+ export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
40
+
38
41
  /**
39
42
  * Structural type that captures the multipart-write surface every supported
40
43
  * FormData implementation exposes (browser, React Native, Node `form-data`
@@ -110,26 +113,19 @@ interface RequestConfig extends RequestOptions {
110
113
  */
111
114
  class TokenStore {
112
115
  private accessToken: string | null = null;
113
- private refreshToken: string | null = null;
114
116
  private csrfToken: string | null = null;
115
117
  private csrfTokenFetchPromise: Promise<string | null> | null = null;
116
118
 
117
- setTokens(accessToken: string, refreshToken = ''): void {
119
+ setTokens(accessToken: string): void {
118
120
  this.accessToken = accessToken;
119
- this.refreshToken = refreshToken;
120
121
  }
121
122
 
122
123
  getAccessToken(): string | null {
123
124
  return this.accessToken;
124
125
  }
125
126
 
126
- getRefreshToken(): string | null {
127
- return this.refreshToken;
128
- }
129
-
130
127
  clearTokens(): void {
131
128
  this.accessToken = null;
132
- this.refreshToken = null;
133
129
  }
134
130
 
135
131
  hasAccessToken(): boolean {
@@ -174,14 +170,13 @@ export class HttpService {
174
170
  private config: OxyConfig;
175
171
  private tokenRefreshPromise: Promise<string | null> | null = null;
176
172
  private tokenRefreshCooldownUntil: number = 0;
177
- private _onTokenRefreshed: ((accessToken: string) => void) | null = null;
173
+ private authRefreshHandler: AuthRefreshHandler | null = null;
178
174
 
179
175
  /**
180
176
  * Fan-out listeners notified on EVERY access-token change on this instance:
181
- * explicit `setTokens`, `clearTokens`, a successful silent refresh, and the
182
- * internal 401-driven clear. Unlike the single-slot `_onTokenRefreshed`
183
- * (owned by AuthManager for the refresh path only), this is a Set so multiple
184
- * independent observers can mirror token state without clobbering each other.
177
+ * explicit `setTokens`, `clearTokens`, an AuthManager-owned refresh, and the
178
+ * internal 401-driven clear. This is a Set so multiple independent observers
179
+ * can mirror token state without clobbering each other.
185
180
  *
186
181
  * Each listener receives the resulting access token, or `null` when cleared.
187
182
  */
@@ -421,22 +416,13 @@ export class HttpService {
421
416
 
422
417
  // Handle response
423
418
  if (!response.ok) {
424
- // On 401, attempt token refresh and retry once before giving up
419
+ // On 401, delegate refresh to AuthManager and retry once before
420
+ // giving up. HttpService deliberately does not know any session
421
+ // routes; the AuthManager is the single session authority.
425
422
  if (response.status === 401 && !config._isAuthRetry) {
426
- const currentToken = this.tokenStore.getAccessToken();
427
- if (currentToken) {
428
- try {
429
- const decoded = jwtDecode<JwtPayload>(currentToken);
430
- if (decoded.sessionId) {
431
- const refreshResult = await this._refreshTokenFromSession(decoded.sessionId);
432
- if (refreshResult) {
433
- // Retry the request with the new token
434
- return this.request<T>({ ...config, _isAuthRetry: true, retry: false });
435
- }
436
- }
437
- } catch {
438
- // Token decode failed, fall through to clear
439
- }
423
+ const refreshed = await this.refreshAccessToken('response-401');
424
+ if (refreshed) {
425
+ return this.request<T>({ ...config, _isAuthRetry: true, retry: false });
440
426
  }
441
427
  // Refresh failed or no token — clear tokens and stale CSRF
442
428
  this.tokenStore.clearTokens();
@@ -464,7 +450,7 @@ export class HttpService {
464
450
  if (contentType && contentType.includes('application/json')) {
465
451
  try {
466
452
  const errorData = await response.json() as { message?: string; error?: string } | null;
467
- // Check both 'message' and 'error' fields for backwards compatibility
453
+ // Accept either structured error field from API responses.
468
454
  if (errorData?.message) {
469
455
  errorMessage = errorData.message;
470
456
  } else if (errorData?.error) {
@@ -878,24 +864,9 @@ export class HttpService {
878
864
  const currentTime = Math.floor(Date.now() / 1000);
879
865
 
880
866
  // If token expires in less than 60 seconds, refresh it
881
- if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
882
- // Skip if we recently failed a refresh (15s cooldown to prevent storms)
883
- if (Date.now() < this.tokenRefreshCooldownUntil) {
884
- return `Bearer ${accessToken}`;
885
- }
886
- // Deduplicate concurrent refresh attempts. The promise is shared
887
- // across all concurrent callers and cleared only after it settles,
888
- // so every awaiter receives the same result.
889
- if (!this.tokenRefreshPromise) {
890
- this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId)
891
- .then((result) => {
892
- if (!result) this.tokenRefreshCooldownUntil = Date.now() + 15000;
893
- return result;
894
- })
895
- .finally(() => { this.tokenRefreshPromise = null; });
896
- }
897
- const result = await this.tokenRefreshPromise;
898
- if (result) return result;
867
+ if (decoded.exp && decoded.exp - currentTime < 60) {
868
+ const refreshed = await this.refreshAccessToken('preflight');
869
+ if (refreshed) return `Bearer ${refreshed}`;
899
870
  // Refresh failed — don't use the expired token (would cause 401 loop)
900
871
  return null;
901
872
  }
@@ -907,28 +878,40 @@ export class HttpService {
907
878
  }
908
879
  }
909
880
 
910
- private async _refreshTokenFromSession(sessionId: string): Promise<string | null> {
911
- try {
912
- const refreshUrl = `${this.baseURL}/session/token/${sessionId}`;
913
- const response = await fetch(refreshUrl, {
914
- method: 'GET',
915
- headers: { 'Accept': 'application/json' },
916
- signal: AbortSignal.timeout(5000),
917
- credentials: 'include',
918
- });
919
-
920
- if (response.ok) {
921
- const { accessToken: newToken } = await response.json();
922
- this.tokenStore.setTokens(newToken);
923
- this._onTokenRefreshed?.(newToken);
924
- this.notifyTokenChange();
925
- this.logger.debug('Token refreshed');
926
- return `Bearer ${newToken}`;
927
- }
928
- } catch (refreshError) {
929
- this.logger.warn('Token refresh failed, using current token');
881
+ private async refreshAccessToken(reason: AuthRefreshReason): Promise<string | null> {
882
+ if (!this.authRefreshHandler) {
883
+ return null;
930
884
  }
931
- return null;
885
+
886
+ if (Date.now() < this.tokenRefreshCooldownUntil) {
887
+ return null;
888
+ }
889
+
890
+ if (!this.tokenRefreshPromise) {
891
+ this.tokenRefreshPromise = this.authRefreshHandler(reason)
892
+ .then((newToken) => {
893
+ if (!newToken) {
894
+ this.tokenRefreshCooldownUntil = Date.now() + 15000;
895
+ return null;
896
+ }
897
+ if (this.tokenStore.getAccessToken() !== newToken) {
898
+ this.tokenStore.setTokens(newToken);
899
+ this.notifyTokenChange();
900
+ }
901
+ this.logger.debug('Token refreshed via AuthManager');
902
+ return newToken;
903
+ })
904
+ .catch((error) => {
905
+ this.logger.warn('Token refresh failed:', error);
906
+ this.tokenRefreshCooldownUntil = Date.now() + 15000;
907
+ return null;
908
+ })
909
+ .finally(() => {
910
+ this.tokenRefreshPromise = null;
911
+ });
912
+ }
913
+
914
+ return this.tokenRefreshPromise;
932
915
  }
933
916
 
934
917
  /**
@@ -996,13 +979,13 @@ export class HttpService {
996
979
  }
997
980
 
998
981
  // Token management
999
- setTokens(accessToken: string, refreshToken = ''): void {
1000
- this.tokenStore.setTokens(accessToken, refreshToken);
982
+ setTokens(accessToken: string): void {
983
+ this.tokenStore.setTokens(accessToken);
1001
984
  this.notifyTokenChange();
1002
985
  }
1003
986
 
1004
- set onTokenRefreshed(callback: ((accessToken: string) => void) | null) {
1005
- this._onTokenRefreshed = callback;
987
+ setAuthRefreshHandler(handler: AuthRefreshHandler | null): void {
988
+ this.authRefreshHandler = handler;
1006
989
  }
1007
990
 
1008
991
  clearTokens(): void {
@@ -1136,4 +1119,3 @@ export class HttpService {
1136
1119
  this.tokenStore.clearCsrfToken();
1137
1120
  }
1138
1121
  }
1139
-
@@ -164,8 +164,8 @@ export class OxyServicesBase {
164
164
  /**
165
165
  * Set authentication tokens
166
166
  */
167
- public setTokens(accessToken: string, refreshToken = ''): void {
168
- this.httpService.setTokens(accessToken, refreshToken);
167
+ public setTokens(accessToken: string): void {
168
+ this.httpService.setTokens(accessToken);
169
169
  }
170
170
 
171
171
  /**
@@ -430,4 +430,3 @@ export class OxyServicesBase {
430
430
  }
431
431
  }
432
432
  }
433
-
@@ -60,7 +60,7 @@ import { OxyServicesBase, type OxyConfig } from './OxyServices.base';
60
60
  import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
61
61
  import type { SessionLoginResponse } from './models/session';
62
62
  import type { FedCMAuthOptions, FedCMConfig } from './mixins/OxyServices.fedcm';
63
- import type { PopupAuthOptions } from './mixins/OxyServices.popup';
63
+ import type { SilentAuthOptions } from './mixins/OxyServices.silent';
64
64
  import type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
65
65
 
66
66
  // Import mixin composition helper
@@ -137,16 +137,14 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
137
137
  revokeFedCMCredential(): Promise<void>;
138
138
  getFedCMConfig(): FedCMConfig;
139
139
 
140
- // Popup authentication
141
- signInWithPopup(options?: PopupAuthOptions): Promise<SessionLoginResponse>;
142
- signUpWithPopup(options?: PopupAuthOptions): Promise<SessionLoginResponse>;
143
- /**
144
- * Open a blank popup SYNCHRONOUSLY (call from a raw user-gesture handler
145
- * BEFORE any `await`). Returns `null` if the popup was blocked. Pass the
146
- * handle into `signInWithPopup({ popup })` to navigate it to auth.oxy.so
147
- * after the async portion of the sign-in flow runs.
148
- */
149
- openBlankPopup(width?: number, height?: number): Window | null;
140
+ // Silent iframe SSO
141
+ resolveAuthUrl(): string;
142
+ silentSignIn(options?: SilentAuthOptions): Promise<SessionLoginResponse | null>;
143
+ waitForIframeAuth(
144
+ iframe: HTMLIFrameElement,
145
+ timeout: number,
146
+ expectedOrigin: string,
147
+ ): Promise<SessionLoginResponse | null>;
150
148
 
151
149
  // Redirect authentication
152
150
  signInWithRedirect(options?: RedirectAuthOptions): void;
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * AuthManager multi-account cookie-path regression tests.
3
3
  *
4
- * Locks in the four new methods that route through the httpOnly
5
- * `oxy_rt_${authuser}` refresh cookies instead of the legacy bearer
6
- * `/session/token/:id` endpoint:
4
+ * Locks in the four methods that route through the httpOnly
5
+ * `oxy_rt_${authuser}` refresh cookies:
7
6
  *
8
7
  * - `restoreFromCookies()` — cold-boot restore of every device-local slot
9
8
  * via `POST /auth/refresh-all`. Picks active slot by persisted
@@ -44,12 +43,17 @@ class InMemoryStorage implements StorageAdapter {
44
43
  raw(): Map<string, string> { return this.store; }
45
44
  }
46
45
 
46
+ interface MockHttpService {
47
+ setTokens: jest.Mock;
48
+ setAuthRefreshHandler: jest.Mock;
49
+ }
50
+
47
51
  interface MockServices {
48
52
  refreshAllSessions: jest.Mock<Promise<RefreshAllResponse>, []>;
49
53
  refreshTokenViaCookie: jest.Mock;
50
54
  logoutSessionByAuthuser: jest.Mock<Promise<void>, [number]>;
51
55
  logoutAllSessionsViaCookie: jest.Mock<Promise<void>, []>;
52
- httpService: { setTokens: jest.Mock; onTokenRefreshed: ((t: string) => void) | undefined };
56
+ httpService: MockHttpService;
53
57
  }
54
58
 
55
59
  function makeMockServices(): MockServices {
@@ -58,7 +62,7 @@ function makeMockServices(): MockServices {
58
62
  refreshTokenViaCookie: jest.fn(),
59
63
  logoutSessionByAuthuser: jest.fn(async () => undefined),
60
64
  logoutAllSessionsViaCookie: jest.fn(async () => undefined),
61
- httpService: { setTokens: jest.fn(), onTokenRefreshed: undefined },
65
+ httpService: { setTokens: jest.fn(), setAuthRefreshHandler: jest.fn() },
62
66
  };
63
67
  }
64
68
 
@@ -68,7 +72,6 @@ function makeManager(services: MockServices, storage: InMemoryStorage): AuthMana
68
72
  storage,
69
73
  autoRefresh: false,
70
74
  crossTabSync: false,
71
- cookieOnly: true,
72
75
  });
73
76
  }
74
77
 
@@ -305,7 +308,7 @@ describe('AuthManager.signOutAllViaCookies', () => {
305
308
  });
306
309
  });
307
310
 
308
- describe('AuthManager.initialize (cookieOnly)', () => {
311
+ describe('AuthManager.initialize', () => {
309
312
  it('returns the active user from restoreFromCookies and never touches localStorage tokens', async () => {
310
313
  const services = makeMockServices();
311
314
  services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
@@ -321,24 +324,23 @@ describe('AuthManager.initialize (cookieOnly)', () => {
321
324
  expect(storage.has('oxy_user')).toBe(false);
322
325
  });
323
326
 
324
- it('returns null when no cookies AND cookieOnly mode (no legacy fallback)', async () => {
327
+ it('returns null when no cookies are restored', async () => {
325
328
  const services = makeMockServices();
326
329
  // Default `{ accounts: [] }`.
327
330
  const storage = new InMemoryStorage();
328
- // Even if legacy token were present in storage, cookieOnly must skip it.
329
- storage.setItem('oxy_access_token', 'stale-legacy-token');
330
- storage.setItem('oxy_user', JSON.stringify({ id: 'legacy', username: 'legacy' }));
331
+ storage.setItem('oxy_access_token', 'stale-storage-token');
332
+ storage.setItem('oxy_user', JSON.stringify({ id: 'stale', username: 'stale' }));
331
333
  const manager = makeManager(services, storage);
332
334
 
333
335
  const user = await manager.initialize();
334
336
 
335
337
  expect(user).toBeNull();
336
- // The legacy access token was NOT planted.
338
+ // Stale storage token material is ignored.
337
339
  expect(services.httpService.setTokens).not.toHaveBeenCalled();
338
340
  });
339
341
  });
340
342
 
341
- describe('AuthManager.getAccessToken (cookieOnly in-memory fallback)', () => {
343
+ describe('AuthManager.getAccessToken', () => {
342
344
  it('returns the in-memory token after a cold-boot cookie restore even though storage holds no token', async () => {
343
345
  // Regression: the cookie restore path plants the active token ONLY in memory
344
346
  // (`_lastKnownAccessToken` + httpService) and intentionally NEVER writes
@@ -352,7 +354,7 @@ describe('AuthManager.getAccessToken (cookieOnly in-memory fallback)', () => {
352
354
 
353
355
  await manager.restoreFromCookies();
354
356
 
355
- // Storage was never touched for the access token (cookieOnly contract holds).
357
+ // Storage was never touched for the access token.
356
358
  expect(storage.has('oxy_access_token')).toBe(false);
357
359
 
358
360
  // getAccessToken still resolves the active slot's token from memory.
@@ -369,7 +371,7 @@ describe('AuthManager.getAccessToken (cookieOnly in-memory fallback)', () => {
369
371
  expect(token).toBeNull();
370
372
  });
371
373
 
372
- it('prefers the storage token over the in-memory token when both are present', async () => {
374
+ it('ignores a retired storage token when an in-memory cookie-restored token is present', async () => {
373
375
  const services = makeMockServices();
374
376
  services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
375
377
  const storage = new InMemoryStorage();
@@ -378,11 +380,11 @@ describe('AuthManager.getAccessToken (cookieOnly in-memory fallback)', () => {
378
380
  // After restore the in-memory token is TOKEN_SLOT_0.
379
381
  await manager.restoreFromCookies();
380
382
 
381
- // Simulate a path that DID write storage (legacy/bearer flow). Storage wins.
383
+ // Simulate stale token material from a retired storage path. Memory wins.
382
384
  const STORAGE_TOKEN = buildAccessToken({ sessionId: 'sess-storage', userId: 'user-storage', exp: 9999999999 });
383
385
  storage.setItem('oxy_access_token', STORAGE_TOKEN);
384
386
 
385
387
  const token = await manager.getAccessToken();
386
- expect(token).toBe(STORAGE_TOKEN);
388
+ expect(token).toBe(TOKEN_SLOT_0);
387
389
  });
388
390
  });