@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
@@ -1,631 +0,0 @@
1
- import type { OxyServicesBase } from '../OxyServices.base';
2
- import { OxyAuthenticationError } from '../OxyServices.errors';
3
- import type { SessionLoginResponse } from '../models/session';
4
- import { createDebugLogger } from '../shared/utils/debugUtils';
5
-
6
- const debug = createDebugLogger('PopupAuth');
7
-
8
- export interface PopupAuthOptions {
9
- width?: number;
10
- height?: number;
11
- timeout?: number;
12
- mode?: 'login' | 'signup';
13
- /**
14
- * A popup window handle the caller already opened SYNCHRONOUSLY inside the
15
- * user-gesture event handler (e.g. an `onClick`). The mixin will navigate
16
- * this window to the auth URL instead of calling `window.open` itself.
17
- *
18
- * Why this exists: Chrome (and other modern browsers) only honor
19
- * `window.open` while a transient user-activation is in scope. The
20
- * activation is consumed by the FIRST `await` in the click handler — so any
21
- * caller that awaits FedCM / silent SSO before reaching `signInWithPopup`
22
- * loses the activation and sees the popup blocked. The caller can dodge
23
- * this by opening a blank popup on the raw click via `openBlankPopup()`,
24
- * then passing the handle in here.
25
- *
26
- * `null` is accepted (and is the same as omitting the option) so consumers
27
- * can pass through the result of `openBlankPopup()` without an extra guard.
28
- */
29
- popup?: Window | null;
30
- }
31
-
32
- export interface SilentAuthOptions {
33
- timeout?: number;
34
- /**
35
- * Override the auth-web (IdP) origin used for the silent iframe, instead of
36
- * the instance's configured `resolveAuthUrl()`.
37
- *
38
- * Why this exists: an instance configured with the CENTRAL IdP
39
- * (`authWebUrl=https://auth.oxy.so`, for the opaque-code `/sso` bounce and
40
- * FedCM) cannot read the DURABLE per-apex `fedcm_session` cookie via the
41
- * central host — that cookie is first-party only on `auth.<rp-apex>` (e.g.
42
- * `auth.mention.earth`). The cross-domain reload-restore path must point the
43
- * `/auth/silent` iframe at the PER-APEX host so the cookie is same-site to
44
- * the RP page (first-party under Safari ITP / Firefox TCP) and the restore
45
- * is NOT a top-level navigation (no flash, works in a backgrounded tab).
46
- *
47
- * When provided this value is used BOTH for the iframe `src` AND for the
48
- * `postMessage` origin validation in {@link waitForIframeAuth}, so the
49
- * security check still matches the exact origin the iframe was loaded from.
50
- * Must be an absolute origin (`https://auth.<apex>`); ignored if empty.
51
- */
52
- authWebUrlOverride?: string;
53
- }
54
-
55
- /**
56
- * Popup-based Cross-Domain Authentication Mixin
57
- *
58
- * Implements OAuth2-style authentication using popup windows and postMessage.
59
- * This is the primary authentication method for modern browsers, providing a
60
- * Google-like experience without full page redirects.
61
- *
62
- * Flow:
63
- * 1. Opens small popup window to auth.oxy.so
64
- * 2. User signs in (auth.oxy.so sets its own first-party cookie)
65
- * 3. auth.oxy.so sends token back via postMessage
66
- * 4. Popup closes, parent app has the session
67
- *
68
- * Features:
69
- * - No full page redirect (preserves app state)
70
- * - Works across different domains (homiio.com, mention.earth, etc.)
71
- * - Silent refresh using hidden iframe for seamless SSO
72
- * - CSRF protection via state parameter
73
- * - XSS protection via origin validation
74
- *
75
- * Browser Support: All modern browsers (IE11+)
76
- */
77
- export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base: T) {
78
- return class extends Base {
79
- constructor(...args: any[]) {
80
- super(...(args as [any]));
81
- }
82
- public static readonly DEFAULT_AUTH_URL = 'https://auth.oxy.so';
83
-
84
- /** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
85
- public resolveAuthUrl(): string {
86
- return this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL;
87
- }
88
-
89
- public static readonly POPUP_WIDTH = 500;
90
- public static readonly POPUP_HEIGHT = 700;
91
- public static readonly POPUP_TIMEOUT = 60000; // 1 minute
92
- public static readonly SILENT_TIMEOUT = 5000; // 5 seconds
93
-
94
- /**
95
- * Sign in using popup window
96
- *
97
- * Opens a centered popup window to auth.oxy.so where the user can sign in.
98
- * The popup automatically closes after successful authentication and the
99
- * session is returned to the parent window.
100
- *
101
- * @param options - Popup configuration options
102
- * @returns Session with access token and user data
103
- * @throws {OxyAuthenticationError} If popup is blocked or auth fails
104
- *
105
- * @example
106
- * ```typescript
107
- * const handleSignIn = async () => {
108
- * try {
109
- * const session = await oxyServices.signInWithPopup();
110
- * console.log('Signed in:', session.user);
111
- * } catch (error) {
112
- * if (error.message.includes('blocked')) {
113
- * alert('Please allow popups for this site');
114
- * }
115
- * }
116
- * };
117
- * ```
118
- */
119
- async signInWithPopup(options: PopupAuthOptions = {}): Promise<SessionLoginResponse> {
120
- if (typeof window === 'undefined') {
121
- throw new OxyAuthenticationError('Popup authentication requires browser environment');
122
- }
123
-
124
- const state = this.generateState();
125
- const nonce = this.generateNonce();
126
-
127
- // Store state for CSRF protection
128
- this.storeAuthState(state, nonce);
129
-
130
- const width = options.width || (this.constructor as any).POPUP_WIDTH;
131
- const height = options.height || (this.constructor as any).POPUP_HEIGHT;
132
- const timeout = options.timeout || (this.constructor as any).POPUP_TIMEOUT;
133
- const mode = options.mode || 'login';
134
-
135
- const authUrl = this.buildAuthUrl({
136
- mode,
137
- state,
138
- nonce,
139
- clientId: window.location.origin,
140
- redirectUri: `${this.resolveAuthUrl()}/auth/callback`,
141
- });
142
-
143
- // If the caller pre-opened a popup on the raw user gesture (recommended
144
- // path — see `openBlankPopup` and `PopupAuthOptions.popup`), navigate it
145
- // to the auth URL instead of issuing a fresh `window.open` (which would
146
- // be blocked once any prior `await` has consumed the user activation).
147
- let popup: Window | null;
148
- const preOpened = options.popup ?? null;
149
- if (preOpened) {
150
- if (preOpened.closed) {
151
- // The pre-opened popup is gone — distinguish a user cancel (they
152
- // closed the blank window before sign-in could navigate it) from a
153
- // blocker rejection. Lumping these together as "Popup blocked" is
154
- // misleading: the popup was NOT blocked, it was opened successfully
155
- // and then dismissed.
156
- throw new OxyAuthenticationError(
157
- 'Sign-in window was closed before authentication could start.'
158
- );
159
- }
160
- try {
161
- preOpened.location.replace(authUrl);
162
- } catch (replaceError) {
163
- // `location.replace` can throw in sandboxed / cross-origin-locked
164
- // environments. Fall back to `href` assignment, which is more
165
- // permissive. Logged at debug-level so consumers can correlate
166
- // unusual sign-in behaviour without producing noise in normal flows.
167
- debug.warn('location.replace failed, falling back to location.href', replaceError);
168
- preOpened.location.href = authUrl;
169
- }
170
- popup = preOpened;
171
- } else {
172
- popup = this.openCenteredPopup(authUrl, 'Oxy Sign In', width, height);
173
- }
174
-
175
- if (!popup) {
176
- throw new OxyAuthenticationError(
177
- 'Popup blocked. Please allow popups for this site and try again.'
178
- );
179
- }
180
-
181
- try {
182
- const session = await this.waitForPopupAuth(popup, state, timeout);
183
-
184
- // Store access token if present
185
- if (session && (session as any).accessToken) {
186
- this.httpService.setTokens((session as any).accessToken);
187
- }
188
-
189
- // Fetch user data using the session ID
190
- // The callback page only sends sessionId/accessToken, not user data
191
- if (session && session.sessionId && !session.user) {
192
- try {
193
- const userData = await this.makeRequest<any>(
194
- 'GET',
195
- `/session/user/${session.sessionId}`,
196
- undefined,
197
- { cache: false }
198
- );
199
- if (userData) {
200
- (session as any).user = userData;
201
- }
202
- } catch (userError) {
203
- debug.warn('Failed to fetch user data:', userError);
204
- // Continue without user data - caller can fetch separately
205
- }
206
- }
207
-
208
- return session;
209
- } catch (error) {
210
- throw error;
211
- } finally {
212
- this.clearAuthState(state);
213
- }
214
- }
215
-
216
- /**
217
- * Sign up using popup window
218
- *
219
- * Same as signInWithPopup but opens the signup page by default.
220
- *
221
- * @param options - Popup configuration options
222
- * @returns Session with access token and user data
223
- */
224
- async signUpWithPopup(options: PopupAuthOptions = {}): Promise<SessionLoginResponse> {
225
- return this.signInWithPopup({ ...options, mode: 'signup' });
226
- }
227
-
228
- /**
229
- * Silent sign-in using hidden iframe
230
- *
231
- * Attempts to automatically re-authenticate the user without any UI.
232
- * This is what enables seamless SSO across all Oxy domains.
233
- *
234
- * How it works:
235
- * 1. Creates hidden iframe pointing to auth.oxy.so/silent-auth
236
- * 2. If user has valid session at auth.oxy.so, it sends token via postMessage
237
- * 3. If not, iframe responds with null (no error thrown)
238
- *
239
- * This should be called on app startup to check for existing sessions.
240
- *
241
- * @param options - Silent auth options
242
- * @returns Session if user is signed in, null otherwise
243
- *
244
- * @example
245
- * ```typescript
246
- * useEffect(() => {
247
- * const checkAuth = async () => {
248
- * const session = await oxyServices.silentSignIn();
249
- * if (session) {
250
- * setUser(session.user);
251
- * }
252
- * };
253
- * checkAuth();
254
- * }, []);
255
- * ```
256
- */
257
- async silentSignIn(options: SilentAuthOptions = {}): Promise<SessionLoginResponse | null> {
258
- if (typeof window === 'undefined') {
259
- return null;
260
- }
261
-
262
- const timeout = options.timeout || (this.constructor as any).SILENT_TIMEOUT;
263
- const nonce = this.generateNonce();
264
- const clientId = window.location.origin;
265
-
266
- // Resolve the IdP origin for the iframe. An explicit per-apex override (the
267
- // durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
268
- // wins over the instance's configured central auth URL. The SAME origin is
269
- // handed to `waitForIframeAuth` so the postMessage origin check matches the
270
- // exact host the iframe was loaded from.
271
- const authOrigin =
272
- options.authWebUrlOverride && options.authWebUrlOverride.length > 0
273
- ? options.authWebUrlOverride
274
- : this.resolveAuthUrl();
275
-
276
- const iframe = document.createElement('iframe');
277
- iframe.style.display = 'none';
278
- iframe.style.position = 'absolute';
279
- iframe.style.width = '0';
280
- iframe.style.height = '0';
281
- iframe.style.border = 'none';
282
-
283
- const silentUrl = `${authOrigin}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
284
-
285
- iframe.src = silentUrl;
286
- document.body.appendChild(iframe);
287
-
288
- try {
289
- const session = await this.waitForIframeAuth(iframe, timeout, authOrigin);
290
-
291
- // Bail early on incomplete responses. The iframe contract requires
292
- // both an access token and a session id; anything less is unusable.
293
- // Returning `null` here (without installing the token) prevents a
294
- // stale credential from being committed to HttpService when the
295
- // user is actually signed out — that pattern caused a `getCurrentUser`
296
- // -> 401 -> token-clear loop in consumer apps because callers gated
297
- // on `session?.user` and never installed the user via
298
- // `handleAuthSuccess`, while HttpService quietly held the token.
299
- const accessToken = session ? (session as { accessToken?: string }).accessToken : undefined;
300
- if (!session || !accessToken || !session.sessionId) {
301
- return null;
302
- }
303
-
304
- // Snapshot the previous token so we can roll back if the user
305
- // lookup below fails — this avoids leaving a half-committed session
306
- // (token installed, user missing) which would let the next
307
- // authenticated request 401 with no way to recover.
308
- const previousAccessToken = this.httpService.getAccessToken();
309
- this.httpService.setTokens(accessToken);
310
-
311
- // The iframe typically returns `{ sessionId, accessToken }` without
312
- // user data. Fetch the user explicitly so callers receive a
313
- // fully-formed session and never need a second `/users/me` round
314
- // trip. If this fails the session is unusable — revert the token
315
- // and return null so the caller treats this exactly like a
316
- // missing-session response.
317
- if (!session.user) {
318
- try {
319
- const userData = await this.makeRequest<unknown>(
320
- 'GET',
321
- `/session/user/${session.sessionId}`,
322
- undefined,
323
- { cache: false, retry: false }
324
- );
325
- if (!userData) {
326
- throw new Error('Empty user response');
327
- }
328
- (session as { user?: unknown }).user = userData;
329
- } catch (userError) {
330
- debug.warn('silentSignIn: failed to fetch user data, rolling back token', userError);
331
- if (previousAccessToken) {
332
- this.httpService.setTokens(previousAccessToken);
333
- } else {
334
- this.httpService.clearTokens();
335
- }
336
- return null;
337
- }
338
- }
339
-
340
- return session;
341
- } catch (error) {
342
- return null;
343
- } finally {
344
- document.body.removeChild(iframe);
345
- }
346
- }
347
-
348
- /**
349
- * Open a blank, centered popup window SYNCHRONOUSLY.
350
- *
351
- * Use this in a click (or other user-gesture) handler BEFORE any `await`
352
- * to capture the transient user-activation. Pass the returned handle into
353
- * `signInWithPopup({ popup })` once the async portion of the flow runs.
354
- *
355
- * Returns `null` if the browser's popup blocker rejected the open.
356
- *
357
- * @example
358
- * ```typescript
359
- * const onSignInClick = () => {
360
- * const popup = oxyServices.openBlankPopup();
361
- * (async () => {
362
- * const silent = await oxyServices.silentSignInWithFedCM();
363
- * if (silent) { popup?.close(); return; }
364
- * await oxyServices.signInWithPopup({ popup });
365
- * })();
366
- * };
367
- * ```
368
- */
369
- public openBlankPopup(width?: number, height?: number): Window | null {
370
- if (typeof window === 'undefined') {
371
- return null;
372
- }
373
- const ctor = this.constructor as unknown as { POPUP_WIDTH: number; POPUP_HEIGHT: number };
374
- const w = width ?? ctor.POPUP_WIDTH;
375
- const h = height ?? ctor.POPUP_HEIGHT;
376
- return this.openCenteredPopup('about:blank', 'Oxy Sign In', w, h);
377
- }
378
-
379
- /**
380
- * Open a centered popup window
381
- *
382
- * @private
383
- */
384
- public openCenteredPopup(url: string, title: string, width: number, height: number): Window | null {
385
- const left = window.screenX + (window.outerWidth - width) / 2;
386
- const top = window.screenY + (window.outerHeight - height) / 2;
387
-
388
- const features = [
389
- `width=${width}`,
390
- `height=${height}`,
391
- `left=${left}`,
392
- `top=${top}`,
393
- 'toolbar=no',
394
- 'menubar=no',
395
- 'scrollbars=yes',
396
- 'resizable=yes',
397
- 'status=no',
398
- 'location=no',
399
- ].join(',');
400
-
401
- return window.open(url, title, features);
402
- }
403
-
404
- /**
405
- * Wait for authentication response from popup
406
- *
407
- * @private
408
- */
409
- public async waitForPopupAuth(
410
- popup: Window,
411
- expectedState: string,
412
- timeout: number
413
- ): Promise<SessionLoginResponse> {
414
- return new Promise((resolve, reject) => {
415
- const timeoutId = setTimeout(() => {
416
- cleanup();
417
- reject(new OxyAuthenticationError('Authentication timeout'));
418
- }, timeout);
419
-
420
- const messageHandler = (event: MessageEvent) => {
421
- const authUrl = this.resolveAuthUrl();
422
-
423
- // Log all messages for debugging
424
- if (event.data && typeof event.data === 'object' && event.data.type) {
425
- debug.log('Message received:', {
426
- origin: event.origin,
427
- expectedOrigin: authUrl,
428
- type: event.data.type,
429
- hasSession: !!event.data.session,
430
- hasError: !!event.data.error,
431
- });
432
- }
433
-
434
- // CRITICAL: Verify origin to prevent XSS attacks
435
- if (event.origin !== authUrl) {
436
- return;
437
- }
438
-
439
- const { type, state, session, error } = event.data;
440
-
441
- if (type !== 'oxy_auth_response') {
442
- return;
443
- }
444
-
445
- debug.log('Valid auth response:', { state, expectedState, hasSession: !!session, error });
446
-
447
- // Verify state parameter to prevent CSRF attacks
448
- if (state !== expectedState) {
449
- cleanup();
450
- debug.error('State mismatch');
451
- reject(new OxyAuthenticationError('Invalid state parameter. Possible CSRF attack.'));
452
- return;
453
- }
454
-
455
- cleanup();
456
-
457
- if (error) {
458
- debug.error('Auth error:', error);
459
- reject(new OxyAuthenticationError(error));
460
- } else if (session) {
461
- debug.log('Session received successfully');
462
- resolve(session);
463
- } else {
464
- debug.error('No session in response');
465
- reject(new OxyAuthenticationError('No session received from authentication server'));
466
- }
467
- };
468
-
469
- // Poll to detect if user closed the popup
470
- const pollInterval = setInterval(() => {
471
- if (popup.closed) {
472
- cleanup();
473
- reject(new OxyAuthenticationError('Authentication cancelled by user'));
474
- }
475
- }, 500);
476
-
477
- const cleanup = () => {
478
- clearTimeout(timeoutId);
479
- clearInterval(pollInterval);
480
- window.removeEventListener('message', messageHandler);
481
- if (!popup.closed) {
482
- popup.close();
483
- }
484
- };
485
-
486
- window.addEventListener('message', messageHandler);
487
- });
488
- }
489
-
490
- /**
491
- * Wait for authentication response from iframe
492
- *
493
- * @private
494
- */
495
- public async waitForIframeAuth(
496
- iframe: HTMLIFrameElement,
497
- timeout: number,
498
- expectedOrigin: string
499
- ): Promise<SessionLoginResponse | null> {
500
- return new Promise((resolve) => {
501
- const timeoutId = setTimeout(() => {
502
- cleanup();
503
- resolve(null); // Silent failure - don't throw
504
- }, timeout);
505
-
506
- const messageHandler = (event: MessageEvent) => {
507
- // Verify origin against the EXACT host the iframe was loaded from
508
- // (`expectedOrigin`). For the per-apex durable-restore path this is
509
- // `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
510
- // we must honour the caller-supplied origin, never re-derive it here.
511
- if (event.origin !== expectedOrigin) {
512
- return;
513
- }
514
-
515
- const { type, session } = event.data;
516
-
517
- if (type !== 'oxy_silent_auth') {
518
- return;
519
- }
520
-
521
- cleanup();
522
- resolve(session || null);
523
- };
524
-
525
- // Fail-fast on a load failure. When the per-apex `/auth/silent` host is
526
- // unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
527
- // network drops, the iframe never posts a message — without this handler
528
- // the silent restore would block for the FULL `timeout` (dead latency in
529
- // the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
530
- // so resolve `null` immediately and let the next cold-boot step run. The
531
- // success path posts a message and is handled above; these only catch the
532
- // no-message failure modes.
533
- const failFast = () => {
534
- cleanup();
535
- resolve(null);
536
- };
537
- iframe.onerror = failFast;
538
- iframe.onabort = failFast;
539
-
540
- const cleanup = () => {
541
- clearTimeout(timeoutId);
542
- iframe.onerror = null;
543
- iframe.onabort = null;
544
- window.removeEventListener('message', messageHandler);
545
- };
546
-
547
- window.addEventListener('message', messageHandler);
548
- });
549
- }
550
-
551
- /**
552
- * Build authentication URL with query parameters
553
- *
554
- * @private
555
- */
556
- public buildAuthUrl(params: {
557
- mode: string;
558
- state: string;
559
- nonce: string;
560
- clientId: string;
561
- redirectUri: string;
562
- }): string {
563
- const url = new URL(`${this.resolveAuthUrl()}/${params.mode}`);
564
- url.searchParams.set('response_type', 'token');
565
- url.searchParams.set('client_id', params.clientId);
566
- url.searchParams.set('redirect_uri', params.redirectUri);
567
- url.searchParams.set('state', params.state);
568
- url.searchParams.set('nonce', params.nonce);
569
- return url.toString();
570
- }
571
-
572
- /**
573
- * Generate cryptographically secure state for CSRF protection
574
- *
575
- * @private
576
- */
577
- public generateState(): string {
578
- if (typeof crypto !== 'undefined' && crypto.randomUUID) {
579
- return crypto.randomUUID();
580
- }
581
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
582
- const bytes = new Uint8Array(16);
583
- crypto.getRandomValues(bytes);
584
- return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
585
- }
586
- throw new Error('No secure random source available for state generation');
587
- }
588
-
589
- /**
590
- * Generate nonce for replay attack prevention
591
- *
592
- * @private
593
- */
594
- public generateNonce(): string {
595
- if (typeof crypto !== 'undefined' && crypto.randomUUID) {
596
- return crypto.randomUUID();
597
- }
598
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
599
- const bytes = new Uint8Array(16);
600
- crypto.getRandomValues(bytes);
601
- return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
602
- }
603
- throw new Error('No secure random source available for nonce generation');
604
- }
605
-
606
- /**
607
- * Store auth state in session storage
608
- *
609
- * @private
610
- */
611
- public storeAuthState(state: string, nonce: string): void {
612
- if (typeof window !== 'undefined' && window.sessionStorage) {
613
- sessionStorage.setItem(`oxy_auth_state_${state}`, JSON.stringify({ nonce, timestamp: Date.now() }));
614
- }
615
- }
616
-
617
- /**
618
- * Clear auth state from session storage
619
- *
620
- * @private
621
- */
622
- public clearAuthState(state: string): void {
623
- if (typeof window !== 'undefined' && window.sessionStorage) {
624
- sessionStorage.removeItem(`oxy_auth_state_${state}`);
625
- }
626
- }
627
- };
628
- }
629
-
630
- // Export the mixin function as both named and default
631
- export { OxyServicesPopupAuthMixin as PopupAuthMixin };