@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +158 -1
- package/dist/cjs/crypto/keyManager.js +4 -6
- package/dist/cjs/crypto/polyfill.js +56 -12
- package/dist/cjs/crypto/signatureService.js +7 -4
- package/dist/cjs/mixins/OxyServices.fedcm.js +9 -4
- package/dist/cjs/mixins/OxyServices.popup.js +9 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +158 -1
- package/dist/esm/crypto/keyManager.js +4 -6
- package/dist/esm/crypto/polyfill.js +23 -12
- package/dist/esm/crypto/signatureService.js +7 -4
- package/dist/esm/mixins/OxyServices.fedcm.js +9 -4
- package/dist/esm/mixins/OxyServices.popup.js +9 -5
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +21 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +2 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +186 -4
- package/src/crypto/keyManager.ts +4 -6
- package/src/crypto/polyfill.ts +23 -12
- package/src/crypto/signatureService.ts +7 -4
- package/src/mixins/OxyServices.fedcm.ts +11 -4
- package/src/mixins/OxyServices.popup.ts +11 -5
|
@@ -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,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
package/src/AuthManager.ts
CHANGED
|
@@ -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,
|
|
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
|
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
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();
|
package/src/crypto/polyfill.ts
CHANGED
|
@@ -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
|
|
34
|
+
let expoCryptoLoadPromise: Promise<void> | null = null;
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
92
|
-
const
|
|
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
|
-
//
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: `${
|
|
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 = `${
|
|
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 =
|
|
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 !==
|
|
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(`${
|
|
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);
|