@oxyhq/core 1.1.0 → 1.2.1

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.
@@ -103,6 +103,7 @@ class AuthManager {
103
103
  this.listeners = new Set();
104
104
  this.currentUser = null;
105
105
  this.refreshTimer = null;
106
+ this.refreshPromise = null;
106
107
  this.oxyServices = oxyServices;
107
108
  this.config = {
108
109
  storage: config.storage ?? this.getDefaultStorage(),
@@ -203,9 +204,23 @@ class AuthManager {
203
204
  }
204
205
  }
205
206
  /**
206
- * Refresh the access token.
207
+ * Refresh the access token. Deduplicates concurrent calls so only one
208
+ * refresh request is in-flight at a time.
207
209
  */
208
210
  async refreshToken() {
211
+ // If a refresh is already in-flight, return the same promise
212
+ if (this.refreshPromise) {
213
+ return this.refreshPromise;
214
+ }
215
+ this.refreshPromise = this._doRefreshToken();
216
+ try {
217
+ return await this.refreshPromise;
218
+ }
219
+ finally {
220
+ this.refreshPromise = null;
221
+ }
222
+ }
223
+ async _doRefreshToken() {
209
224
  const refreshToken = await this.storage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
210
225
  if (!refreshToken) {
211
226
  return false;
@@ -85,6 +85,7 @@ class TokenStore {
85
85
  */
86
86
  class HttpService {
87
87
  constructor(config) {
88
+ this.tokenRefreshPromise = null;
88
89
  // Performance monitoring
89
90
  this.requestMetrics = {
90
91
  totalRequests: 0,
@@ -448,24 +449,17 @@ class HttpService {
448
449
  const currentTime = Math.floor(Date.now() / 1000);
449
450
  // If token expires in less than 60 seconds, refresh it
450
451
  if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
452
+ // Deduplicate concurrent refresh attempts
453
+ if (!this.tokenRefreshPromise) {
454
+ this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId);
455
+ }
451
456
  try {
452
- const refreshUrl = `${this.baseURL}/api/session/token/${decoded.sessionId}`;
453
- // Use AbortSignal.timeout for consistent timeout handling
454
- const response = await fetch(refreshUrl, {
455
- method: 'GET',
456
- headers: { 'Accept': 'application/json' },
457
- signal: AbortSignal.timeout(5000),
458
- credentials: 'include', // Include cookies for cross-origin requests
459
- });
460
- if (response.ok) {
461
- const { accessToken: newToken } = await response.json();
462
- this.tokenStore.setTokens(newToken);
463
- this.logger.debug('Token refreshed');
464
- return `Bearer ${newToken}`;
465
- }
457
+ const result = await this.tokenRefreshPromise;
458
+ if (result)
459
+ return result;
466
460
  }
467
- catch (refreshError) {
468
- this.logger.warn('Token refresh failed, using current token');
461
+ finally {
462
+ this.tokenRefreshPromise = null;
469
463
  }
470
464
  }
471
465
  return `Bearer ${accessToken}`;
@@ -475,6 +469,27 @@ class HttpService {
475
469
  return `Bearer ${accessToken}`;
476
470
  }
477
471
  }
472
+ async _refreshTokenFromSession(sessionId) {
473
+ try {
474
+ const refreshUrl = `${this.baseURL}/api/session/token/${sessionId}`;
475
+ const response = await fetch(refreshUrl, {
476
+ method: 'GET',
477
+ headers: { 'Accept': 'application/json' },
478
+ signal: AbortSignal.timeout(5000),
479
+ credentials: 'include',
480
+ });
481
+ if (response.ok) {
482
+ const { accessToken: newToken } = await response.json();
483
+ this.tokenStore.setTokens(newToken);
484
+ this.logger.debug('Token refreshed');
485
+ return `Bearer ${newToken}`;
486
+ }
487
+ }
488
+ catch (refreshError) {
489
+ this.logger.warn('Token refresh failed, using current token');
490
+ }
491
+ return null;
492
+ }
478
493
  /**
479
494
  * Unwrap standardized API response format
480
495
  */
package/dist/cjs/index.js CHANGED
@@ -29,8 +29,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
29
29
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
30
30
  };
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
- exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.initPlatformFromReactNative = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.KeyManager = exports.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
33
- exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.packageInfo = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = void 0;
32
+ exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.KeyManager = exports.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
33
+ exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.packageInfo = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = void 0;
34
34
  // Ensure crypto polyfills are loaded before anything else
35
35
  require("./crypto/polyfill");
36
36
  // --- Core API Client ---
@@ -74,7 +74,6 @@ Object.defineProperty(exports, "isWeb", { enumerable: true, get: function () { r
74
74
  Object.defineProperty(exports, "isNative", { enumerable: true, get: function () { return platform_1.isNative; } });
75
75
  Object.defineProperty(exports, "isIOS", { enumerable: true, get: function () { return platform_1.isIOS; } });
76
76
  Object.defineProperty(exports, "isAndroid", { enumerable: true, get: function () { return platform_1.isAndroid; } });
77
- Object.defineProperty(exports, "initPlatformFromReactNative", { enumerable: true, get: function () { return platform_1.initPlatformFromReactNative; } });
78
77
  // --- Shared Utilities ---
79
78
  var colorUtils_1 = require("./shared/utils/colorUtils");
80
79
  Object.defineProperty(exports, "darkenColor", { enumerable: true, get: function () { return colorUtils_1.darkenColor; } });
@@ -380,11 +380,15 @@ function OxyServicesFedCMMixin(Base) {
380
380
  * @private
381
381
  */
382
382
  generateNonce() {
383
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
384
- return window.crypto.randomUUID();
383
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
384
+ return crypto.randomUUID();
385
385
  }
386
- // Fallback for older browsers
387
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
386
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
387
+ const bytes = new Uint8Array(16);
388
+ crypto.getRandomValues(bytes);
389
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
390
+ }
391
+ throw new Error('No secure random source available for nonce generation');
388
392
  }
389
393
  /**
390
394
  * Get the client ID for this origin
@@ -323,10 +323,15 @@ function OxyServicesPopupAuthMixin(Base) {
323
323
  * @private
324
324
  */
325
325
  generateState() {
326
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
327
- return window.crypto.randomUUID();
326
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
327
+ return crypto.randomUUID();
328
328
  }
329
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
329
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
330
+ const bytes = new Uint8Array(16);
331
+ crypto.getRandomValues(bytes);
332
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
333
+ }
334
+ throw new Error('No secure random source available for state generation');
330
335
  }
331
336
  /**
332
337
  * Generate nonce for replay attack prevention
@@ -334,10 +339,15 @@ function OxyServicesPopupAuthMixin(Base) {
334
339
  * @private
335
340
  */
336
341
  generateNonce() {
337
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
338
- return window.crypto.randomUUID();
342
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
343
+ return crypto.randomUUID();
344
+ }
345
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
346
+ const bytes = new Uint8Array(16);
347
+ crypto.getRandomValues(bytes);
348
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
339
349
  }
340
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
350
+ throw new Error('No secure random source available for nonce generation');
341
351
  }
342
352
  /**
343
353
  * Store auth state in session storage
@@ -254,10 +254,15 @@ function OxyServicesRedirectAuthMixin(Base) {
254
254
  * @private
255
255
  */
256
256
  generateState() {
257
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
258
- return window.crypto.randomUUID();
257
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
258
+ return crypto.randomUUID();
259
259
  }
260
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
260
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
261
+ const bytes = new Uint8Array(16);
262
+ crypto.getRandomValues(bytes);
263
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
264
+ }
265
+ throw new Error('No secure random source available for state generation');
261
266
  }
262
267
  /**
263
268
  * Generate nonce for replay attack prevention
@@ -265,10 +270,15 @@ function OxyServicesRedirectAuthMixin(Base) {
265
270
  * @private
266
271
  */
267
272
  generateNonce() {
268
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
269
- return window.crypto.randomUUID();
273
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
274
+ return crypto.randomUUID();
275
+ }
276
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
277
+ const bytes = new Uint8Array(16);
278
+ crypto.getRandomValues(bytes);
279
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
270
280
  }
271
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
281
+ throw new Error('No secure random source available for nonce generation');
272
282
  }
273
283
  /**
274
284
  * Store auth state in session storage
@@ -171,16 +171,12 @@ class DeviceManager {
171
171
  * Generate a unique device ID
172
172
  */
173
173
  static generateDeviceId() {
174
- // Use crypto.getRandomValues if available, otherwise fallback to Math.random
175
174
  if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
176
175
  const array = new Uint8Array(32);
177
176
  crypto.getRandomValues(array);
178
177
  return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
179
178
  }
180
- else {
181
- // Fallback for environments without crypto.getRandomValues
182
- return 'device_' + Date.now().toString(36) + Math.random().toString(36).substr(2);
183
- }
179
+ throw new Error('No secure random source available for device ID generation');
184
180
  }
185
181
  /**
186
182
  * Get a user-friendly device name based on platform
@@ -6,39 +6,6 @@
6
6
  * This allows core modules to be used in web/Node.js environments
7
7
  * without bundlers failing on react-native imports.
8
8
  */
9
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- var desc = Object.getOwnPropertyDescriptor(m, k);
12
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
- desc = { enumerable: true, get: function() { return m[k]; } };
14
- }
15
- Object.defineProperty(o, k2, desc);
16
- }) : (function(o, m, k, k2) {
17
- if (k2 === undefined) k2 = k;
18
- o[k2] = m[k];
19
- }));
20
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
- Object.defineProperty(o, "default", { enumerable: true, value: v });
22
- }) : function(o, v) {
23
- o["default"] = v;
24
- });
25
- var __importStar = (this && this.__importStar) || (function () {
26
- var ownKeys = function(o) {
27
- ownKeys = Object.getOwnPropertyNames || function (o) {
28
- var ar = [];
29
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
- return ar;
31
- };
32
- return ownKeys(o);
33
- };
34
- return function (mod) {
35
- if (mod && mod.__esModule) return mod;
36
- var result = {};
37
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
- __setModuleDefault(result, mod);
39
- return result;
40
- };
41
- })();
42
9
  Object.defineProperty(exports, "__esModule", { value: true });
43
10
  exports.getPlatformOS = getPlatformOS;
44
11
  exports.isWeb = isWeb;
@@ -46,7 +13,6 @@ exports.isNative = isNative;
46
13
  exports.isIOS = isIOS;
47
14
  exports.isAndroid = isAndroid;
48
15
  exports.setPlatformOS = setPlatformOS;
49
- exports.initPlatformFromReactNative = initPlatformFromReactNative;
50
16
  /**
51
17
  * Detect the current platform without importing react-native
52
18
  *
@@ -125,21 +91,3 @@ function setPlatformOS(os) {
125
91
  cachedPlatform = os;
126
92
  globalThis.__REACT_NATIVE_PLATFORM__ = os;
127
93
  }
128
- /**
129
- * Try to initialize platform from react-native if available
130
- * This is called lazily when needed, avoiding top-level imports
131
- */
132
- async function initPlatformFromReactNative() {
133
- if (cachedPlatform !== null && cachedPlatform !== 'unknown') {
134
- return; // Already initialized
135
- }
136
- try {
137
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
138
- const moduleName = 'react-native';
139
- const { Platform } = await Promise.resolve(`${moduleName}`).then(s => __importStar(require(s)));
140
- setPlatformOS(Platform.OS);
141
- }
142
- catch {
143
- // react-native not available, use detected platform
144
- }
145
- }
@@ -99,6 +99,7 @@ export class AuthManager {
99
99
  this.listeners = new Set();
100
100
  this.currentUser = null;
101
101
  this.refreshTimer = null;
102
+ this.refreshPromise = null;
102
103
  this.oxyServices = oxyServices;
103
104
  this.config = {
104
105
  storage: config.storage ?? this.getDefaultStorage(),
@@ -199,9 +200,23 @@ export class AuthManager {
199
200
  }
200
201
  }
201
202
  /**
202
- * Refresh the access token.
203
+ * Refresh the access token. Deduplicates concurrent calls so only one
204
+ * refresh request is in-flight at a time.
203
205
  */
204
206
  async refreshToken() {
207
+ // If a refresh is already in-flight, return the same promise
208
+ if (this.refreshPromise) {
209
+ return this.refreshPromise;
210
+ }
211
+ this.refreshPromise = this._doRefreshToken();
212
+ try {
213
+ return await this.refreshPromise;
214
+ }
215
+ finally {
216
+ this.refreshPromise = null;
217
+ }
218
+ }
219
+ async _doRefreshToken() {
205
220
  const refreshToken = await this.storage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
206
221
  if (!refreshToken) {
207
222
  return false;
@@ -82,6 +82,7 @@ class TokenStore {
82
82
  */
83
83
  export class HttpService {
84
84
  constructor(config) {
85
+ this.tokenRefreshPromise = null;
85
86
  // Performance monitoring
86
87
  this.requestMetrics = {
87
88
  totalRequests: 0,
@@ -445,24 +446,17 @@ export class HttpService {
445
446
  const currentTime = Math.floor(Date.now() / 1000);
446
447
  // If token expires in less than 60 seconds, refresh it
447
448
  if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
449
+ // Deduplicate concurrent refresh attempts
450
+ if (!this.tokenRefreshPromise) {
451
+ this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId);
452
+ }
448
453
  try {
449
- const refreshUrl = `${this.baseURL}/api/session/token/${decoded.sessionId}`;
450
- // Use AbortSignal.timeout for consistent timeout handling
451
- const response = await fetch(refreshUrl, {
452
- method: 'GET',
453
- headers: { 'Accept': 'application/json' },
454
- signal: AbortSignal.timeout(5000),
455
- credentials: 'include', // Include cookies for cross-origin requests
456
- });
457
- if (response.ok) {
458
- const { accessToken: newToken } = await response.json();
459
- this.tokenStore.setTokens(newToken);
460
- this.logger.debug('Token refreshed');
461
- return `Bearer ${newToken}`;
462
- }
454
+ const result = await this.tokenRefreshPromise;
455
+ if (result)
456
+ return result;
463
457
  }
464
- catch (refreshError) {
465
- this.logger.warn('Token refresh failed, using current token');
458
+ finally {
459
+ this.tokenRefreshPromise = null;
466
460
  }
467
461
  }
468
462
  return `Bearer ${accessToken}`;
@@ -472,6 +466,27 @@ export class HttpService {
472
466
  return `Bearer ${accessToken}`;
473
467
  }
474
468
  }
469
+ async _refreshTokenFromSession(sessionId) {
470
+ try {
471
+ const refreshUrl = `${this.baseURL}/api/session/token/${sessionId}`;
472
+ const response = await fetch(refreshUrl, {
473
+ method: 'GET',
474
+ headers: { 'Accept': 'application/json' },
475
+ signal: AbortSignal.timeout(5000),
476
+ credentials: 'include',
477
+ });
478
+ if (response.ok) {
479
+ const { accessToken: newToken } = await response.json();
480
+ this.tokenStore.setTokens(newToken);
481
+ this.logger.debug('Token refreshed');
482
+ return `Bearer ${newToken}`;
483
+ }
484
+ }
485
+ catch (refreshError) {
486
+ this.logger.warn('Token refresh failed, using current token');
487
+ }
488
+ return null;
489
+ }
475
490
  /**
476
491
  * Unwrap standardized API response format
477
492
  */
package/dist/esm/index.js CHANGED
@@ -31,7 +31,7 @@ export { DeviceManager } from './utils/deviceManager';
31
31
  // --- Language Utilities ---
32
32
  export { SUPPORTED_LANGUAGES, getLanguageMetadata, getLanguageName, getNativeLanguageName, normalizeLanguageCode, } from './utils/languageUtils';
33
33
  // --- Platform Detection ---
34
- export { getPlatformOS, setPlatformOS, isWeb, isNative, isIOS, isAndroid, initPlatformFromReactNative, } from './utils/platform';
34
+ export { getPlatformOS, setPlatformOS, isWeb, isNative, isIOS, isAndroid, } from './utils/platform';
35
35
  // --- Shared Utilities ---
36
36
  export { darkenColor, lightenColor, hexToRgb, rgbToHex, withOpacity, isLightColor, getContrastTextColor, } from './shared/utils/colorUtils';
37
37
  export { normalizeTheme, normalizeColorScheme, getOppositeTheme, systemPrefersDarkMode, getSystemColorScheme, } from './shared/utils/themeUtils';
@@ -376,11 +376,15 @@ export function OxyServicesFedCMMixin(Base) {
376
376
  * @private
377
377
  */
378
378
  generateNonce() {
379
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
380
- return window.crypto.randomUUID();
379
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
380
+ return crypto.randomUUID();
381
381
  }
382
- // Fallback for older browsers
383
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
382
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
383
+ const bytes = new Uint8Array(16);
384
+ crypto.getRandomValues(bytes);
385
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
386
+ }
387
+ throw new Error('No secure random source available for nonce generation');
384
388
  }
385
389
  /**
386
390
  * Get the client ID for this origin
@@ -319,10 +319,15 @@ export function OxyServicesPopupAuthMixin(Base) {
319
319
  * @private
320
320
  */
321
321
  generateState() {
322
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
323
- return window.crypto.randomUUID();
322
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
323
+ return crypto.randomUUID();
324
324
  }
325
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
325
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
326
+ const bytes = new Uint8Array(16);
327
+ crypto.getRandomValues(bytes);
328
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
329
+ }
330
+ throw new Error('No secure random source available for state generation');
326
331
  }
327
332
  /**
328
333
  * Generate nonce for replay attack prevention
@@ -330,10 +335,15 @@ export function OxyServicesPopupAuthMixin(Base) {
330
335
  * @private
331
336
  */
332
337
  generateNonce() {
333
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
334
- return window.crypto.randomUUID();
338
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
339
+ return crypto.randomUUID();
340
+ }
341
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
342
+ const bytes = new Uint8Array(16);
343
+ crypto.getRandomValues(bytes);
344
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
335
345
  }
336
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
346
+ throw new Error('No secure random source available for nonce generation');
337
347
  }
338
348
  /**
339
349
  * Store auth state in session storage
@@ -250,10 +250,15 @@ export function OxyServicesRedirectAuthMixin(Base) {
250
250
  * @private
251
251
  */
252
252
  generateState() {
253
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
254
- return window.crypto.randomUUID();
253
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
254
+ return crypto.randomUUID();
255
255
  }
256
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
256
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
257
+ const bytes = new Uint8Array(16);
258
+ crypto.getRandomValues(bytes);
259
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
260
+ }
261
+ throw new Error('No secure random source available for state generation');
257
262
  }
258
263
  /**
259
264
  * Generate nonce for replay attack prevention
@@ -261,10 +266,15 @@ export function OxyServicesRedirectAuthMixin(Base) {
261
266
  * @private
262
267
  */
263
268
  generateNonce() {
264
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
265
- return window.crypto.randomUUID();
269
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
270
+ return crypto.randomUUID();
271
+ }
272
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
273
+ const bytes = new Uint8Array(16);
274
+ crypto.getRandomValues(bytes);
275
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
266
276
  }
267
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
277
+ throw new Error('No secure random source available for nonce generation');
268
278
  }
269
279
  /**
270
280
  * Store auth state in session storage
@@ -135,16 +135,12 @@ export class DeviceManager {
135
135
  * Generate a unique device ID
136
136
  */
137
137
  static generateDeviceId() {
138
- // Use crypto.getRandomValues if available, otherwise fallback to Math.random
139
138
  if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
140
139
  const array = new Uint8Array(32);
141
140
  crypto.getRandomValues(array);
142
141
  return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
143
142
  }
144
- else {
145
- // Fallback for environments without crypto.getRandomValues
146
- return 'device_' + Date.now().toString(36) + Math.random().toString(36).substr(2);
147
- }
143
+ throw new Error('No secure random source available for device ID generation');
148
144
  }
149
145
  /**
150
146
  * Get a user-friendly device name based on platform
@@ -83,21 +83,3 @@ export function setPlatformOS(os) {
83
83
  cachedPlatform = os;
84
84
  globalThis.__REACT_NATIVE_PLATFORM__ = os;
85
85
  }
86
- /**
87
- * Try to initialize platform from react-native if available
88
- * This is called lazily when needed, avoiding top-level imports
89
- */
90
- export async function initPlatformFromReactNative() {
91
- if (cachedPlatform !== null && cachedPlatform !== 'unknown') {
92
- return; // Already initialized
93
- }
94
- try {
95
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
96
- const moduleName = 'react-native';
97
- const { Platform } = await import(moduleName);
98
- setPlatformOS(Platform.OS);
99
- }
100
- catch {
101
- // react-native not available, use detected platform
102
- }
103
- }
@@ -66,6 +66,7 @@ export declare class AuthManager {
66
66
  private listeners;
67
67
  private currentUser;
68
68
  private refreshTimer;
69
+ private refreshPromise;
69
70
  private config;
70
71
  constructor(oxyServices: OxyServices, config?: AuthManagerConfig);
71
72
  /**
@@ -95,9 +96,11 @@ export declare class AuthManager {
95
96
  */
96
97
  private setupTokenRefresh;
97
98
  /**
98
- * Refresh the access token.
99
+ * Refresh the access token. Deduplicates concurrent calls so only one
100
+ * refresh request is in-flight at a time.
99
101
  */
100
102
  refreshToken(): Promise<boolean>;
103
+ private _doRefreshToken;
101
104
  /**
102
105
  * Sign out and clear all auth data.
103
106
  */
@@ -43,6 +43,7 @@ export declare class HttpService {
43
43
  private requestQueue;
44
44
  private logger;
45
45
  private config;
46
+ private tokenRefreshPromise;
46
47
  private requestMetrics;
47
48
  constructor(config: OxyConfig);
48
49
  /**
@@ -72,6 +73,7 @@ export declare class HttpService {
72
73
  * Get auth header with automatic token refresh
73
74
  */
74
75
  private getAuthHeader;
76
+ private _refreshTokenFromSession;
75
77
  /**
76
78
  * Unwrap standardized API response format
77
79
  */
@@ -31,7 +31,7 @@ export { DeviceManager } from './utils/deviceManager';
31
31
  export type { DeviceFingerprint, StoredDeviceInfo } from './utils/deviceManager';
32
32
  export { SUPPORTED_LANGUAGES, getLanguageMetadata, getLanguageName, getNativeLanguageName, normalizeLanguageCode, } from './utils/languageUtils';
33
33
  export type { LanguageMetadata } from './utils/languageUtils';
34
- export { getPlatformOS, setPlatformOS, isWeb, isNative, isIOS, isAndroid, initPlatformFromReactNative, } from './utils/platform';
34
+ export { getPlatformOS, setPlatformOS, isWeb, isNative, isIOS, isAndroid, } from './utils/platform';
35
35
  export type { PlatformOS } from './utils/platform';
36
36
  export { darkenColor, lightenColor, hexToRgb, rgbToHex, withOpacity, isLightColor, getContrastTextColor, } from './shared/utils/colorUtils';
37
37
  export { normalizeTheme, normalizeColorScheme, getOppositeTheme, systemPrefersDarkMode, getSystemColorScheme, } from './shared/utils/themeUtils';
@@ -33,8 +33,3 @@ export declare function isAndroid(): boolean;
33
33
  * This allows lazy detection in environments where react-native is available
34
34
  */
35
35
  export declare function setPlatformOS(os: PlatformOS): void;
36
- /**
37
- * Try to initialize platform from react-native if available
38
- * This is called lazily when needed, avoiding top-level imports
39
- */
40
- export declare function initPlatformFromReactNative(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -142,6 +142,7 @@ export class AuthManager {
142
142
  private listeners: Set<AuthStateChangeCallback> = new Set();
143
143
  private currentUser: MinimalUserData | null = null;
144
144
  private refreshTimer: ReturnType<typeof setTimeout> | null = null;
145
+ private refreshPromise: Promise<boolean> | null = null;
145
146
  private config: Required<AuthManagerConfig>;
146
147
 
147
148
  constructor(oxyServices: OxyServices, config: AuthManagerConfig = {}) {
@@ -261,9 +262,24 @@ export class AuthManager {
261
262
  }
262
263
 
263
264
  /**
264
- * Refresh the access token.
265
+ * Refresh the access token. Deduplicates concurrent calls so only one
266
+ * refresh request is in-flight at a time.
265
267
  */
266
268
  async refreshToken(): Promise<boolean> {
269
+ // If a refresh is already in-flight, return the same promise
270
+ if (this.refreshPromise) {
271
+ return this.refreshPromise;
272
+ }
273
+
274
+ this.refreshPromise = this._doRefreshToken();
275
+ try {
276
+ return await this.refreshPromise;
277
+ } finally {
278
+ this.refreshPromise = null;
279
+ }
280
+ }
281
+
282
+ private async _doRefreshToken(): Promise<boolean> {
267
283
  const refreshToken = await this.storage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
268
284
  if (!refreshToken) {
269
285
  return false;
@@ -131,6 +131,7 @@ export class HttpService {
131
131
  private requestQueue: RequestQueue;
132
132
  private logger: SimpleLogger;
133
133
  private config: OxyConfig;
134
+ private tokenRefreshPromise: Promise<string | null> | null = null;
134
135
 
135
136
  // Performance monitoring
136
137
  private requestMetrics = {
@@ -563,25 +564,15 @@ export class HttpService {
563
564
 
564
565
  // If token expires in less than 60 seconds, refresh it
565
566
  if (decoded.exp && decoded.exp - currentTime < 60 && decoded.sessionId) {
567
+ // Deduplicate concurrent refresh attempts
568
+ if (!this.tokenRefreshPromise) {
569
+ this.tokenRefreshPromise = this._refreshTokenFromSession(decoded.sessionId);
570
+ }
566
571
  try {
567
- const refreshUrl = `${this.baseURL}/api/session/token/${decoded.sessionId}`;
568
-
569
- // Use AbortSignal.timeout for consistent timeout handling
570
- const response = await fetch(refreshUrl, {
571
- method: 'GET',
572
- headers: { 'Accept': 'application/json' },
573
- signal: AbortSignal.timeout(5000),
574
- credentials: 'include', // Include cookies for cross-origin requests
575
- });
576
-
577
- if (response.ok) {
578
- const { accessToken: newToken } = await response.json();
579
- this.tokenStore.setTokens(newToken);
580
- this.logger.debug('Token refreshed');
581
- return `Bearer ${newToken}`;
582
- }
583
- } catch (refreshError) {
584
- this.logger.warn('Token refresh failed, using current token');
572
+ const result = await this.tokenRefreshPromise;
573
+ if (result) return result;
574
+ } finally {
575
+ this.tokenRefreshPromise = null;
585
576
  }
586
577
  }
587
578
 
@@ -592,6 +583,28 @@ export class HttpService {
592
583
  }
593
584
  }
594
585
 
586
+ private async _refreshTokenFromSession(sessionId: string): Promise<string | null> {
587
+ try {
588
+ const refreshUrl = `${this.baseURL}/api/session/token/${sessionId}`;
589
+ const response = await fetch(refreshUrl, {
590
+ method: 'GET',
591
+ headers: { 'Accept': 'application/json' },
592
+ signal: AbortSignal.timeout(5000),
593
+ credentials: 'include',
594
+ });
595
+
596
+ if (response.ok) {
597
+ const { accessToken: newToken } = await response.json();
598
+ this.tokenStore.setTokens(newToken);
599
+ this.logger.debug('Token refreshed');
600
+ return `Bearer ${newToken}`;
601
+ }
602
+ } catch (refreshError) {
603
+ this.logger.warn('Token refresh failed, using current token');
604
+ }
605
+ return null;
606
+ }
607
+
595
608
  /**
596
609
  * Unwrap standardized API response format
597
610
  */
package/src/index.ts CHANGED
@@ -61,7 +61,6 @@ export {
61
61
  isNative,
62
62
  isIOS,
63
63
  isAndroid,
64
- initPlatformFromReactNative,
65
64
  } from './utils/platform';
66
65
  export type { PlatformOS } from './utils/platform';
67
66
 
@@ -439,11 +439,15 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
439
439
  * @private
440
440
  */
441
441
  public generateNonce(): string {
442
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
443
- return window.crypto.randomUUID();
442
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
443
+ return crypto.randomUUID();
444
444
  }
445
- // Fallback for older browsers
446
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
445
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
446
+ const bytes = new Uint8Array(16);
447
+ crypto.getRandomValues(bytes);
448
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
449
+ }
450
+ throw new Error('No secure random source available for nonce generation');
447
451
  }
448
452
 
449
453
  /**
@@ -397,10 +397,15 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
397
397
  * @private
398
398
  */
399
399
  public generateState(): string {
400
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
401
- return window.crypto.randomUUID();
400
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
401
+ return crypto.randomUUID();
402
402
  }
403
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
403
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
404
+ const bytes = new Uint8Array(16);
405
+ crypto.getRandomValues(bytes);
406
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
407
+ }
408
+ throw new Error('No secure random source available for state generation');
404
409
  }
405
410
 
406
411
  /**
@@ -409,10 +414,15 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
409
414
  * @private
410
415
  */
411
416
  public generateNonce(): string {
412
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
413
- return window.crypto.randomUUID();
417
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
418
+ return crypto.randomUUID();
419
+ }
420
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
421
+ const bytes = new Uint8Array(16);
422
+ crypto.getRandomValues(bytes);
423
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
414
424
  }
415
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
425
+ throw new Error('No secure random source available for nonce generation');
416
426
  }
417
427
 
418
428
  /**
@@ -299,10 +299,15 @@ export function OxyServicesRedirectAuthMixin<T extends typeof OxyServicesBase>(B
299
299
  * @private
300
300
  */
301
301
  public generateState(): string {
302
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
303
- return window.crypto.randomUUID();
302
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
303
+ return crypto.randomUUID();
304
304
  }
305
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
305
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
306
+ const bytes = new Uint8Array(16);
307
+ crypto.getRandomValues(bytes);
308
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
309
+ }
310
+ throw new Error('No secure random source available for state generation');
306
311
  }
307
312
 
308
313
  /**
@@ -311,10 +316,15 @@ export function OxyServicesRedirectAuthMixin<T extends typeof OxyServicesBase>(B
311
316
  * @private
312
317
  */
313
318
  public generateNonce(): string {
314
- if (typeof window !== 'undefined' && window.crypto && window.crypto.randomUUID) {
315
- return window.crypto.randomUUID();
319
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
320
+ return crypto.randomUUID();
321
+ }
322
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
323
+ const bytes = new Uint8Array(16);
324
+ crypto.getRandomValues(bytes);
325
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
316
326
  }
317
- return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
327
+ throw new Error('No secure random source available for nonce generation');
318
328
  }
319
329
 
320
330
  /**
@@ -170,15 +170,12 @@ export class DeviceManager {
170
170
  * Generate a unique device ID
171
171
  */
172
172
  private static generateDeviceId(): string {
173
- // Use crypto.getRandomValues if available, otherwise fallback to Math.random
174
173
  if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
175
174
  const array = new Uint8Array(32);
176
175
  crypto.getRandomValues(array);
177
176
  return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
178
- } else {
179
- // Fallback for environments without crypto.getRandomValues
180
- return 'device_' + Date.now().toString(36) + Math.random().toString(36).substr(2);
181
177
  }
178
+ throw new Error('No secure random source available for device ID generation');
182
179
  }
183
180
 
184
181
  /**
@@ -98,21 +98,3 @@ export function setPlatformOS(os: PlatformOS): void {
98
98
  (globalThis as any).__REACT_NATIVE_PLATFORM__ = os;
99
99
  }
100
100
 
101
- /**
102
- * Try to initialize platform from react-native if available
103
- * This is called lazily when needed, avoiding top-level imports
104
- */
105
- export async function initPlatformFromReactNative(): Promise<void> {
106
- if (cachedPlatform !== null && cachedPlatform !== 'unknown') {
107
- return; // Already initialized
108
- }
109
-
110
- try {
111
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
112
- const moduleName = 'react-native';
113
- const { Platform } = await import(moduleName);
114
- setPlatformOS(Platform.OS as PlatformOS);
115
- } catch {
116
- // react-native not available, use detected platform
117
- }
118
- }