@oxyhq/core 1.11.8 → 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 +5 -7
- package/dist/cjs/mixins/OxyServices.popup.js +7 -7
- package/dist/cjs/mixins/OxyServices.redirect.js +1 -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 +5 -7
- package/dist/esm/mixins/OxyServices.popup.js +7 -7
- package/dist/esm/mixins/OxyServices.redirect.js +1 -5
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +21 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -3
- package/dist/types/mixins/OxyServices.popup.d.ts +2 -2
- package/dist/types/mixins/OxyServices.redirect.d.ts +0 -2
- 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 +6 -7
- package/src/mixins/OxyServices.popup.ts +8 -7
- package/src/mixins/OxyServices.redirect.ts +1 -6
|
@@ -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,8 +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
|
-
|
|
37
|
-
get fedcmConfigUrl(): string;
|
|
36
|
+
resolveFedcmConfigUrl(): string;
|
|
38
37
|
/**
|
|
39
38
|
* Instance method to check FedCM support
|
|
40
39
|
*/
|
|
@@ -204,7 +203,6 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
|
|
|
204
203
|
[key: string]: any;
|
|
205
204
|
}>;
|
|
206
205
|
};
|
|
207
|
-
readonly DEFAULT_AUTH_URL: "https://auth.oxy.so";
|
|
208
206
|
readonly DEFAULT_CONFIG_URL: "https://auth.oxy.so/fedcm.json";
|
|
209
207
|
readonly FEDCM_TIMEOUT: 15000;
|
|
210
208
|
readonly FEDCM_SILENT_TIMEOUT: 3000;
|
|
@@ -33,8 +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
|
-
/**
|
|
37
|
-
|
|
36
|
+
/** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
|
|
37
|
+
resolveAuthUrl(): string;
|
|
38
38
|
/**
|
|
39
39
|
* Sign in using popup window
|
|
40
40
|
*
|
|
@@ -31,8 +31,6 @@ export interface RedirectAuthOptions {
|
|
|
31
31
|
*/
|
|
32
32
|
export declare function OxyServicesRedirectAuthMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
33
33
|
new (...args: any[]): {
|
|
34
|
-
/** Resolved auth URL: config.authWebUrl takes precedence over the static default */
|
|
35
|
-
get authUrl(): string;
|
|
36
34
|
/**
|
|
37
35
|
* Sign in using full page redirect
|
|
38
36
|
*
|
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();
|
|
@@ -51,15 +51,14 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
51
51
|
constructor(...args: any[]) {
|
|
52
52
|
super(...(args as [any]));
|
|
53
53
|
}
|
|
54
|
-
public static readonly DEFAULT_AUTH_URL = 'https://auth.oxy.so';
|
|
55
54
|
public static readonly DEFAULT_CONFIG_URL = 'https://auth.oxy.so/fedcm.json';
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
public get fedcmConfigUrl(): string {
|
|
56
|
+
public resolveFedcmConfigUrl(): string {
|
|
59
57
|
return this.config.authWebUrl
|
|
60
58
|
? `${this.config.authWebUrl}/fedcm.json`
|
|
61
59
|
: (this.constructor as any).DEFAULT_CONFIG_URL;
|
|
62
60
|
}
|
|
61
|
+
|
|
63
62
|
public static readonly FEDCM_TIMEOUT = 15000; // 15 seconds for interactive
|
|
64
63
|
public static readonly FEDCM_SILENT_TIMEOUT = 3000; // 3 seconds for silent mediation
|
|
65
64
|
|
|
@@ -121,7 +120,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
121
120
|
// Request credential from browser's native identity flow
|
|
122
121
|
// mode: 'button' signals this is a user-gesture-initiated flow (Chrome 125+)
|
|
123
122
|
const credential = await this.requestIdentityCredential({
|
|
124
|
-
configURL: this.
|
|
123
|
+
configURL: this.resolveFedcmConfigUrl(),
|
|
125
124
|
clientId,
|
|
126
125
|
nonce,
|
|
127
126
|
context: options.context,
|
|
@@ -224,7 +223,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
224
223
|
debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
|
|
225
224
|
|
|
226
225
|
credential = await this.requestIdentityCredential({
|
|
227
|
-
configURL: this.
|
|
226
|
+
configURL: this.resolveFedcmConfigUrl(),
|
|
228
227
|
clientId,
|
|
229
228
|
nonce,
|
|
230
229
|
loginHint,
|
|
@@ -469,7 +468,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
469
468
|
if ('IdentityCredential' in window && 'disconnect' in (window as any).IdentityCredential) {
|
|
470
469
|
const clientId = this.getClientId();
|
|
471
470
|
await (window as any).IdentityCredential.disconnect({
|
|
472
|
-
configURL: this.
|
|
471
|
+
configURL: this.resolveFedcmConfigUrl(),
|
|
473
472
|
clientId,
|
|
474
473
|
accountHint: accountHint || '*',
|
|
475
474
|
});
|
|
@@ -488,7 +487,7 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
488
487
|
getFedCMConfig(): FedCMConfig {
|
|
489
488
|
return {
|
|
490
489
|
enabled: this.isFedCMSupported(),
|
|
491
|
-
configURL: this.
|
|
490
|
+
configURL: this.resolveFedcmConfigUrl(),
|
|
492
491
|
clientId: this.getClientId(),
|
|
493
492
|
};
|
|
494
493
|
}
|
|
@@ -45,10 +45,11 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
45
45
|
}
|
|
46
46
|
public static readonly DEFAULT_AUTH_URL = 'https://auth.oxy.so';
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
public
|
|
48
|
+
/** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
|
|
49
|
+
public resolveAuthUrl(): string {
|
|
50
50
|
return this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL;
|
|
51
51
|
}
|
|
52
|
+
|
|
52
53
|
public static readonly POPUP_WIDTH = 500;
|
|
53
54
|
public static readonly POPUP_HEIGHT = 700;
|
|
54
55
|
public static readonly POPUP_TIMEOUT = 60000; // 1 minute
|
|
@@ -100,7 +101,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
100
101
|
state,
|
|
101
102
|
nonce,
|
|
102
103
|
clientId: window.location.origin,
|
|
103
|
-
redirectUri: `${this.
|
|
104
|
+
redirectUri: `${this.resolveAuthUrl()}/auth/callback`,
|
|
104
105
|
});
|
|
105
106
|
|
|
106
107
|
const popup = this.openCenteredPopup(authUrl, 'Oxy Sign In', width, height);
|
|
@@ -203,7 +204,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
203
204
|
iframe.style.height = '0';
|
|
204
205
|
iframe.style.border = 'none';
|
|
205
206
|
|
|
206
|
-
const silentUrl = `${this.
|
|
207
|
+
const silentUrl = `${this.resolveAuthUrl()}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
|
|
207
208
|
|
|
208
209
|
iframe.src = silentUrl;
|
|
209
210
|
document.body.appendChild(iframe);
|
|
@@ -265,7 +266,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
265
266
|
}, timeout);
|
|
266
267
|
|
|
267
268
|
const messageHandler = (event: MessageEvent) => {
|
|
268
|
-
const authUrl = this.
|
|
269
|
+
const authUrl = this.resolveAuthUrl();
|
|
269
270
|
|
|
270
271
|
// Log all messages for debugging
|
|
271
272
|
if (event.data && typeof event.data === 'object' && event.data.type) {
|
|
@@ -352,7 +353,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
352
353
|
|
|
353
354
|
const messageHandler = (event: MessageEvent) => {
|
|
354
355
|
// Verify origin
|
|
355
|
-
if (event.origin !== this.
|
|
356
|
+
if (event.origin !== this.resolveAuthUrl()) {
|
|
356
357
|
return;
|
|
357
358
|
}
|
|
358
359
|
|
|
@@ -387,7 +388,7 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
387
388
|
clientId: string;
|
|
388
389
|
redirectUri: string;
|
|
389
390
|
}): string {
|
|
390
|
-
const url = new URL(`${this.
|
|
391
|
+
const url = new URL(`${this.resolveAuthUrl()}/${params.mode}`);
|
|
391
392
|
url.searchParams.set('response_type', 'token');
|
|
392
393
|
url.searchParams.set('client_id', params.clientId);
|
|
393
394
|
url.searchParams.set('redirect_uri', params.redirectUri);
|
|
@@ -38,11 +38,6 @@ export function OxyServicesRedirectAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
38
38
|
super(...(args as [any]));
|
|
39
39
|
}
|
|
40
40
|
public static readonly DEFAULT_AUTH_URL = 'https://auth.oxy.so';
|
|
41
|
-
|
|
42
|
-
/** Resolved auth URL: config.authWebUrl takes precedence over the static default */
|
|
43
|
-
public get authUrl(): string {
|
|
44
|
-
return this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL;
|
|
45
|
-
}
|
|
46
41
|
public static readonly TOKEN_STORAGE_KEY = 'oxy_access_token';
|
|
47
42
|
public static readonly SESSION_STORAGE_KEY = 'oxy_session_id';
|
|
48
43
|
public static readonly STATE_STORAGE_KEY = 'oxy_auth_state';
|
|
@@ -275,7 +270,7 @@ export function OxyServicesRedirectAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
275
270
|
nonce: string;
|
|
276
271
|
clientId: string;
|
|
277
272
|
}): string {
|
|
278
|
-
const url = new URL(`${this.
|
|
273
|
+
const url = new URL(`${(this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL)}/${params.mode}`);
|
|
279
274
|
url.searchParams.set('redirect_uri', params.redirectUri);
|
|
280
275
|
url.searchParams.set('state', params.state);
|
|
281
276
|
url.searchParams.set('nonce', params.nonce);
|