forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth_service.dart
More file actions
963 lines (828 loc) · 35.4 KB
/
Copy pathauth_service.dart
File metadata and controls
963 lines (828 loc) · 35.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:app_links/app_links.dart';
import 'package:crypto/crypto.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:http/http.dart' as http;
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:omi/backend/http/api/users.dart';
import 'package:omi/backend/preferences.dart';
import 'package:omi/env/env.dart';
import 'package:omi/flavors.dart';
import 'package:omi/services/auth/auth_token_result.dart';
import 'package:omi/utils/logger.dart';
import 'package:omi/utils/platform/platform_manager.dart';
final class _FirebaseAuthTokenGateway implements AuthTokenGateway {
@override
AuthUserSnapshot? get currentUser {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return null;
return AuthUserSnapshot(uid: user.uid, email: user.email, displayName: user.displayName);
}
@override
Future<RefreshedAuthToken?> forceRefresh() async {
final result = await FirebaseAuth.instance.currentUser?.getIdTokenResult(true);
if (result == null) return null;
return RefreshedAuthToken(token: result.token, expirationTime: result.expirationTime);
}
@override
Future<void> signOut() => FirebaseAuth.instance.signOut();
}
/// Source-bound proof captured before a credential-collision sign-in replaces
/// the anonymous Firebase user.
final class AnonymousSourceMigration {
const AnonymousSourceMigration({required this.uid, required this.token});
final String uid;
final String token;
}
/// The outcome of linking an external provider.
///
/// A credential collision signs in to the already-linked destination account
/// inside [AuthService]. The caller must use [anonymousSourceMigration], rather
/// than inspecting FirebaseAuth.currentUser after that switch, to migrate the
/// anonymous source data.
final class ProviderLinkResult {
const ProviderLinkResult({required this.destinationUid, this.anonymousSourceMigration});
final String? destinationUid;
final AnonymousSourceMigration? anonymousSourceMigration;
}
/// Captures an anonymous source proof before [establishDestination] can replace
/// FirebaseAuth.currentUser, then returns both sides of the completed collision.
@visibleForTesting
Future<ProviderLinkResult> resolveProviderCredentialCollision({
required String sourceUid,
required bool sourceIsAnonymous,
required Future<String?> Function() captureSourceToken,
required Future<String?> Function() establishDestination,
}) async {
final sourceToken = sourceIsAnonymous ? await captureSourceToken() : null;
final anonymousSourceMigration =
sourceToken == null ? null : AnonymousSourceMigration(uid: sourceUid, token: sourceToken);
final destinationUid = await establishDestination();
return ProviderLinkResult(destinationUid: destinationUid, anonymousSourceMigration: anonymousSourceMigration);
}
class AuthService {
static final AuthService _instance = AuthService._internal();
static AuthService get instance => _instance;
AuthService._internal()
: _tokenGateway = _FirebaseAuthTokenGateway(),
_refreshAttemptTimeout = _defaultRefreshAttemptTimeout,
_refreshDelay = _defaultRefreshDelay,
_recordTelemetry = _recordProductionTelemetry,
_telemetryContextProvider = _productionTelemetryContext;
@visibleForTesting
AuthService.forTesting({
required AuthTokenGateway tokenGateway,
AuthRefreshDelay? refreshDelay,
Duration? refreshAttemptTimeout,
AuthTelemetryRecorder? recordTelemetry,
AuthTelemetryContextProvider? telemetryContextProvider,
}) : _tokenGateway = tokenGateway,
_refreshAttemptTimeout = refreshAttemptTimeout ?? _defaultRefreshAttemptTimeout,
_refreshDelay = refreshDelay ?? _defaultRefreshDelay,
_recordTelemetry = recordTelemetry ?? ((eventName, properties) {}),
_telemetryContextProvider = telemetryContextProvider ?? (() => const {});
static const int _maxRefreshAttempts = 3;
/// Per-attempt ceiling on the Firebase forced token refresh.
///
/// `forceRefresh()` can hang indefinitely rather than fail: measured on
/// iPhone 17 Pro / iOS 27.0 against a Firebase Auth emulator on a non-loopback
/// host, it never returned at all. Because `shared.dart` refreshes on the way
/// into *every* authenticated request, an unbounded stall here silently freezes
/// all backend traffic app-wide — no error, no timeout, nothing to report.
///
/// A timed-out attempt is reported as a transient failure, which the retry loop
/// below and every existing caller already handle.
static const Duration _defaultRefreshAttemptTimeout = Duration(seconds: 8);
static const List<Duration> _refreshRetryDelays = [Duration(milliseconds: 200), Duration(milliseconds: 500)];
static const Set<String> _terminalTokenErrorCodes = {
'invalid-user-token',
'user-disabled',
'user-not-found',
'user-token-expired',
};
static Future<void> _defaultRefreshDelay(Duration duration) => Future<void>.delayed(duration);
final AuthTokenGateway _tokenGateway;
final Duration _refreshAttemptTimeout;
final AuthRefreshDelay _refreshDelay;
final AuthTelemetryRecorder _recordTelemetry;
final AuthTelemetryContextProvider _telemetryContextProvider;
final StreamController<AuthSessionExpiredEvent> _sessionExpiredController =
StreamController<AuthSessionExpiredEvent>.broadcast(sync: true);
Future<AuthTokenResult>? _refreshInFlight;
Future<void>? _expireSessionInFlight;
bool _sessionExpired = false;
int _sessionGeneration = 0;
String? _refreshUserUid;
Stream<AuthSessionExpiredEvent> get sessionExpiredEvents => _sessionExpiredController.stream;
static void _recordProductionTelemetry(String eventName, Map<String, dynamic> properties) {
PlatformManager.instance.analytics.track(eventName, properties: properties);
}
static Map<String, dynamic> _productionTelemetryContext() => {
'platform': PlatformManager.instance.platform,
'app_version': PlatformManager.instance.appVersion,
'release_channel': Env.isTestFlight ? 'testflight' : (F.env == Environment.prod ? 'app_store' : 'dev'),
};
bool isSignedIn() => FirebaseAuth.instance.currentUser != null && !FirebaseAuth.instance.currentUser!.isAnonymous;
static const _pkceCodeVerifierLength = 64;
static const _pkceCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
getFirebaseUser() {
return FirebaseAuth.instance.currentUser;
}
/// Google Sign In using the standard google_sign_in package (iOS, Android)
Future<UserCredential?> signInWithGoogleMobile() async {
// Trigger the authentication flow
final GoogleSignInAccount? googleUser = await GoogleSignIn(scopes: ['profile', 'email']).signIn();
// Obtain the auth details from the request
final GoogleSignInAuthentication? googleAuth = await googleUser?.authentication;
if (googleAuth == null) {
return null;
}
// Create a new credential
if (googleAuth.accessToken == null && googleAuth.idToken == null) {
return null;
}
final credential = GoogleAuthProvider.credential(accessToken: googleAuth.accessToken, idToken: googleAuth.idToken);
// Once signed in, return the UserCredential
final result = await FirebaseAuth.instance.signInWithCredential(credential);
await _updateUserPreferences(result, 'google');
return result;
}
/// Generates a cryptographically secure random nonce, to be included in a
/// credential request.
String generateNonce([int length = 32]) {
const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._';
final random = Random.secure();
return List.generate(length, (_) => charset[random.nextInt(charset.length)]).join();
}
/// Returns the sha256 hash of [input] in hex notation.
String sha256ofString(String input) {
final bytes = utf8.encode(input);
final digest = sha256.convert(bytes);
return digest.toString();
}
Future<UserCredential?> signInWithAppleMobile() async {
try {
// Sign out the current user first
Logger.debug('Signing out current user...');
handleAuthUserChanged(null);
await FirebaseAuth.instance.signOut();
Logger.debug('User signed out successfully.');
final rawNonce = generateNonce();
final nonce = sha256ofString(rawNonce);
Logger.debug('Requesting Apple credential...');
final appleCredential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
nonce: nonce,
);
if (appleCredential.identityToken == null) {
throw Exception('Apple Sign In failed - no identity token received.');
}
// Create an `OAuthCredential` from the credential returned by Apple.
final oauthCredential = OAuthProvider("apple.com").credential(
idToken: appleCredential.identityToken,
rawNonce: rawNonce,
accessToken: appleCredential.authorizationCode,
);
// Sign in the user with Firebase.
Logger.debug('Attempting to sign in with Firebase...');
UserCredential userCred = await FirebaseAuth.instance.signInWithCredential(oauthCredential);
Logger.debug('Firebase sign-in successful.');
// Extract name from Apple credential (only available on first sign-in)
if (appleCredential.givenName != null && appleCredential.givenName!.isNotEmpty) {
Logger.debug('Apple provided name: ${appleCredential.givenName} ${appleCredential.familyName ?? ""}');
SharedPreferencesUtil().givenName = appleCredential.givenName!;
if (appleCredential.familyName != null && appleCredential.familyName!.isNotEmpty) {
SharedPreferencesUtil().familyName = appleCredential.familyName!;
}
// Update Firebase profile with the name
final fullName = appleCredential.familyName != null && appleCredential.familyName!.isNotEmpty
? '${appleCredential.givenName} ${appleCredential.familyName}'
: appleCredential.givenName!;
try {
await userCred.user?.updateProfile(displayName: fullName);
await userCred.user?.reload();
} catch (e) {
Logger.debug('Failed to update Firebase profile with Apple name: $e');
}
}
await _updateUserPreferences(userCred, 'apple');
return userCred;
} on FirebaseAuthException catch (e) {
Logger.debug('FirebaseAuthException: ${e.code} - ${e.message}');
if (e.code == 'invalid-credential') {
Logger.debug('Please check Firebase console configuration for Apple Sign In.');
}
return null;
} catch (e) {
Logger.debug('Error during Apple Sign In: $e');
Logger.handle(e, null, message: 'An error occurred while signing in. Please try again later.');
return null;
}
}
Future<void> signOut() async {
_invalidateRefreshes();
_clearCachedIdentityAndAuth();
await _tokenGateway.signOut();
}
void _invalidateRefreshes() {
_sessionGeneration++;
_refreshInFlight = null;
}
void handleAuthUserChanged(String? uid) {
if (_refreshUserUid == uid) return;
_refreshUserUid = uid;
_invalidateRefreshes();
}
void markAuthenticatedUser(String uid) {
_refreshUserUid = uid;
_sessionExpired = false;
_invalidateRefreshes();
}
void _clearCachedAuth() {
SharedPreferencesUtil().authToken = '';
SharedPreferencesUtil().tokenExpirationTime = 0;
}
void _clearCachedIdentityAndAuth() {
SharedPreferencesUtil().clearUserDisplayCache();
}
/// Compatibility for sign-in/onboarding callers that only need the token.
/// Authenticated HTTP must use [refreshIdToken] so failure classes are kept.
Future<String?> getIdToken() async {
final result = await refreshIdToken();
switch (result) {
case AuthTokenSuccess(:final token):
return token;
case AuthTokenMissingToken():
await expireSession(const AuthSessionExpiredEvent(reason: AuthSessionExpirationReason.missingToken));
break;
case AuthTokenTerminalFailure(:final code):
await expireSession(
AuthSessionExpiredEvent(reason: AuthSessionExpirationReason.terminalTokenFailure, code: code),
);
break;
case AuthTokenMissingUser():
break;
case AuthTokenTransientFailure():
break;
}
return null;
}
Future<AuthTokenResult> refreshIdToken() {
if (_sessionExpired) {
return Future<AuthTokenResult>.value(const AuthTokenMissingUser());
}
final currentUid = _tokenGateway.currentUser?.uid;
if (_refreshUserUid != currentUid) {
_refreshUserUid = currentUid;
_invalidateRefreshes();
}
final inFlight = _refreshInFlight;
if (inFlight != null) return inFlight;
final generation = _sessionGeneration;
final refresh = _refreshIdTokenWithRetries(generation, currentUid);
_refreshInFlight = refresh;
unawaited(
refresh.then<void>(
(result) {
if (identical(_refreshInFlight, refresh)) _refreshInFlight = null;
},
onError: (Object error, StackTrace stackTrace) {
if (identical(_refreshInFlight, refresh)) _refreshInFlight = null;
},
),
);
return refresh;
}
Future<AuthTokenResult> _refreshIdTokenWithRetries(int generation, String? expectedUid) async {
if (generation != _sessionGeneration) return const AuthTokenMissingUser();
if (expectedUid == null || _tokenGateway.currentUser?.uid != expectedUid) {
Logger.debug('refreshIdToken: currentUser is null');
_clearCachedAuth();
return const AuthTokenMissingUser();
}
AuthTokenResult? lastRetryableFailure;
for (var attempt = 0; attempt < _maxRefreshAttempts; attempt++) {
if (generation != _sessionGeneration) return const AuthTokenMissingUser();
final result = await _refreshIdTokenOnce(generation, expectedUid);
if (result is! AuthTokenTransientFailure && result is! AuthTokenMissingToken) {
if (result is AuthTokenTerminalFailure) {
_recordRefreshFailure(failureClass: 'terminal', code: result.code);
}
return result;
}
lastRetryableFailure = result;
if (attempt < _refreshRetryDelays.length) {
await _refreshDelay(_refreshRetryDelays[attempt]);
}
}
if (lastRetryableFailure is AuthTokenTransientFailure) {
_recordRefreshFailure(failureClass: lastRetryableFailure.failureClass, code: lastRetryableFailure.code);
} else if (lastRetryableFailure is AuthTokenMissingToken) {
_recordRefreshFailure(failureClass: 'missing_token');
}
return lastRetryableFailure!;
}
Future<AuthTokenResult> _refreshIdTokenOnce(int generation, String expectedUid) async {
try {
final refreshed = await _tokenGateway.forceRefresh().timeout(_refreshAttemptTimeout);
if (generation != _sessionGeneration || _tokenGateway.currentUser?.uid != expectedUid) {
return const AuthTokenMissingUser();
}
final token = refreshed?.token;
if (token == null || token.isEmpty) {
Logger.debug('refreshIdToken: token refresh returned no token');
return const AuthTokenMissingToken();
}
final user = _tokenGateway.currentUser;
if (user == null || user.uid != expectedUid) {
_clearCachedAuth();
return const AuthTokenMissingUser();
}
SharedPreferencesUtil().uid = user.uid;
SharedPreferencesUtil().tokenExpirationTime = refreshed?.expirationTime?.millisecondsSinceEpoch ?? 0;
SharedPreferencesUtil().authToken = token;
if (SharedPreferencesUtil().email.isEmpty) {
SharedPreferencesUtil().email = user.email ?? '';
}
if (SharedPreferencesUtil().givenName.isEmpty) {
final nameParts = user.displayName?.split(' ') ?? const <String>[];
SharedPreferencesUtil().givenName = nameParts.isEmpty ? '' : nameParts.first;
SharedPreferencesUtil().familyName = nameParts.length > 1 ? nameParts[1] : '';
}
_sessionExpired = false;
return AuthTokenSuccess(token: token, expirationTime: refreshed?.expirationTime);
} on TimeoutException {
if (generation != _sessionGeneration) return const AuthTokenMissingUser();
Logger.debug('refreshIdToken: forceRefresh timed out after $_refreshAttemptTimeout');
// Distinct class so a stalled refresh is distinguishable in telemetry from
// one that actually failed — they have very different causes.
return const AuthTokenTransientFailure(failureClass: 'refresh_timeout');
} on FirebaseAuthException catch (e) {
if (generation != _sessionGeneration) return const AuthTokenMissingUser();
Logger.debug('refreshIdToken: FirebaseAuthException: ${e.code}');
if (_terminalTokenErrorCodes.contains(e.code)) {
_clearCachedAuth();
return AuthTokenTerminalFailure(code: e.code);
}
return AuthTokenTransientFailure(failureClass: 'firebase_transient', code: e.code);
} catch (e) {
if (generation != _sessionGeneration) return const AuthTokenMissingUser();
Logger.debug('refreshIdToken: token refresh failed transiently: ${e.runtimeType}');
return const AuthTokenTransientFailure(failureClass: 'transient');
}
}
void _recordRefreshFailure({required String failureClass, String? code}) {
_recordTelemetry('auth_token_refresh_failed', {
'failure_class': failureClass,
'code': code ?? failureClass,
..._telemetryContextProvider(),
});
}
void recordAuthenticatedRequest401({required bool recovered, required String outcome}) {
_recordTelemetry('authenticated_request_401', {
'recovered': recovered,
'outcome': outcome,
..._telemetryContextProvider(),
});
}
Future<void> expireSession(AuthSessionExpiredEvent event) {
final inFlight = _expireSessionInFlight;
if (_sessionExpired) return inFlight ?? Future<void>.value();
_sessionExpired = true;
_invalidateRefreshes();
_clearCachedIdentityAndAuth();
_sessionExpiredController.add(event);
final expiration = _runSessionExpiration();
_expireSessionInFlight = expiration;
return expiration;
}
Future<void> _runSessionExpiration() async {
try {
await _tokenGateway.signOut();
} catch (e) {
// Local session state is already terminal and cleared. A platform sign-
// out failure must not escape back into request handling or restore the
// stale authenticated shell.
Logger.debug('expireSession: Firebase sign-out failed: ${e.runtimeType}');
} finally {
_expireSessionInFlight = null;
}
}
Future<UserCredential?> authenticateWithProvider(String provider) async {
try {
final state = _generateState();
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _codeChallengeForVerifier(codeVerifier);
final redirectUri = Env.authRedirectUri;
final callbackScheme = Env.authCallbackScheme;
Logger.debug('Starting OAuth flow for provider: $provider');
final authUrl = Uri.parse('${Env.authApiBaseUrl}v1/auth/authorize').replace(
queryParameters: {
'provider': provider,
'redirect_uri': redirectUri,
'state': state,
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
},
).toString();
Logger.debug('Authorization URL: $authUrl');
// Set up listeners before launching URL
final appLinks = AppLinks();
late StreamSubscription linkSubscription;
final completer = Completer<String>();
// Listen via app_links
linkSubscription = appLinks.uriLinkStream.listen(
(Uri uri) {
Logger.debug('Received callback URI via app_links: $uri');
if (uri.scheme == callbackScheme && uri.host == 'auth' && uri.path == '/callback') {
if (!completer.isCompleted) {
linkSubscription.cancel();
completer.complete(uri.toString());
}
}
},
onError: (error) {
Logger.debug('App link error: $error');
if (!completer.isCompleted) {
linkSubscription.cancel();
completer.completeError(error);
}
},
);
// Now launch the URL
final launched = await launchUrl(Uri.parse(authUrl), mode: LaunchMode.inAppBrowserView);
if (!launched) {
linkSubscription.cancel();
throw Exception('Failed to launch authentication URL');
}
final result = await completer.future.timeout(
const Duration(minutes: 5),
onTimeout: () {
linkSubscription.cancel();
throw Exception('Authentication timeout');
},
);
final uri = Uri.parse(result);
final code = uri.queryParameters['code'];
final returnedState = uri.queryParameters['state'];
if (code == null) {
throw Exception('No authorization code received');
}
if (returnedState != state) {
throw Exception('Invalid state parameter');
}
// Exchange the code for OAuth credentials
final oauthCredentials = await _exchangeCodeForOAuthCredentials(code, redirectUri, codeVerifier);
if (oauthCredentials == null) {
throw Exception('Failed to exchange code for OAuth credentials');
}
// Sign in to Firebase with the OAuth credentials
final credential = await _signInWithOAuthCredentials(oauthCredentials);
// Update user profile and local storage after successful sign-in
await _updateUserPreferences(credential, provider);
Logger.debug('Firebase authentication successful');
return credential;
} catch (e) {
Logger.debug('OAuth authentication error: $e');
Logger.handle(e, StackTrace.current, message: 'Authentication failed');
return null;
}
}
Future<Map<String, dynamic>?> _exchangeCodeForOAuthCredentials(
String code,
String redirectUri,
String codeVerifier,
) async {
try {
final useCustomToken = Env.useAuthCustomToken;
final response = await http.post(
Uri.parse('${Env.authApiBaseUrl}v1/auth/token'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirectUri,
'use_custom_token': useCustomToken.toString(),
'code_verifier': codeVerifier,
},
);
Logger.debug('Token exchange response status: ${response.statusCode}');
if (response.statusCode == 200) {
Logger.debug('Token exchange succeeded');
return json.decode(response.body);
} else {
Logger.debug('Token exchange failed: ${response.body}');
return null;
}
} catch (e) {
Logger.debug('Token exchange error: $e');
return null;
}
}
Future<UserCredential> _signInWithOAuthCredentials(Map<String, dynamic> oauthCredentials) async {
final provider = oauthCredentials['provider'];
final useCustomToken = Env.useAuthCustomToken;
final customToken = oauthCredentials['custom_token'];
// Use custom token if enabled and available
if (useCustomToken && customToken != null) {
Logger.debug('Signing in with Firebase custom token from $provider');
return await FirebaseAuth.instance.signInWithCustomToken(customToken);
}
// Fallback to OAuth credentials
final idToken = oauthCredentials['id_token'];
final accessToken = oauthCredentials['access_token'];
Logger.debug('Signing in with $provider OAuth credentials');
if (provider == 'google') {
final credential = GoogleAuthProvider.credential(idToken: idToken, accessToken: accessToken);
return await FirebaseAuth.instance.signInWithCredential(credential);
} else if (provider == 'apple') {
final credential = OAuthProvider('apple.com').credential(idToken: idToken, accessToken: accessToken);
return await FirebaseAuth.instance.signInWithCredential(credential);
} else {
throw Exception('Unsupported provider: $provider');
}
}
Future<void> _updateUserPreferences(UserCredential result, String provider) async {
try {
final user = result.user;
if (user == null) return;
markAuthenticatedUser(user.uid);
// Update UID and basic user info
SharedPreferencesUtil().uid = user.uid;
// Get user info from Firebase user and additional user info
var email = user.email ?? '';
var displayName = user.displayName ?? '';
var givenName = '';
var familyName = '';
if (result.additionalUserInfo?.profile != null) {
final profile = result.additionalUserInfo!.profile!;
if (provider == 'google') {
givenName = profile['given_name'] ?? '';
familyName = profile['family_name'] ?? '';
email = profile['email'] ?? email;
} else if (provider == 'apple') {
if (profile.containsKey('name')) {
final name = profile['name'];
if (name is Map) {
givenName = name['firstName'] ?? '';
familyName = name['lastName'] ?? '';
}
}
email = profile['email'] ?? email;
}
}
if (givenName.isEmpty && displayName.isNotEmpty) {
var nameParts = displayName.split(' ');
givenName = nameParts.isNotEmpty ? nameParts[0] : '';
familyName = nameParts.length > 1 ? nameParts.sublist(1).join(' ') : '';
}
// Update SharedPreferences
if (email.isNotEmpty) {
SharedPreferencesUtil().email = email;
}
if (givenName.isNotEmpty) {
SharedPreferencesUtil().givenName = givenName;
SharedPreferencesUtil().familyName = familyName;
}
// Update Firebase user profile if needed
if (displayName.isEmpty && givenName.isNotEmpty) {
final fullName = familyName.isNotEmpty ? '$givenName $familyName' : givenName;
try {
await user.updateProfile(displayName: fullName);
await user.reload();
} catch (e) {
Logger.debug('Failed to update Firebase profile: $e');
}
}
Logger.debug('Updated user preferences:');
Logger.debug('Email: ${SharedPreferencesUtil().email}');
Logger.debug('Given Name: ${SharedPreferencesUtil().givenName}');
Logger.debug('Family Name: ${SharedPreferencesUtil().familyName}');
Logger.debug('UID: ${SharedPreferencesUtil().uid}');
// Restore onboarding state from server
await _restoreOnboardingState();
} catch (e) {
Logger.debug('Error updating user preferences: $e');
}
}
/// Restore onboarding state from server. Call this on app startup when using cached credentials.
Future<void> restoreOnboardingState() async {
return _restoreOnboardingState();
}
Future<void> _restoreOnboardingState() async {
try {
final state = await getUserOnboardingState();
if (state != null) {
if (state['completed'] == true) {
SharedPreferencesUtil().onboardingCompleted = true;
}
final acquisitionSource = state['acquisition_source'] as String? ?? '';
if (acquisitionSource.isNotEmpty) {
SharedPreferencesUtil().foundOmiSource = acquisitionSource;
}
// Restore language from server if not already set locally
final serverLanguage = await getUserPrimaryLanguage();
if (serverLanguage != null && serverLanguage.isNotEmpty) {
SharedPreferencesUtil().userPrimaryLanguage = serverLanguage;
SharedPreferencesUtil().hasSetPrimaryLanguage = true;
}
}
} catch (e) {
Logger.debug('restoreOnboardingState failed: $e');
}
}
Future<void> updateGivenName(String fullName) async {
try {
var user = FirebaseAuth.instance.currentUser;
SharedPreferencesUtil().givenName = fullName.split(' ')[0];
if (fullName.split(' ').length > 1) {
SharedPreferencesUtil().familyName = fullName.split(' ').sublist(1).join(' ');
}
if (user == null) {
Logger.debug('Firebase user is null, skipping Firebase profile update');
return;
}
// Try to update Firebase profile with platform-specific handling
try {
Logger.debug('Attempting to update Firebase user profile...');
if (kIsWeb) {
Logger.debug('Web platform detected - attempting updateProfile with caution');
// Try with a timeout to prevent hanging
await user.updateProfile(displayName: fullName).timeout(
const Duration(seconds: 5),
onTimeout: () {
Logger.debug('updateProfile timed out on web platform');
throw TimeoutException('updateProfile timed out', const Duration(seconds: 5));
},
);
} else {
await user.updateProfile(displayName: fullName);
}
await user.reload();
user = FirebaseAuth.instance.currentUser;
} catch (updateError) {
Logger.debug('Firebase updateProfile failed: $updateError');
}
} catch (e) {
Logger.debug('Error in updateGivenName: $e');
// Ensure SharedPreferences are updated even if everything else fails
try {
SharedPreferencesUtil().givenName = fullName.split(' ')[0];
if (fullName.split(' ').length > 1) {
SharedPreferencesUtil().familyName = fullName.split(' ').sublist(1).join(' ');
}
Logger.debug('SharedPreferences updated despite error');
} catch (prefError) {
Logger.debug('Failed to update SharedPreferences: $prefError');
}
}
}
String _generateState() {
final random = Random.secure();
final bytes = Uint8List(32);
for (int i = 0; i < 32; i++) {
bytes[i] = random.nextInt(256);
}
return base64Url.encode(bytes);
}
String _generateCodeVerifier([int length = _pkceCodeVerifierLength]) {
final random = Random.secure();
return List.generate(length, (_) => _pkceCharset[random.nextInt(_pkceCharset.length)]).join();
}
String _codeChallengeForVerifier(String verifier) {
final digest = sha256.convert(utf8.encode(verifier));
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
Future<ProviderLinkResult?> linkWithProvider(String provider) async {
try {
final currentUser = FirebaseAuth.instance.currentUser;
if (currentUser == null) {
throw Exception('No user is currently signed in');
}
final state = _generateState();
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _codeChallengeForVerifier(codeVerifier);
final redirectUri = Env.authRedirectUri;
final callbackScheme = Env.authCallbackScheme;
Logger.debug('Starting OAuth linking flow for provider: $provider');
final authUrl = Uri.parse('${Env.authApiBaseUrl}v1/auth/authorize').replace(
queryParameters: {
'provider': provider,
'redirect_uri': redirectUri,
'state': state,
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
},
).toString();
Logger.debug('Authorization URL: $authUrl');
final launched = await launchUrl(Uri.parse(authUrl), mode: LaunchMode.inAppBrowserView);
if (!launched) {
throw Exception('Failed to launch authentication URL');
}
// Listen for the callback URL using app_links
final appLinks = AppLinks();
late StreamSubscription linkSubscription;
final completer = Completer<String>();
linkSubscription = appLinks.uriLinkStream.listen(
(Uri uri) {
Logger.debug('Received callback URI: $uri');
if (uri.scheme == callbackScheme && uri.host == 'auth' && uri.path == '/callback') {
linkSubscription.cancel();
completer.complete(uri.toString());
}
},
onError: (error) {
Logger.debug('App link error: $error');
linkSubscription.cancel();
completer.completeError(error);
},
);
final result = await completer.future.timeout(
const Duration(minutes: 5),
onTimeout: () {
linkSubscription.cancel();
throw Exception('Authentication timeout');
},
);
final uri = Uri.parse(result);
final code = uri.queryParameters['code'];
final returnedState = uri.queryParameters['state'];
if (code == null) {
throw Exception('No authorization code received');
}
if (returnedState != state) {
throw Exception('Invalid state parameter');
}
// Exchange the code for OAuth credentials
final oauthCredentials = await _exchangeCodeForOAuthCredentials(code, redirectUri, codeVerifier);
if (oauthCredentials == null) {
throw Exception('Failed to exchange code for OAuth credentials');
}
// Create Firebase credential
final credential = await _createFirebaseCredential(oauthCredentials);
try {
// Link the credential to the current user
final result = await currentUser.linkWithCredential(credential);
// Update user preferences after successful linking
await _updateUserPreferences(result, provider);
Logger.debug('Firebase account linking successful');
return ProviderLinkResult(destinationUid: result.user?.uid);
} catch (e) {
if (e is FirebaseAuthException && e.code == 'credential-already-in-use') {
return await resolveProviderCredentialCollision(
sourceUid: currentUser.uid,
sourceIsAnonymous: currentUser.isAnonymous,
captureSourceToken: currentUser.getIdToken,
establishDestination: () async => (await _handleExistingCredential(e)).user?.uid,
);
}
rethrow;
}
} catch (e) {
Logger.debug('OAuth linking error: $e');
Logger.handle(e, StackTrace.current, message: 'Account linking failed');
rethrow;
}
}
Future<AuthCredential> _createFirebaseCredential(Map<String, dynamic> oauthCredentials) async {
final provider = oauthCredentials['provider'];
final idToken = oauthCredentials['id_token'];
final accessToken = oauthCredentials['access_token'];
if (provider == 'google') {
return GoogleAuthProvider.credential(idToken: idToken, accessToken: accessToken);
} else if (provider == 'apple') {
return OAuthProvider('apple.com').credential(idToken: idToken, accessToken: accessToken);
} else {
throw Exception('Unsupported provider: $provider');
}
}
/// Handle the case when credential is already in use
Future<UserCredential> _handleExistingCredential(FirebaseAuthException e) async {
// Get existing user credentials
final existingCred = e.credential;
// Sign out current anonymous user
handleAuthUserChanged(null);
await FirebaseAuth.instance.signOut();
// Sign in with existing account
final result = await FirebaseAuth.instance.signInWithCredential(existingCred!);
final newUserId = FirebaseAuth.instance.currentUser?.uid;
if (newUserId != null) markAuthenticatedUser(newUserId);
await getIdToken();
SharedPreferencesUtil().onboardingCompleted = false;
SharedPreferencesUtil().uid = newUserId ?? '';
SharedPreferencesUtil().email = FirebaseAuth.instance.currentUser?.email ?? '';
SharedPreferencesUtil().givenName = FirebaseAuth.instance.currentUser?.displayName?.split(' ')[0] ?? '';
return result;
}
Future<ProviderLinkResult?> linkWithGoogle() async {
return await linkWithProvider('google');
}
Future<ProviderLinkResult?> linkWithApple() async {
return await linkWithProvider('apple');
}
}