@oxyhq/core 1.11.9 → 1.11.10

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.
@@ -106,17 +106,106 @@ class AuthManager {
106
106
  this.currentUser = null;
107
107
  this.refreshTimer = null;
108
108
  this.refreshPromise = null;
109
+ /** Tracks the access token this instance last knew about, for cross-tab adoption. */
110
+ this._lastKnownAccessToken = null;
111
+ /** BroadcastChannel for coordinating token refreshes across browser tabs. */
112
+ this._broadcastChannel = null;
113
+ /** Set to true when another tab broadcasts a successful refresh, so this tab can skip its own. */
114
+ this._otherTabRefreshed = false;
109
115
  this.oxyServices = oxyServices;
116
+ const crossTabSync = config.crossTabSync ?? (typeof BroadcastChannel !== 'undefined');
110
117
  this.config = {
111
118
  storage: config.storage ?? this.getDefaultStorage(),
112
119
  autoRefresh: config.autoRefresh ?? true,
113
120
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
121
+ crossTabSync,
114
122
  };
115
123
  this.storage = this.config.storage;
116
124
  // Persist tokens to storage when HttpService refreshes them automatically
117
125
  this.oxyServices.httpService.onTokenRefreshed = (accessToken) => {
126
+ this._lastKnownAccessToken = accessToken;
118
127
  this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
119
128
  };
129
+ // Setup cross-tab coordination in browser environments
130
+ if (this.config.crossTabSync) {
131
+ this._initBroadcastChannel();
132
+ }
133
+ }
134
+ /**
135
+ * Initialize BroadcastChannel for cross-tab token refresh coordination.
136
+ * Only called in browser environments where BroadcastChannel is available.
137
+ */
138
+ _initBroadcastChannel() {
139
+ if (typeof BroadcastChannel === 'undefined')
140
+ return;
141
+ try {
142
+ this._broadcastChannel = new BroadcastChannel('oxy_auth_sync');
143
+ this._broadcastChannel.onmessage = (event) => {
144
+ this._handleCrossTabMessage(event.data);
145
+ };
146
+ }
147
+ catch {
148
+ // BroadcastChannel not supported or blocked (e.g., opaque origins)
149
+ this._broadcastChannel = null;
150
+ }
151
+ }
152
+ /**
153
+ * Handle messages from other tabs about token refresh activity.
154
+ */
155
+ async _handleCrossTabMessage(message) {
156
+ if (!message || !message.type)
157
+ return;
158
+ switch (message.type) {
159
+ case 'tokens_refreshed': {
160
+ // Another tab successfully refreshed. Signal to cancel our pending refresh.
161
+ this._otherTabRefreshed = true;
162
+ // Adopt the new tokens from shared storage
163
+ const newToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
164
+ if (newToken && newToken !== this._lastKnownAccessToken) {
165
+ this._lastKnownAccessToken = newToken;
166
+ this.oxyServices.httpService.setTokens(newToken);
167
+ // Re-read session for updated expiry and schedule next refresh
168
+ const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
169
+ if (sessionJson) {
170
+ try {
171
+ const session = JSON.parse(sessionJson);
172
+ if (session.expiresAt && this.config.autoRefresh) {
173
+ this.setupTokenRefresh(session.expiresAt);
174
+ }
175
+ }
176
+ catch {
177
+ // Ignore parse errors
178
+ }
179
+ }
180
+ }
181
+ break;
182
+ }
183
+ case 'signed_out': {
184
+ // Another tab signed out. Clear our local state to stay consistent.
185
+ if (this.refreshTimer) {
186
+ clearTimeout(this.refreshTimer);
187
+ this.refreshTimer = null;
188
+ }
189
+ this.refreshPromise = null;
190
+ this._lastKnownAccessToken = null;
191
+ this.oxyServices.httpService.setTokens('');
192
+ this.currentUser = null;
193
+ this.notifyListeners();
194
+ break;
195
+ }
196
+ // 'refresh_starting' is informational; we don't need to act on it currently
197
+ }
198
+ }
199
+ /**
200
+ * Broadcast a message to other tabs.
201
+ */
202
+ _broadcast(message) {
203
+ try {
204
+ this._broadcastChannel?.postMessage(message);
205
+ }
206
+ catch {
207
+ // Channel closed or unavailable
208
+ }
120
209
  }
121
210
  /**
122
211
  * Get default storage based on environment.
@@ -163,6 +252,7 @@ class AuthManager {
163
252
  async handleAuthSuccess(session, method = 'credentials') {
164
253
  // Store tokens
165
254
  if (session.accessToken) {
255
+ this._lastKnownAccessToken = session.accessToken;
166
256
  await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, session.accessToken);
167
257
  this.oxyServices.httpService.setTokens(session.accessToken);
168
258
  }
@@ -227,6 +317,8 @@ class AuthManager {
227
317
  }
228
318
  }
229
319
  async _doRefreshToken() {
320
+ // Reset the cross-tab flag before starting
321
+ this._otherTabRefreshed = false;
230
322
  // Get session info to find sessionId for token refresh
231
323
  const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
232
324
  if (!sessionJson) {
@@ -243,8 +335,22 @@ class AuthManager {
243
335
  console.error('AuthManager: Failed to parse session from storage.', err);
244
336
  return false;
245
337
  }
338
+ // Record the token we know about before attempting refresh
339
+ const tokenBeforeRefresh = this._lastKnownAccessToken;
340
+ // Broadcast that we're starting a refresh (informational for other tabs)
341
+ this._broadcast({ type: 'refresh_starting', sessionId, timestamp: Date.now() });
246
342
  try {
247
343
  await (0, asyncUtils_1.retryAsync)(async () => {
344
+ // Before each attempt, check if another tab already refreshed
345
+ if (this._otherTabRefreshed) {
346
+ const adoptedToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
347
+ if (adoptedToken && adoptedToken !== tokenBeforeRefresh) {
348
+ // Another tab succeeded. Adopt its tokens and short-circuit.
349
+ this._lastKnownAccessToken = adoptedToken;
350
+ this.oxyServices.httpService.setTokens(adoptedToken);
351
+ return;
352
+ }
353
+ }
248
354
  const httpService = this.oxyServices.httpService;
249
355
  // Use session-based token endpoint which handles auto-refresh server-side
250
356
  const response = await httpService.request({
@@ -257,6 +363,7 @@ class AuthManager {
257
363
  throw new Error('No access token in refresh response');
258
364
  }
259
365
  // Update access token in storage and HTTP client
366
+ this._lastKnownAccessToken = response.accessToken;
260
367
  await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
261
368
  this.oxyServices.httpService.setTokens(response.accessToken);
262
369
  // Update session expiry and schedule next refresh
@@ -274,6 +381,8 @@ class AuthManager {
274
381
  this.setupTokenRefresh(response.expiresAt);
275
382
  }
276
383
  }
384
+ // Broadcast success so other tabs can adopt these tokens
385
+ this._broadcast({ type: 'tokens_refreshed', sessionId, timestamp: Date.now() });
277
386
  }, 2, // 2 retries = 3 total attempts
278
387
  1000, // 1s base delay with exponential backoff + jitter
279
388
  (error) => {
@@ -286,7 +395,41 @@ class AuthManager {
286
395
  return true;
287
396
  }
288
397
  catch {
289
- // All retry attempts exhausted, clear session
398
+ // All retry attempts exhausted. Before clearing the session, check if
399
+ // another tab managed to refresh successfully while we were retrying.
400
+ // Since all tabs share the same storage (localStorage), a successful
401
+ // refresh from another tab will have written a different access token.
402
+ const currentStoredToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
403
+ if (currentStoredToken && currentStoredToken !== tokenBeforeRefresh) {
404
+ // Another tab refreshed successfully. Adopt its tokens instead of logging out.
405
+ this._lastKnownAccessToken = currentStoredToken;
406
+ this.oxyServices.httpService.setTokens(currentStoredToken);
407
+ // Restore user from storage in case it was updated
408
+ const userJson = await this.storage.getItem(STORAGE_KEYS.USER);
409
+ if (userJson) {
410
+ try {
411
+ this.currentUser = JSON.parse(userJson);
412
+ }
413
+ catch {
414
+ // Ignore parse errors
415
+ }
416
+ }
417
+ // Re-read session expiry and schedule next refresh
418
+ const updatedSessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
419
+ if (updatedSessionJson) {
420
+ try {
421
+ const session = JSON.parse(updatedSessionJson);
422
+ if (session.expiresAt && this.config.autoRefresh) {
423
+ this.setupTokenRefresh(session.expiresAt);
424
+ }
425
+ }
426
+ catch {
427
+ // Ignore parse errors
428
+ }
429
+ }
430
+ return true;
431
+ }
432
+ // No other tab rescued us -- truly clear the session
290
433
  await this.clearSession();
291
434
  this.currentUser = null;
292
435
  this.notifyListeners();
@@ -328,8 +471,11 @@ class AuthManager {
328
471
  }
329
472
  // Clear HTTP client tokens
330
473
  this.oxyServices.httpService.setTokens('');
474
+ this._lastKnownAccessToken = null;
331
475
  // Clear storage
332
476
  await this.clearSession();
477
+ // Notify other tabs so they also sign out
478
+ this._broadcast({ type: 'signed_out', timestamp: Date.now() });
333
479
  // Update state and notify
334
480
  this.currentUser = null;
335
481
  this.notifyListeners();
@@ -404,6 +550,7 @@ class AuthManager {
404
550
  // Restore token to HTTP client
405
551
  const token = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
406
552
  if (token) {
553
+ this._lastKnownAccessToken = token;
407
554
  this.oxyServices.httpService.setTokens(token);
408
555
  }
409
556
  // Check session expiry
@@ -444,6 +591,16 @@ class AuthManager {
444
591
  this.refreshTimer = null;
445
592
  }
446
593
  this.listeners.clear();
594
+ // Close BroadcastChannel
595
+ if (this._broadcastChannel) {
596
+ try {
597
+ this._broadcastChannel.close();
598
+ }
599
+ catch {
600
+ // Ignore close errors
601
+ }
602
+ this._broadcastChannel = null;
603
+ }
447
604
  }
448
605
  }
449
606
  exports.AuthManager = AuthManager;
@@ -126,13 +126,11 @@ async function getSecureRandomBytes(length) {
126
126
  return Crypto.getRandomBytes(length);
127
127
  }
128
128
  // In Node.js, use Node's crypto module
129
- // Use Function constructor to prevent Metro bundler from statically analyzing this require
130
- // This ensures the require is only evaluated in Node.js runtime, not during Metro bundling
129
+ // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
131
130
  try {
132
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
133
- const getCrypto = new Function('return require("crypto")');
134
- const crypto = getCrypto();
135
- return new Uint8Array(crypto.randomBytes(length));
131
+ const cryptoModuleName = 'crypto';
132
+ const nodeCrypto = await Promise.resolve(`${cryptoModuleName}`).then(s => __importStar(require(s)));
133
+ return new Uint8Array(nodeCrypto.randomBytes(length));
136
134
  }
137
135
  catch (error) {
138
136
  // Fallback to expo-crypto if Node crypto fails
@@ -8,6 +8,39 @@
8
8
  * - Browser/Node.js: Uses native crypto
9
9
  * - React Native: Falls back to expo-crypto if native crypto unavailable
10
10
  */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
11
44
  Object.defineProperty(exports, "__esModule", { value: true });
12
45
  exports.Buffer = void 0;
13
46
  const buffer_1 = require("buffer");
@@ -30,27 +63,35 @@ if (!globalObject.Buffer) {
30
63
  }
31
64
  // Cache for expo-crypto module (lazy loaded only in React Native)
32
65
  let expoCryptoModule = null;
33
- let expoCryptoLoadAttempted = false;
34
- function getRandomBytesSync(byteCount) {
35
- if (!expoCryptoLoadAttempted) {
36
- expoCryptoLoadAttempted = true;
66
+ let expoCryptoLoadPromise = null;
67
+ /**
68
+ * Eagerly start loading expo-crypto. The module is cached once resolved so
69
+ * the synchronous getRandomValues shim can read from it immediately.
70
+ * Uses dynamic import with variable indirection to prevent ESM bundlers
71
+ * (Vite, webpack) from statically resolving the specifier.
72
+ */
73
+ function startExpoCryptoLoad() {
74
+ if (expoCryptoLoadPromise)
75
+ return;
76
+ expoCryptoLoadPromise = (async () => {
37
77
  try {
38
- // Only use require() in CJS environments (Metro/Node). In ESM (Vite/browser),
39
- // crypto.getRandomValues exists natively so this code path is never reached.
40
- if (typeof require !== 'undefined') {
41
- const moduleName = 'expo-crypto';
42
- expoCryptoModule = require(moduleName);
43
- }
78
+ const moduleName = 'expo-crypto';
79
+ expoCryptoModule = await Promise.resolve(`${moduleName}`).then(s => __importStar(require(s)));
44
80
  }
45
81
  catch {
46
82
  // expo-crypto not available — expected in non-RN environments
47
83
  }
48
- }
84
+ })();
85
+ }
86
+ function getRandomBytesSync(byteCount) {
87
+ // Kick off loading if not already started (should have been started at module init)
88
+ startExpoCryptoLoad();
49
89
  if (expoCryptoModule) {
50
90
  return expoCryptoModule.getRandomBytes(byteCount);
51
91
  }
52
92
  throw new Error('No crypto.getRandomValues implementation available. ' +
53
- 'In React Native, install expo-crypto.');
93
+ 'In React Native, install expo-crypto. ' +
94
+ 'If expo-crypto is installed, ensure the polyfill module is imported early enough for the async load to complete.');
54
95
  }
55
96
  const cryptoPolyfill = {
56
97
  getRandomValues(array) {
@@ -62,8 +103,11 @@ const cryptoPolyfill = {
62
103
  };
63
104
  // Only polyfill if crypto or crypto.getRandomValues is not available
64
105
  if (typeof globalObject.crypto === 'undefined') {
106
+ // Start loading expo-crypto eagerly so it is ready by the time getRandomValues is called
107
+ startExpoCryptoLoad();
65
108
  globalObject.crypto = cryptoPolyfill;
66
109
  }
67
110
  else if (typeof globalObject.crypto.getRandomValues !== 'function') {
111
+ startExpoCryptoLoad();
68
112
  globalObject.crypto.getRandomValues = cryptoPolyfill.getRandomValues;
69
113
  }
@@ -98,11 +98,11 @@ class SignatureService {
98
98
  .join('');
99
99
  }
100
100
  // In Node.js, use Node's crypto module
101
+ // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
101
102
  if ((0, platform_1.isNodeJS)()) {
102
103
  try {
103
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
104
- const getCrypto = new Function('return require("crypto")');
105
- const nodeCrypto = getCrypto();
104
+ const cryptoModuleName = 'crypto';
105
+ const nodeCrypto = await Promise.resolve(`${cryptoModuleName}`).then(s => __importStar(require(s)));
106
106
  return nodeCrypto.randomBytes(32).toString('hex');
107
107
  }
108
108
  catch {
@@ -169,7 +169,10 @@ class SignatureService {
169
169
  // In React Native, use async verify instead
170
170
  throw new Error('verifySync should only be used in Node.js. Use verify() in React Native.');
171
171
  }
172
- // Use Function constructor to prevent Metro bundler from statically analyzing this require
172
+ // Intentionally using Function constructor here: this method is synchronous by design
173
+ // (Node.js backend hot-path) so we cannot use `await import()`. The Function constructor
174
+ // prevents Metro/bundlers from statically resolving the require. This is acceptable because
175
+ // verifySync is gated by isNodeJS() and will never execute in browser/RN environments.
173
176
  // eslint-disable-next-line @typescript-eslint/no-implied-eval
174
177
  const getCrypto = new Function('return require("crypto")');
175
178
  const crypto = getCrypto();
@@ -38,6 +38,11 @@ function OxyServicesFedCMMixin(Base) {
38
38
  constructor(...args) {
39
39
  super(...args);
40
40
  }
41
+ resolveFedcmConfigUrl() {
42
+ return this.config.authWebUrl
43
+ ? `${this.config.authWebUrl}/fedcm.json`
44
+ : this.constructor.DEFAULT_CONFIG_URL;
45
+ }
41
46
  /**
42
47
  * Check if FedCM is supported in the current browser
43
48
  */
@@ -89,7 +94,7 @@ function OxyServicesFedCMMixin(Base) {
89
94
  // Request credential from browser's native identity flow
90
95
  // mode: 'button' signals this is a user-gesture-initiated flow (Chrome 125+)
91
96
  const credential = await this.requestIdentityCredential({
92
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : this.constructor.DEFAULT_CONFIG_URL),
97
+ configURL: this.resolveFedcmConfigUrl(),
93
98
  clientId,
94
99
  nonce,
95
100
  context: options.context,
@@ -179,7 +184,7 @@ function OxyServicesFedCMMixin(Base) {
179
184
  const nonce = this.generateNonce();
180
185
  debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
181
186
  credential = await this.requestIdentityCredential({
182
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : this.constructor.DEFAULT_CONFIG_URL),
187
+ configURL: this.resolveFedcmConfigUrl(),
183
188
  clientId,
184
189
  nonce,
185
190
  loginHint,
@@ -393,7 +398,7 @@ function OxyServicesFedCMMixin(Base) {
393
398
  if ('IdentityCredential' in window && 'disconnect' in window.IdentityCredential) {
394
399
  const clientId = this.getClientId();
395
400
  await window.IdentityCredential.disconnect({
396
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : this.constructor.DEFAULT_CONFIG_URL),
401
+ configURL: this.resolveFedcmConfigUrl(),
397
402
  clientId,
398
403
  accountHint: accountHint || '*',
399
404
  });
@@ -412,7 +417,7 @@ function OxyServicesFedCMMixin(Base) {
412
417
  getFedCMConfig() {
413
418
  return {
414
419
  enabled: this.isFedCMSupported(),
415
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : this.constructor.DEFAULT_CONFIG_URL),
420
+ configURL: this.resolveFedcmConfigUrl(),
416
421
  clientId: this.getClientId(),
417
422
  };
418
423
  }
@@ -33,6 +33,10 @@ function OxyServicesPopupAuthMixin(Base) {
33
33
  constructor(...args) {
34
34
  super(...args);
35
35
  }
36
+ /** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
37
+ resolveAuthUrl() {
38
+ return this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL;
39
+ }
36
40
  /**
37
41
  * Sign in using popup window
38
42
  *
@@ -75,7 +79,7 @@ function OxyServicesPopupAuthMixin(Base) {
75
79
  state,
76
80
  nonce,
77
81
  clientId: window.location.origin,
78
- redirectUri: `${(this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL)}/auth/callback`,
82
+ redirectUri: `${this.resolveAuthUrl()}/auth/callback`,
79
83
  });
80
84
  const popup = this.openCenteredPopup(authUrl, 'Oxy Sign In', width, height);
81
85
  if (!popup) {
@@ -163,7 +167,7 @@ function OxyServicesPopupAuthMixin(Base) {
163
167
  iframe.style.width = '0';
164
168
  iframe.style.height = '0';
165
169
  iframe.style.border = 'none';
166
- const silentUrl = `${(this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL)}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
170
+ const silentUrl = `${this.resolveAuthUrl()}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
167
171
  iframe.src = silentUrl;
168
172
  document.body.appendChild(iframe);
169
173
  try {
@@ -214,7 +218,7 @@ function OxyServicesPopupAuthMixin(Base) {
214
218
  reject(new OxyServices_errors_1.OxyAuthenticationError('Authentication timeout'));
215
219
  }, timeout);
216
220
  const messageHandler = (event) => {
217
- const authUrl = (this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL);
221
+ const authUrl = this.resolveAuthUrl();
218
222
  // Log all messages for debugging
219
223
  if (event.data && typeof event.data === 'object' && event.data.type) {
220
224
  debug.log('Message received:', {
@@ -286,7 +290,7 @@ function OxyServicesPopupAuthMixin(Base) {
286
290
  }, timeout);
287
291
  const messageHandler = (event) => {
288
292
  // Verify origin
289
- if (event.origin !== (this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL)) {
293
+ if (event.origin !== this.resolveAuthUrl()) {
290
294
  return;
291
295
  }
292
296
  const { type, session } = event.data;
@@ -309,7 +313,7 @@ function OxyServicesPopupAuthMixin(Base) {
309
313
  * @private
310
314
  */
311
315
  buildAuthUrl(params) {
312
- const url = new URL(`${(this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL)}/${params.mode}`);
316
+ const url = new URL(`${this.resolveAuthUrl()}/${params.mode}`);
313
317
  url.searchParams.set('response_type', 'token');
314
318
  url.searchParams.set('client_id', params.clientId);
315
319
  url.searchParams.set('redirect_uri', params.redirectUri);