260 lines
7.8 KiB
Dart
260 lines
7.8 KiB
Dart
import 'package:firebase_auth/firebase_auth.dart';
|
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:google_sign_in/google_sign_in.dart';
|
|
|
|
import '../config/auth_config.dart';
|
|
import '../firebase_options.dart';
|
|
import '../models/app_user.dart';
|
|
import '../utils/auth_log.dart';
|
|
import '../utils/firebase_auth_domain_check.dart';
|
|
|
|
class AuthServiceFirebase {
|
|
AuthServiceFirebase._();
|
|
|
|
static final AuthServiceFirebase instance = AuthServiceFirebase._();
|
|
|
|
final FirebaseAuth _firebaseAuth = FirebaseAuth.instance;
|
|
final GoogleSignIn _googleSignIn = GoogleSignIn.instance;
|
|
|
|
bool _googleSignInReady = false;
|
|
|
|
Stream<AppUser?> get authStateChanges =>
|
|
_firebaseAuth.authStateChanges().map(_mapUser);
|
|
|
|
AppUser? get currentUser => _mapUser(_firebaseAuth.currentUser);
|
|
|
|
Future<void> initialize() async {
|
|
authLog('initialize', context: _authEnvironmentContext());
|
|
|
|
if (kIsWeb) {
|
|
await logFirebaseAuthorizedDomainsIfWeb();
|
|
await _completeWebRedirectIfAny();
|
|
// Skip eager google_sign_in init on web. Firebase Auth's signInWithPopup
|
|
// already loads Google Identity Services, and double-initializing logs
|
|
// a "google.accounts.id.initialize() is called multiple times" warning.
|
|
// We lazy-init GIS in _signInWithGoogleWeb() only if the popup fails and
|
|
// we need the GIS fallback path.
|
|
return;
|
|
}
|
|
|
|
if (googleWebOAuthClientId != null) {
|
|
await _googleSignIn.initialize(
|
|
serverClientId: googleWebOAuthClientId,
|
|
);
|
|
} else {
|
|
await _googleSignIn.initialize();
|
|
}
|
|
_googleSignInReady = true;
|
|
authLog('google_sign_in initialized (mobile/desktop)');
|
|
}
|
|
|
|
/// After a redirect-based OAuth round-trip, Firebase completes sign-in here.
|
|
Future<void> _completeWebRedirectIfAny() async {
|
|
authLog('web: checking getRedirectResult (return from redirect flow)');
|
|
try {
|
|
final UserCredential credential = await _firebaseAuth.getRedirectResult();
|
|
final User? user = credential.user;
|
|
if (user != null) {
|
|
authLog(
|
|
'web: redirect sign-in completed',
|
|
context: <String, Object?>{
|
|
'uid': user.uid,
|
|
if (user.email != null) 'email': user.email!,
|
|
'provider': credential.credential?.providerId ?? 'unknown',
|
|
},
|
|
);
|
|
} else {
|
|
authLog('web: getRedirectResult — no user (normal on fresh load)');
|
|
}
|
|
} on FirebaseAuthException catch (e, st) {
|
|
authLog(
|
|
'web: getRedirectResult FirebaseAuthException',
|
|
error: e,
|
|
stackTrace: st,
|
|
context: firebaseExceptionContext(e),
|
|
);
|
|
} catch (e, st) {
|
|
authLog(
|
|
'web: getRedirectResult failed',
|
|
error: e,
|
|
stackTrace: st,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> signInWithGoogle() async {
|
|
authLog('signInWithGoogle start', context: _authEnvironmentContext());
|
|
if (kIsWeb) {
|
|
await _signInWithGoogleWeb();
|
|
return;
|
|
}
|
|
|
|
_ensureGoogleSignInReady();
|
|
|
|
try {
|
|
final GoogleSignInAccount account = await _googleSignIn.authenticate();
|
|
final String? idToken = account.authentication.idToken;
|
|
if (idToken == null) {
|
|
throw StateError('Google Sign-In did not return an ID token.');
|
|
}
|
|
|
|
final credential = GoogleAuthProvider.credential(idToken: idToken);
|
|
await _firebaseAuth.signInWithCredential(credential);
|
|
authLog('signInWithCredential succeeded', context: <String, Object?>{
|
|
'uid': _firebaseAuth.currentUser?.uid ?? '',
|
|
});
|
|
} catch (e, st) {
|
|
authLog('native sign-in failed', error: e, stackTrace: st);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Web: try Firebase popup first; on failure use full-page redirect.
|
|
///
|
|
/// [GoogleSignIn.authenticate] is not implemented on web (google_sign_in 7.x);
|
|
/// use [signInWithRedirect] instead of a GIS programmatic fallback.
|
|
Future<void> _signInWithGoogleWeb() async {
|
|
authLog('web: sign-in start', context: _authEnvironmentContext());
|
|
|
|
try {
|
|
authLog('web: attempting signInWithPopup(GoogleAuthProvider)');
|
|
final UserCredential credential = await _firebaseAuth.signInWithPopup(
|
|
GoogleAuthProvider(),
|
|
);
|
|
authLog(
|
|
'web: signInWithPopup succeeded',
|
|
context: <String, Object?>{
|
|
'uid': credential.user?.uid ?? '',
|
|
if (credential.user?.email != null) 'email': credential.user!.email!,
|
|
},
|
|
);
|
|
return;
|
|
} on FirebaseAuthException catch (e, st) {
|
|
authLog(
|
|
'web: signInWithPopup FirebaseAuthException',
|
|
error: e,
|
|
stackTrace: st,
|
|
context: firebaseExceptionContext(e),
|
|
);
|
|
if (_isUserCancelledAuth(e)) {
|
|
rethrow;
|
|
}
|
|
await _signInWithGoogleRedirect(triggerCode: e.code);
|
|
return;
|
|
} catch (e, st) {
|
|
authLog(
|
|
'web: signInWithPopup unexpected error',
|
|
error: e,
|
|
stackTrace: st,
|
|
);
|
|
if (e is UnimplementedError) {
|
|
authLog(
|
|
'web: caught UnimplementedError from popup path — using redirect',
|
|
);
|
|
}
|
|
await _signInWithGoogleRedirect(triggerCode: e.runtimeType.toString());
|
|
}
|
|
}
|
|
|
|
/// Full-page redirect when popup/iframe auth fails.
|
|
Future<void> _signInWithGoogleRedirect({String? triggerCode}) async {
|
|
authLog(
|
|
'web: attempting signInWithRedirect after popup failure',
|
|
context: <String, Object?>{
|
|
'trigger': triggerCode ?? 'unknown',
|
|
..._authEnvironmentContext(),
|
|
},
|
|
);
|
|
try {
|
|
await _firebaseAuth.signInWithRedirect(GoogleAuthProvider());
|
|
authLog(
|
|
'web: signInWithRedirect invoked — page will navigate away; '
|
|
'on return, getRedirectResult runs in initialize()',
|
|
);
|
|
} on FirebaseAuthException catch (e, st) {
|
|
authLog(
|
|
'web: signInWithRedirect FirebaseAuthException',
|
|
error: e,
|
|
stackTrace: st,
|
|
context: firebaseExceptionContext(e),
|
|
);
|
|
rethrow;
|
|
} catch (e, st) {
|
|
authLog(
|
|
'web: signInWithRedirect failed',
|
|
error: e,
|
|
stackTrace: st,
|
|
);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> signOut() async {
|
|
authLog('signOut');
|
|
if (_googleSignInReady) {
|
|
await _googleSignIn.signOut();
|
|
}
|
|
await _firebaseAuth.signOut();
|
|
}
|
|
|
|
Future<String?> getIdToken({bool forceRefresh = false}) async {
|
|
final User? user = _firebaseAuth.currentUser;
|
|
if (user == null) {
|
|
return null;
|
|
}
|
|
try {
|
|
return await user.getIdToken(forceRefresh);
|
|
} catch (e, st) {
|
|
authLog(
|
|
'getIdToken failed',
|
|
error: e,
|
|
stackTrace: st,
|
|
context: <String, Object?>{'uid': user.uid},
|
|
);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
void _ensureGoogleSignInReady() {
|
|
if (_googleSignInReady) {
|
|
return;
|
|
}
|
|
throw StateError(
|
|
'Google Sign-In is not configured. Set googleWebOAuthClientId in '
|
|
'lib/config/auth_config.dart for Android, or run a full restart after '
|
|
'changing auth settings.',
|
|
);
|
|
}
|
|
|
|
bool _isUserCancelledAuth(FirebaseAuthException e) {
|
|
return e.code == 'popup-closed-by-user' ||
|
|
e.code == 'cancelled-popup-request' ||
|
|
e.code == 'auth/popup-closed-by-user';
|
|
}
|
|
|
|
Map<String, Object?> _authEnvironmentContext() {
|
|
final FirebaseOptions options = DefaultFirebaseOptions.currentPlatform;
|
|
return <String, Object?>{
|
|
if (kIsWeb) 'pageOrigin': Uri.base.origin,
|
|
if (kIsWeb) 'pageHost': Uri.base.host,
|
|
'authDomain': options.authDomain ?? '',
|
|
'projectId': options.projectId,
|
|
'hasGoogleWebClientId': googleWebOAuthClientId != null,
|
|
'platform': kIsWeb ? 'web' : 'native',
|
|
};
|
|
}
|
|
|
|
AppUser? _mapUser(User? user) {
|
|
if (user == null) {
|
|
return null;
|
|
}
|
|
return AppUser(
|
|
uid: user.uid,
|
|
email: user.email,
|
|
displayName: user.displayName,
|
|
photoUrl: user.photoURL,
|
|
);
|
|
}
|
|
}
|