@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.
@@ -34,6 +34,8 @@ export interface AuthManagerConfig {
34
34
  autoRefresh?: boolean;
35
35
  /** Token refresh interval in milliseconds (default: 5 minutes before expiry) */
36
36
  refreshBuffer?: number;
37
+ /** Enable cross-tab coordination via BroadcastChannel (default: true in browsers) */
38
+ crossTabSync?: boolean;
37
39
  }
38
40
  /**
39
41
  * AuthManager - Centralized authentication management.
@@ -68,7 +70,26 @@ export declare class AuthManager {
68
70
  private refreshTimer;
69
71
  private refreshPromise;
70
72
  private config;
73
+ /** Tracks the access token this instance last knew about, for cross-tab adoption. */
74
+ private _lastKnownAccessToken;
75
+ /** BroadcastChannel for coordinating token refreshes across browser tabs. */
76
+ private _broadcastChannel;
77
+ /** Set to true when another tab broadcasts a successful refresh, so this tab can skip its own. */
78
+ private _otherTabRefreshed;
71
79
  constructor(oxyServices: OxyServices, config?: AuthManagerConfig);
80
+ /**
81
+ * Initialize BroadcastChannel for cross-tab token refresh coordination.
82
+ * Only called in browser environments where BroadcastChannel is available.
83
+ */
84
+ private _initBroadcastChannel;
85
+ /**
86
+ * Handle messages from other tabs about token refresh activity.
87
+ */
88
+ private _handleCrossTabMessage;
89
+ /**
90
+ * Broadcast a message to other tabs.
91
+ */
92
+ private _broadcast;
72
93
  /**
73
94
  * Get default storage based on environment.
74
95
  */
@@ -33,6 +33,7 @@ export interface FedCMConfig {
33
33
  */
34
34
  export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T): {
35
35
  new (...args: any[]): {
36
+ resolveFedcmConfigUrl(): string;
36
37
  /**
37
38
  * Instance method to check FedCM support
38
39
  */
@@ -33,6 +33,8 @@ export interface SilentAuthOptions {
33
33
  */
34
34
  export declare function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base: T): {
35
35
  new (...args: any[]): {
36
+ /** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
37
+ resolveAuthUrl(): string;
36
38
  /**
37
39
  * Sign in using popup window
38
40
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.9",
3
+ "version": "1.11.10",
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",
@@ -48,6 +48,17 @@ export interface AuthManagerConfig {
48
48
  autoRefresh?: boolean;
49
49
  /** Token refresh interval in milliseconds (default: 5 minutes before expiry) */
50
50
  refreshBuffer?: number;
51
+ /** Enable cross-tab coordination via BroadcastChannel (default: true in browsers) */
52
+ crossTabSync?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Messages sent between tabs via BroadcastChannel for token refresh coordination.
57
+ */
58
+ interface CrossTabMessage {
59
+ type: 'refresh_starting' | 'tokens_refreshed' | 'signed_out';
60
+ sessionId?: string;
61
+ timestamp: number;
51
62
  }
52
63
 
53
64
  /**
@@ -145,21 +156,117 @@ export class AuthManager {
145
156
  private currentUser: MinimalUserData | null = null;
146
157
  private refreshTimer: ReturnType<typeof setTimeout> | null = null;
147
158
  private refreshPromise: Promise<boolean> | null = null;
148
- private config: Required<AuthManagerConfig>;
159
+ private config: Required<Omit<AuthManagerConfig, 'crossTabSync'>> & { crossTabSync: boolean };
160
+
161
+ /** Tracks the access token this instance last knew about, for cross-tab adoption. */
162
+ private _lastKnownAccessToken: string | null = null;
163
+
164
+ /** BroadcastChannel for coordinating token refreshes across browser tabs. */
165
+ private _broadcastChannel: BroadcastChannel | null = null;
166
+
167
+ /** Set to true when another tab broadcasts a successful refresh, so this tab can skip its own. */
168
+ private _otherTabRefreshed = false;
149
169
 
150
170
  constructor(oxyServices: OxyServices, config: AuthManagerConfig = {}) {
151
171
  this.oxyServices = oxyServices;
172
+ const crossTabSync = config.crossTabSync ?? (typeof BroadcastChannel !== 'undefined');
152
173
  this.config = {
153
174
  storage: config.storage ?? this.getDefaultStorage(),
154
175
  autoRefresh: config.autoRefresh ?? true,
155
176
  refreshBuffer: config.refreshBuffer ?? 5 * 60 * 1000, // 5 minutes
177
+ crossTabSync,
156
178
  };
157
179
  this.storage = this.config.storage;
158
180
 
159
181
  // Persist tokens to storage when HttpService refreshes them automatically
160
182
  this.oxyServices.httpService.onTokenRefreshed = (accessToken: string) => {
183
+ this._lastKnownAccessToken = accessToken;
161
184
  this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
162
185
  };
186
+
187
+ // Setup cross-tab coordination in browser environments
188
+ if (this.config.crossTabSync) {
189
+ this._initBroadcastChannel();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Initialize BroadcastChannel for cross-tab token refresh coordination.
195
+ * Only called in browser environments where BroadcastChannel is available.
196
+ */
197
+ private _initBroadcastChannel(): void {
198
+ if (typeof BroadcastChannel === 'undefined') return;
199
+
200
+ try {
201
+ this._broadcastChannel = new BroadcastChannel('oxy_auth_sync');
202
+ this._broadcastChannel.onmessage = (event: MessageEvent<CrossTabMessage>) => {
203
+ this._handleCrossTabMessage(event.data);
204
+ };
205
+ } catch {
206
+ // BroadcastChannel not supported or blocked (e.g., opaque origins)
207
+ this._broadcastChannel = null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Handle messages from other tabs about token refresh activity.
213
+ */
214
+ private async _handleCrossTabMessage(message: CrossTabMessage): Promise<void> {
215
+ if (!message || !message.type) return;
216
+
217
+ switch (message.type) {
218
+ case 'tokens_refreshed': {
219
+ // Another tab successfully refreshed. Signal to cancel our pending refresh.
220
+ this._otherTabRefreshed = true;
221
+
222
+ // Adopt the new tokens from shared storage
223
+ const newToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
224
+ if (newToken && newToken !== this._lastKnownAccessToken) {
225
+ this._lastKnownAccessToken = newToken;
226
+ this.oxyServices.httpService.setTokens(newToken);
227
+
228
+ // Re-read session for updated expiry and schedule next refresh
229
+ const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
230
+ if (sessionJson) {
231
+ try {
232
+ const session = JSON.parse(sessionJson);
233
+ if (session.expiresAt && this.config.autoRefresh) {
234
+ this.setupTokenRefresh(session.expiresAt);
235
+ }
236
+ } catch {
237
+ // Ignore parse errors
238
+ }
239
+ }
240
+ }
241
+ break;
242
+ }
243
+
244
+ case 'signed_out': {
245
+ // Another tab signed out. Clear our local state to stay consistent.
246
+ if (this.refreshTimer) {
247
+ clearTimeout(this.refreshTimer);
248
+ this.refreshTimer = null;
249
+ }
250
+ this.refreshPromise = null;
251
+ this._lastKnownAccessToken = null;
252
+ this.oxyServices.httpService.setTokens('');
253
+ this.currentUser = null;
254
+ this.notifyListeners();
255
+ break;
256
+ }
257
+ // 'refresh_starting' is informational; we don't need to act on it currently
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Broadcast a message to other tabs.
263
+ */
264
+ private _broadcast(message: CrossTabMessage): void {
265
+ try {
266
+ this._broadcastChannel?.postMessage(message);
267
+ } catch {
268
+ // Channel closed or unavailable
269
+ }
163
270
  }
164
271
 
165
272
  /**
@@ -212,6 +319,7 @@ export class AuthManager {
212
319
  ): Promise<void> {
213
320
  // Store tokens
214
321
  if (session.accessToken) {
322
+ this._lastKnownAccessToken = session.accessToken;
215
323
  await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, session.accessToken);
216
324
  this.oxyServices.httpService.setTokens(session.accessToken);
217
325
  }
@@ -287,6 +395,9 @@ export class AuthManager {
287
395
  }
288
396
 
289
397
  private async _doRefreshToken(): Promise<boolean> {
398
+ // Reset the cross-tab flag before starting
399
+ this._otherTabRefreshed = false;
400
+
290
401
  // Get session info to find sessionId for token refresh
291
402
  const sessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
292
403
  if (!sessionJson) {
@@ -298,14 +409,31 @@ export class AuthManager {
298
409
  const session = JSON.parse(sessionJson);
299
410
  sessionId = session.sessionId;
300
411
  if (!sessionId) return false;
301
- } catch (err) {
412
+ } catch (err) {
302
413
  console.error('AuthManager: Failed to parse session from storage.', err);
303
414
  return false;
304
415
  }
305
416
 
417
+ // Record the token we know about before attempting refresh
418
+ const tokenBeforeRefresh = this._lastKnownAccessToken;
419
+
420
+ // Broadcast that we're starting a refresh (informational for other tabs)
421
+ this._broadcast({ type: 'refresh_starting', sessionId, timestamp: Date.now() });
422
+
306
423
  try {
307
424
  await retryAsync(
308
425
  async () => {
426
+ // Before each attempt, check if another tab already refreshed
427
+ if (this._otherTabRefreshed) {
428
+ const adoptedToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
429
+ if (adoptedToken && adoptedToken !== tokenBeforeRefresh) {
430
+ // Another tab succeeded. Adopt its tokens and short-circuit.
431
+ this._lastKnownAccessToken = adoptedToken;
432
+ this.oxyServices.httpService.setTokens(adoptedToken);
433
+ return;
434
+ }
435
+ }
436
+
309
437
  const httpService = this.oxyServices.httpService as HttpService;
310
438
  // Use session-based token endpoint which handles auto-refresh server-side
311
439
  const response = await httpService.request<{ accessToken: string; expiresAt: string }>({
@@ -320,6 +448,7 @@ export class AuthManager {
320
448
  }
321
449
 
322
450
  // Update access token in storage and HTTP client
451
+ this._lastKnownAccessToken = response.accessToken;
323
452
  await this.storage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
324
453
  this.oxyServices.httpService.setTokens(response.accessToken);
325
454
 
@@ -329,7 +458,7 @@ export class AuthManager {
329
458
  const session = JSON.parse(sessionJson);
330
459
  session.expiresAt = response.expiresAt;
331
460
  await this.storage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(session));
332
- } catch (err) {
461
+ } catch (err) {
333
462
  // Ignore parse errors for session update, but log for debugging.
334
463
  console.error('AuthManager: Failed to re-save session after token refresh.', err);
335
464
  }
@@ -338,6 +467,9 @@ export class AuthManager {
338
467
  this.setupTokenRefresh(response.expiresAt);
339
468
  }
340
469
  }
470
+
471
+ // Broadcast success so other tabs can adopt these tokens
472
+ this._broadcast({ type: 'tokens_refreshed', sessionId, timestamp: Date.now() });
341
473
  },
342
474
  2, // 2 retries = 3 total attempts
343
475
  1000, // 1s base delay with exponential backoff + jitter
@@ -350,7 +482,42 @@ export class AuthManager {
350
482
  );
351
483
  return true;
352
484
  } catch {
353
- // All retry attempts exhausted, clear session
485
+ // All retry attempts exhausted. Before clearing the session, check if
486
+ // another tab managed to refresh successfully while we were retrying.
487
+ // Since all tabs share the same storage (localStorage), a successful
488
+ // refresh from another tab will have written a different access token.
489
+ const currentStoredToken = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
490
+ if (currentStoredToken && currentStoredToken !== tokenBeforeRefresh) {
491
+ // Another tab refreshed successfully. Adopt its tokens instead of logging out.
492
+ this._lastKnownAccessToken = currentStoredToken;
493
+ this.oxyServices.httpService.setTokens(currentStoredToken);
494
+
495
+ // Restore user from storage in case it was updated
496
+ const userJson = await this.storage.getItem(STORAGE_KEYS.USER);
497
+ if (userJson) {
498
+ try {
499
+ this.currentUser = JSON.parse(userJson);
500
+ } catch {
501
+ // Ignore parse errors
502
+ }
503
+ }
504
+
505
+ // Re-read session expiry and schedule next refresh
506
+ const updatedSessionJson = await this.storage.getItem(STORAGE_KEYS.SESSION);
507
+ if (updatedSessionJson) {
508
+ try {
509
+ const session = JSON.parse(updatedSessionJson);
510
+ if (session.expiresAt && this.config.autoRefresh) {
511
+ this.setupTokenRefresh(session.expiresAt);
512
+ }
513
+ } catch {
514
+ // Ignore parse errors
515
+ }
516
+ }
517
+ return true;
518
+ }
519
+
520
+ // No other tab rescued us -- truly clear the session
354
521
  await this.clearSession();
355
522
  this.currentUser = null;
356
523
  this.notifyListeners();
@@ -394,10 +561,14 @@ export class AuthManager {
394
561
 
395
562
  // Clear HTTP client tokens
396
563
  this.oxyServices.httpService.setTokens('');
564
+ this._lastKnownAccessToken = null;
397
565
 
398
566
  // Clear storage
399
567
  await this.clearSession();
400
568
 
569
+ // Notify other tabs so they also sign out
570
+ this._broadcast({ type: 'signed_out', timestamp: Date.now() });
571
+
401
572
  // Update state and notify
402
573
  this.currentUser = null;
403
574
  this.notifyListeners();
@@ -479,6 +650,7 @@ export class AuthManager {
479
650
  // Restore token to HTTP client
480
651
  const token = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
481
652
  if (token) {
653
+ this._lastKnownAccessToken = token;
482
654
  this.oxyServices.httpService.setTokens(token);
483
655
  }
484
656
 
@@ -520,6 +692,16 @@ export class AuthManager {
520
692
  this.refreshTimer = null;
521
693
  }
522
694
  this.listeners.clear();
695
+
696
+ // Close BroadcastChannel
697
+ if (this._broadcastChannel) {
698
+ try {
699
+ this._broadcastChannel.close();
700
+ } catch {
701
+ // Ignore close errors
702
+ }
703
+ this._broadcastChannel = null;
704
+ }
523
705
  }
524
706
  }
525
707
 
@@ -102,13 +102,11 @@ async function getSecureRandomBytes(length: number): Promise<Uint8Array> {
102
102
  }
103
103
 
104
104
  // In Node.js, use Node's crypto module
105
- // Use Function constructor to prevent Metro bundler from statically analyzing this require
106
- // This ensures the require is only evaluated in Node.js runtime, not during Metro bundling
105
+ // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
107
106
  try {
108
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
109
- const getCrypto = new Function('return require("crypto")');
110
- const crypto = getCrypto();
111
- return new Uint8Array(crypto.randomBytes(length));
107
+ const cryptoModuleName = 'crypto';
108
+ const nodeCrypto = await import(cryptoModuleName);
109
+ return new Uint8Array(nodeCrypto.randomBytes(length));
112
110
  } catch (error) {
113
111
  // Fallback to expo-crypto if Node crypto fails
114
112
  const Crypto = await initExpoCrypto();
@@ -31,28 +31,36 @@ type CryptoLike = {
31
31
 
32
32
  // Cache for expo-crypto module (lazy loaded only in React Native)
33
33
  let expoCryptoModule: { getRandomBytes: (count: number) => Uint8Array } | null = null;
34
- let expoCryptoLoadAttempted = false;
34
+ let expoCryptoLoadPromise: Promise<void> | null = null;
35
35
 
36
- function getRandomBytesSync(byteCount: number): Uint8Array {
37
- if (!expoCryptoLoadAttempted) {
38
- expoCryptoLoadAttempted = true;
36
+ /**
37
+ * Eagerly start loading expo-crypto. The module is cached once resolved so
38
+ * the synchronous getRandomValues shim can read from it immediately.
39
+ * Uses dynamic import with variable indirection to prevent ESM bundlers
40
+ * (Vite, webpack) from statically resolving the specifier.
41
+ */
42
+ function startExpoCryptoLoad(): void {
43
+ if (expoCryptoLoadPromise) return;
44
+ expoCryptoLoadPromise = (async () => {
39
45
  try {
40
- // Only use require() in CJS environments (Metro/Node). In ESM (Vite/browser),
41
- // crypto.getRandomValues exists natively so this code path is never reached.
42
- if (typeof require !== 'undefined') {
43
- const moduleName = 'expo-crypto';
44
- expoCryptoModule = require(moduleName);
45
- }
46
+ const moduleName = 'expo-crypto';
47
+ expoCryptoModule = await import(moduleName);
46
48
  } catch {
47
49
  // expo-crypto not available — expected in non-RN environments
48
50
  }
49
- }
51
+ })();
52
+ }
53
+
54
+ function getRandomBytesSync(byteCount: number): Uint8Array {
55
+ // Kick off loading if not already started (should have been started at module init)
56
+ startExpoCryptoLoad();
50
57
  if (expoCryptoModule) {
51
58
  return expoCryptoModule.getRandomBytes(byteCount);
52
59
  }
53
60
  throw new Error(
54
61
  'No crypto.getRandomValues implementation available. ' +
55
- 'In React Native, install expo-crypto.'
62
+ 'In React Native, install expo-crypto. ' +
63
+ 'If expo-crypto is installed, ensure the polyfill module is imported early enough for the async load to complete.'
56
64
  );
57
65
  }
58
66
 
@@ -67,8 +75,11 @@ const cryptoPolyfill: CryptoLike = {
67
75
 
68
76
  // Only polyfill if crypto or crypto.getRandomValues is not available
69
77
  if (typeof globalObject.crypto === 'undefined') {
78
+ // Start loading expo-crypto eagerly so it is ready by the time getRandomValues is called
79
+ startExpoCryptoLoad();
70
80
  (globalObject as unknown as { crypto: CryptoLike }).crypto = cryptoPolyfill;
71
81
  } else if (typeof globalObject.crypto.getRandomValues !== 'function') {
82
+ startExpoCryptoLoad();
72
83
  (globalObject.crypto as CryptoLike).getRandomValues = cryptoPolyfill.getRandomValues;
73
84
  }
74
85
 
@@ -86,11 +86,11 @@ export class SignatureService {
86
86
  }
87
87
 
88
88
  // In Node.js, use Node's crypto module
89
+ // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
89
90
  if (isNodeJS()) {
90
91
  try {
91
- // eslint-disable-next-line @typescript-eslint/no-implied-eval
92
- const getCrypto = new Function('return require("crypto")');
93
- const nodeCrypto = getCrypto();
92
+ const cryptoModuleName = 'crypto';
93
+ const nodeCrypto = await import(cryptoModuleName);
94
94
  return nodeCrypto.randomBytes(32).toString('hex');
95
95
  } catch {
96
96
  // Fall through to Web Crypto API
@@ -162,7 +162,10 @@ export class SignatureService {
162
162
  // In React Native, use async verify instead
163
163
  throw new Error('verifySync should only be used in Node.js. Use verify() in React Native.');
164
164
  }
165
- // Use Function constructor to prevent Metro bundler from statically analyzing this require
165
+ // Intentionally using Function constructor here: this method is synchronous by design
166
+ // (Node.js backend hot-path) so we cannot use `await import()`. The Function constructor
167
+ // prevents Metro/bundlers from statically resolving the require. This is acceptable because
168
+ // verifySync is gated by isNodeJS() and will never execute in browser/RN environments.
166
169
  // eslint-disable-next-line @typescript-eslint/no-implied-eval
167
170
  const getCrypto = new Function('return require("crypto")');
168
171
  const crypto = getCrypto();
@@ -52,6 +52,13 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
52
52
  super(...(args as [any]));
53
53
  }
54
54
  public static readonly DEFAULT_CONFIG_URL = 'https://auth.oxy.so/fedcm.json';
55
+
56
+ public resolveFedcmConfigUrl(): string {
57
+ return this.config.authWebUrl
58
+ ? `${this.config.authWebUrl}/fedcm.json`
59
+ : (this.constructor as any).DEFAULT_CONFIG_URL;
60
+ }
61
+
55
62
  public static readonly FEDCM_TIMEOUT = 15000; // 15 seconds for interactive
56
63
  public static readonly FEDCM_SILENT_TIMEOUT = 3000; // 3 seconds for silent mediation
57
64
 
@@ -113,7 +120,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
113
120
  // Request credential from browser's native identity flow
114
121
  // mode: 'button' signals this is a user-gesture-initiated flow (Chrome 125+)
115
122
  const credential = await this.requestIdentityCredential({
116
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : (this.constructor as any).DEFAULT_CONFIG_URL),
123
+ configURL: this.resolveFedcmConfigUrl(),
117
124
  clientId,
118
125
  nonce,
119
126
  context: options.context,
@@ -216,7 +223,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
216
223
  debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
217
224
 
218
225
  credential = await this.requestIdentityCredential({
219
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : (this.constructor as any).DEFAULT_CONFIG_URL),
226
+ configURL: this.resolveFedcmConfigUrl(),
220
227
  clientId,
221
228
  nonce,
222
229
  loginHint,
@@ -461,7 +468,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
461
468
  if ('IdentityCredential' in window && 'disconnect' in (window as any).IdentityCredential) {
462
469
  const clientId = this.getClientId();
463
470
  await (window as any).IdentityCredential.disconnect({
464
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : (this.constructor as any).DEFAULT_CONFIG_URL),
471
+ configURL: this.resolveFedcmConfigUrl(),
465
472
  clientId,
466
473
  accountHint: accountHint || '*',
467
474
  });
@@ -480,7 +487,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
480
487
  getFedCMConfig(): FedCMConfig {
481
488
  return {
482
489
  enabled: this.isFedCMSupported(),
483
- configURL: (this.config.authWebUrl ? `${this.config.authWebUrl}/fedcm.json` : (this.constructor as any).DEFAULT_CONFIG_URL),
490
+ configURL: this.resolveFedcmConfigUrl(),
484
491
  clientId: this.getClientId(),
485
492
  };
486
493
  }
@@ -44,6 +44,12 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
44
44
  super(...(args as [any]));
45
45
  }
46
46
  public static readonly DEFAULT_AUTH_URL = 'https://auth.oxy.so';
47
+
48
+ /** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
49
+ public resolveAuthUrl(): string {
50
+ return this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL;
51
+ }
52
+
47
53
  public static readonly POPUP_WIDTH = 500;
48
54
  public static readonly POPUP_HEIGHT = 700;
49
55
  public static readonly POPUP_TIMEOUT = 60000; // 1 minute
@@ -95,7 +101,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
95
101
  state,
96
102
  nonce,
97
103
  clientId: window.location.origin,
98
- redirectUri: `${(this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL)}/auth/callback`,
104
+ redirectUri: `${this.resolveAuthUrl()}/auth/callback`,
99
105
  });
100
106
 
101
107
  const popup = this.openCenteredPopup(authUrl, 'Oxy Sign In', width, height);
@@ -198,7 +204,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
198
204
  iframe.style.height = '0';
199
205
  iframe.style.border = 'none';
200
206
 
201
- const silentUrl = `${(this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL)}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
207
+ const silentUrl = `${this.resolveAuthUrl()}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
202
208
 
203
209
  iframe.src = silentUrl;
204
210
  document.body.appendChild(iframe);
@@ -260,7 +266,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
260
266
  }, timeout);
261
267
 
262
268
  const messageHandler = (event: MessageEvent) => {
263
- const authUrl = (this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL);
269
+ const authUrl = this.resolveAuthUrl();
264
270
 
265
271
  // Log all messages for debugging
266
272
  if (event.data && typeof event.data === 'object' && event.data.type) {
@@ -347,7 +353,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
347
353
 
348
354
  const messageHandler = (event: MessageEvent) => {
349
355
  // Verify origin
350
- if (event.origin !== (this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL)) {
356
+ if (event.origin !== this.resolveAuthUrl()) {
351
357
  return;
352
358
  }
353
359
 
@@ -382,7 +388,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
382
388
  clientId: string;
383
389
  redirectUri: string;
384
390
  }): string {
385
- const url = new URL(`${(this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL)}/${params.mode}`);
391
+ const url = new URL(`${this.resolveAuthUrl()}/${params.mode}`);
386
392
  url.searchParams.set('response_type', 'token');
387
393
  url.searchParams.set('client_id', params.clientId);
388
394
  url.searchParams.set('redirect_uri', params.redirectUri);