diff --git a/README.md b/README.md index 096dc79..9a16712 100644 --- a/README.md +++ b/README.md @@ -563,7 +563,6 @@ For the purpose of Fraud prevention, user safety, and compliance the dedicated A # Todos - Fix Riverpod async gaps - analytics manager (keep live) -- Revisit Google and Apple logins - Providers, Cancellation exception, separating Credentials from Sign In. USe Firebase directly for Apple on Android. diff --git a/lib/common/data/entity/exception/custom_exception.dart b/lib/common/data/entity/exception/custom_exception.dart index da99201..89893c3 100644 --- a/lib/common/data/entity/exception/custom_exception.dart +++ b/lib/common/data/entity/exception/custom_exception.dart @@ -70,6 +70,8 @@ sealed class CustomException with _$CustomException implements Exception { switch (error.code) { case 'credential-already-in-use': return CustomException.credentialAlreadyInUse(credential: error.credential); + case 'web-context-canceled': + return const CustomException.signInCancelled(); default: return CustomException.withMessage(message: error.message); } diff --git a/lib/common/usecase/authentication/sign_in_anonymously_use_case.dart b/lib/common/usecase/authentication/sign_in_anonymously_use_case.dart index 2f2ef75..9480f4a 100644 --- a/lib/common/usecase/authentication/sign_in_anonymously_use_case.dart +++ b/lib/common/usecase/authentication/sign_in_anonymously_use_case.dart @@ -1,15 +1,18 @@ import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter_app/common/data/entity/user_entity.dart'; -import 'package:flutter_app/common/usecase/authentication/sign_in_completion_use_case.dart'; +import 'package:flutter_app/common/data/entity/exception/custom_exception.dart'; import 'package:flutter_app/core/flogger.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; -final signInAnonymouslyUseCase = FutureProvider((ref) async { - Flogger.d('[Authentication] Going to sign in user anonymously'); +part 'sign_in_anonymously_use_case.g.dart'; - await FirebaseAuth.instance.signInAnonymously(); +@riverpod +Future signInAnonymouslyUseCase(Ref ref) async { + try { + Flogger.d('[Authentication] Going to sign in user anonymously'); - final user = await ref.read(signInCompletionUseCaseProvider.future); - - return user; -}); + await FirebaseAuth.instance.signInAnonymously(); + } catch (e) { + Flogger.e('[Authentication] Error during anonymous sign in: $e'); + throw CustomException.fromErrorObject(error: e); + } +} diff --git a/lib/common/usecase/authentication/sign_in_completion_use_case.dart b/lib/common/usecase/authentication/sign_in_completion_use_case.dart index 4d454c6..15afd13 100644 --- a/lib/common/usecase/authentication/sign_in_completion_use_case.dart +++ b/lib/common/usecase/authentication/sign_in_completion_use_case.dart @@ -1,6 +1,6 @@ +import 'package:flutter_app/common/data/entity/exception/custom_exception.dart'; import 'package:flutter_app/common/data/entity/user_entity.dart'; import 'package:flutter_app/common/data/enum/user_role.dart'; -import 'package:flutter_app/common/provider/current_user_state.dart'; import 'package:flutter_app/core/flogger.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -8,29 +8,31 @@ part 'sign_in_completion_use_case.g.dart'; @riverpod Future signInCompletionUseCase(Ref ref) async { - Flogger.d('[Authentication] Going to sign in user on BE'); + try { + Flogger.d('[Authentication] Going to sign in user on BE'); - /* - final dio = ref.read(dioProvider); - final response = await dio.post('v1/sign-in'); - final userResponse = UserResponseDTO.fromJson(response.data); - final user = UserEntity.fromAPI(user: userResponse); - */ - // TODO(strv): Remove this line and uncomment the above lines - const user = UserEntity( - id: '1', - email: 'john.doe@example.com', - displayName: 'John Doe', - imageUrl: 'https://randomuser.me/api/portraits', - role: UserRole.user, - referredId: '1', - ); + /* + final dio = ref.read(dioProvider); + final response = await dio.post('v1/sign-in'); + final userResponse = UserResponseDTO.fromJson(response.data); + final user = UserEntity.fromAPI(user: userResponse); + */ - Flogger.d('[Authentication] Received new user from BE $user'); + // TODO(strv): Remove this line and uncomment the above lines + const user = UserEntity( + id: '1', + email: 'john.doe@example.com', + displayName: 'John Doe', + imageUrl: 'https://randomuser.me/api/portraits', + role: UserRole.user, + referredId: '1', + ); - await ref.read(currentUserStateProvider.notifier).updateCurrentUser(user); + Flogger.d('[Authentication] Received new user from BE $user'); - Flogger.d('[Authentication] Current user updated'); - - return user; + return user; + } catch (e) { + Flogger.e('[Authentication] Error during sign in completion: $e'); + throw CustomException.fromErrorObject(error: e); + } } diff --git a/lib/common/usecase/authentication/sign_in_with_apple_use_case.dart b/lib/common/usecase/authentication/sign_in_with_apple_use_case.dart index 90a51b2..997ffae 100644 --- a/lib/common/usecase/authentication/sign_in_with_apple_use_case.dart +++ b/lib/common/usecase/authentication/sign_in_with_apple_use_case.dart @@ -1,37 +1,55 @@ import 'dart:convert'; +import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter_app/common/data/entity/user_entity.dart'; -import 'package:flutter_app/common/usecase/authentication/sign_in_with_auth_credential_use_case.dart'; +import 'package:flutter_app/common/data/entity/exception/custom_exception.dart'; import 'package:flutter_app/core/flogger.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sign_in_with_apple/sign_in_with_apple.dart'; -final signInWithAppleUseCase = FutureProvider((ref) async { - Flogger.d('[Authentication] Sign in with Apple started'); - - // To prevent replay attacks with the credential returned from Apple, we include a nonce in the credential request. - // When signing in with Firebase, the nonce in the id token returned by Apple, is expected to match the sha256 hash of `rawNonce`. - final rawNonce = generateNonce(); - final sha256Nonce = sha256.convert(utf8.encode(rawNonce)).toString(); - - // webAuthenticationOptions is required on Android and on the Web. - // TODO(strv): Configure for Android and web after the domain is registered. - final appleCredential = await SignInWithApple.getAppleIDCredential( - nonce: sha256Nonce, - webAuthenticationOptions: WebAuthenticationOptions( - clientId: 'com.example.app', - redirectUri: Uri.parse('https://example.com/auth/callback'), - ), - scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName], - ); - - final oauthCredential = OAuthProvider( - 'apple.com', - ).credential(rawNonce: rawNonce, idToken: appleCredential.identityToken, accessToken: appleCredential.authorizationCode); - - Flogger.d('[Authentication] Received credential from Apple: $oauthCredential'); - - return await ref.read(signInWithAuthCredentialUseCaseProvider(credential: oauthCredential).future); -}); +part 'sign_in_with_apple_use_case.g.dart'; + +@riverpod +Future signInWithAppleUseCase(Ref ref) async { + try { + Flogger.d('[Authentication] Sign in with Apple started natively: ${Platform.isIOS}'); + + if (Platform.isIOS) { + // To prevent replay attacks with the credential returned from Apple, we include a nonce in the credential request. + // When signing in with Firebase, the nonce in the id token returned by Apple, is expected to match the sha256 hash of `rawNonce`. + final rawNonce = generateNonce(); + final sha256Nonce = sha256.convert(utf8.encode(rawNonce)).toString(); + + // Subtitle: Step 1 - Get Apple ID credential + final appleCredential = await SignInWithApple.getAppleIDCredential( + nonce: sha256Nonce, + scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName], + ); + + final oauthCredential = OAuthProvider( + 'apple.com', + ).credential(rawNonce: rawNonce, idToken: appleCredential.identityToken, accessToken: appleCredential.authorizationCode); + + Flogger.d('[Authentication] Received credential from Apple: $oauthCredential'); + + // Subtitle: Step 2 - Sign in with credentials from provider + await FirebaseAuth.instance.signInWithCredential(oauthCredential); + } else { + final appleProvider = AppleAuthProvider() + ..addScope('email') + ..addScope('name'); + await FirebaseAuth.instance.signInWithProvider(appleProvider); + } + } on SignInWithAppleAuthorizationException catch (e) { + Flogger.e('[Authentication] Error during sign in with Apple: $e'); + if (e.code == AuthorizationErrorCode.canceled) { + throw const CustomException.signInCancelled(); + } else { + throw CustomException.fromErrorObject(error: e); + } + } catch (e) { + Flogger.e('[Authentication] Error during sign in with Apple: $e'); + throw CustomException.fromErrorObject(error: e); + } +} diff --git a/lib/common/usecase/authentication/sign_in_with_auth_credential_use_case.dart b/lib/common/usecase/authentication/sign_in_with_auth_credential_use_case.dart deleted file mode 100644 index 8af7b71..0000000 --- a/lib/common/usecase/authentication/sign_in_with_auth_credential_use_case.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter_app/common/data/entity/exception/custom_exception.dart'; -import 'package:flutter_app/common/data/entity/user_entity.dart'; -import 'package:flutter_app/common/usecase/authentication/sign_in_completion_use_case.dart'; -import 'package:flutter_app/core/flogger.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'sign_in_with_auth_credential_use_case.g.dart'; - -@riverpod -FutureOr signInWithAuthCredentialUseCase( - Ref ref, { - required AuthCredential credential, -}) async { - final firebaseUser = FirebaseAuth.instance.currentUser; - - Flogger.d('[Authentication] Firebase user $firebaseUser'); - - if (firebaseUser != null) { - // Title: Link existing user with credentials - Flogger.d('[Authentication] Going to link firebase user with received credential'); - - try { - await firebaseUser.linkWithCredential(credential); - - Flogger.d('[Authentication] Anonymous user was linked with google credential'); - } on Exception catch (error) { - final customException = CustomException.fromErrorObject(error: error); - final credentialIsAlreadyInUse = customException.whenOrNull( - credentialAlreadyInUse: (credential) => credential, - ); - - if (credentialIsAlreadyInUse != null) { - await FirebaseAuth.instance.signInWithCredential(credentialIsAlreadyInUse); - } else { - rethrow; - } - } - } else { - // Title: Sign in with credentials - Flogger.d('[Authentication] Going to sign in user with received credential ${credential.asMap()}'); - - await FirebaseAuth.instance.signInWithCredential(credential); - } - - return await ref.read(signInCompletionUseCaseProvider.future); -} diff --git a/lib/common/usecase/authentication/sign_in_with_google_use_case.dart b/lib/common/usecase/authentication/sign_in_with_google_use_case.dart index 3baaf4d..39318a3 100644 --- a/lib/common/usecase/authentication/sign_in_with_google_use_case.dart +++ b/lib/common/usecase/authentication/sign_in_with_google_use_case.dart @@ -1,40 +1,51 @@ import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter_app/common/data/entity/user_entity.dart'; -import 'package:flutter_app/common/usecase/authentication/sign_in_with_auth_credential_use_case.dart'; +import 'package:flutter_app/common/data/entity/exception/custom_exception.dart'; import 'package:flutter_app/core/flogger.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_sign_in/google_sign_in.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'sign_in_with_google_use_case.g.dart'; const List _scopes = [ 'email', ]; -final signInWithGoogleUseCase = FutureProvider((ref) async { - Flogger.d('[Authentication] Sign in with Google started'); - - final googleSignIn = GoogleSignIn.instance; - - // Subtitle: Step 1 - Logout from Current Google account if any - await googleSignIn.disconnect(); - - // Subtitle: Step 2 - Authenticate user with Google - final account = await googleSignIn.authenticate(); - - // Subtitle: Step 3 - Authorize scopes - var authorization = await account.authorizationClient.authorizationForScopes(_scopes); - authorization ??= await account.authorizationClient.authorizeScopes(_scopes); - - // Subtitle: Step 4 - Create OAuth credential for Firebase - final oauthCredential = GoogleAuthProvider.credential( - accessToken: authorization.accessToken, - idToken: account.authentication.idToken, - ); - - Flogger.d('[Authentication] Received credential: $oauthCredential'); - - return await ref.read( - signInWithAuthCredentialUseCaseProvider( - credential: oauthCredential, - ).future, - ); -}); +@riverpod +Future signInWithGoogleUseCase(Ref ref) async { + try { + Flogger.d('[Authentication] Sign in with Google started'); + + final googleSignIn = GoogleSignIn.instance; + + // Subtitle: Step 1 - Logout from Current Google account if any + await googleSignIn.signOut(); + + // Subtitle: Step 2 - Authenticate user with Google + final account = await googleSignIn.authenticate(); + + // Subtitle: Step 3 - Authorize scopes + var authorization = await account.authorizationClient.authorizationForScopes(_scopes); + authorization ??= await account.authorizationClient.authorizeScopes(_scopes); + + // Subtitle: Step 4 - Create OAuth credential for Firebase + final oauthCredential = GoogleAuthProvider.credential( + accessToken: authorization.accessToken, + idToken: account.authentication.idToken, + ); + + Flogger.d('[Authentication] Received credential: $oauthCredential'); + + // Subtitle: Step 5 - Sign in with credentials from provider + await FirebaseAuth.instance.signInWithCredential(oauthCredential); + } on GoogleSignInException catch (e) { + Flogger.e('[Authentication] Error during sign in with Google: $e'); + if (e.code == GoogleSignInExceptionCode.canceled) { + throw const CustomException.signInCancelled(); + } else { + throw CustomException.fromErrorObject(error: e); + } + } catch (e) { + Flogger.e('[Authentication] Error during sign in with Google: $e'); + throw CustomException.fromErrorObject(error: e); + } +} diff --git a/lib/common/usecase/authentication/sign_out_use_case.dart b/lib/common/usecase/authentication/sign_out_use_case.dart index 383c4df..ce8c53b 100644 --- a/lib/common/usecase/authentication/sign_out_use_case.dart +++ b/lib/common/usecase/authentication/sign_out_use_case.dart @@ -16,11 +16,13 @@ Future signOutUseCase(Ref ref) async { // Title: Try to sign out from Google - if any try { - await GoogleSignIn.instance.disconnect(); + await GoogleSignIn.instance.signOut(); } on MissingPluginException catch (error) { - Flogger.d('[Authentication] MissingPluginException $error'); + Flogger.e('[Authentication] Sign Out MissingPluginException $error'); } on PlatformException catch (error) { - Flogger.d('[Authentication] PlatformException $error'); + Flogger.e('[Authentication] Sign Out PlatformException $error'); + } on Exception catch (error) { + Flogger.e('[Authentication] Sign Out Exception $error'); } // Title: Sign out from Firebase diff --git a/lib/features/authentication/authentication_page_content.dart b/lib/features/authentication/authentication_page_content.dart index 7d1badf..8a4f38c 100644 --- a/lib/features/authentication/authentication_page_content.dart +++ b/lib/features/authentication/authentication_page_content.dart @@ -36,7 +36,6 @@ class _DataStateWidget extends ConsumerWidget { const Spacer(), CustomButtonPrimary( text: 'Mock Sign In', - isLoading: data.isSigningIn, onPressed: () async { await ref.read(signInCompletionUseCaseProvider.future); if (context.mounted) await context.router.replaceAll([const LandingRoute()]); @@ -45,19 +44,18 @@ class _DataStateWidget extends ConsumerWidget { const SizedBox(height: 48), CustomButtonPrimary( text: 'Sign in Anonymously', - isLoading: data.isSigningIn, onPressed: () => ref.read(authenticationStateProvider.notifier).signInAnonymously(), ), const SizedBox(height: 24), CustomButtonPrimary( text: 'Sign in with Google', - isLoading: data.isSigningIn, + isLoading: data.isGoogleSigningIn, onPressed: () => ref.read(authenticationStateProvider.notifier).signInWithGoogle(), ), const SizedBox(height: 8), CustomButtonPrimary( text: 'Sign in with Apple', - isLoading: data.isSigningIn, + isLoading: data.isAppleSigningIn, onPressed: () => ref.read(authenticationStateProvider.notifier).signInWithApple(), ), ], diff --git a/lib/features/authentication/authentication_state.dart b/lib/features/authentication/authentication_state.dart index 1ec3b00..49f6b65 100644 --- a/lib/features/authentication/authentication_state.dart +++ b/lib/features/authentication/authentication_state.dart @@ -1,12 +1,12 @@ import 'package:flutter_app/common/data/entity/exception/custom_exception.dart'; -import 'package:flutter_app/common/data/entity/user_entity.dart'; +import 'package:flutter_app/common/provider/current_user_state.dart'; import 'package:flutter_app/common/usecase/authentication/sign_in_anonymously_use_case.dart'; +import 'package:flutter_app/common/usecase/authentication/sign_in_completion_use_case.dart'; import 'package:flutter_app/common/usecase/authentication/sign_in_with_apple_use_case.dart'; import 'package:flutter_app/common/usecase/authentication/sign_in_with_google_use_case.dart'; import 'package:flutter_app/core/flogger.dart'; import 'package:flutter_app/core/riverpod/state_handler.dart'; import 'package:flutter_app/features/authentication/authentication_event.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -16,7 +16,8 @@ part 'authentication_state.g.dart'; @freezed abstract class AuthenticationState with _$AuthenticationState { const factory AuthenticationState({ - required bool isSigningIn, + required bool isGoogleSigningIn, + required bool isAppleSigningIn, }) = _AuthenticationState; } @@ -25,27 +26,39 @@ class AuthenticationStateNotifier extends _$AuthenticationStateNotifier with Aut @override FutureOr build() async { return const AuthenticationState( - isSigningIn: false, + isGoogleSigningIn: false, + isAppleSigningIn: false, ); } Future signInAnonymously() async { - await _signInWithProvider(signInAnonymouslyUseCase); + final provider = ref.read(signInAnonymouslyUseCaseProvider.future); + await _signInCompletion(provider); } Future signInWithGoogle() async { - await _signInWithProvider(signInWithGoogleUseCase); + setStateData(currentData?.copyWith(isGoogleSigningIn: true)); + final provider = ref.read(signInWithGoogleUseCaseProvider.future); + await _signInCompletion(provider); + setStateData(currentData?.copyWith(isGoogleSigningIn: false)); } Future signInWithApple() async { - await _signInWithProvider(signInWithAppleUseCase); + setStateData(currentData?.copyWith(isAppleSigningIn: true)); + final provider = ref.read(signInWithAppleUseCaseProvider.future); + await _signInCompletion(provider); + setStateData(currentData?.copyWith(isAppleSigningIn: false)); } - Future _signInWithProvider(FutureProvider provider) async { - setStateData(currentData?.copyWith(isSigningIn: true)); - + Future _signInCompletion(Future provider) async { try { - await ref.read(provider.future); + await provider; + + // Sign in completion on BE + final user = await ref.read(signInCompletionUseCaseProvider.future); + + // Update current user + await ref.read(currentUserStateProvider.notifier).updateCurrentUser(user); // Sign in success ref.read(authenticationEventNotifierProvider.notifier).send(const AuthenticationEvent.signedIn()); @@ -61,7 +74,5 @@ class AuthenticationStateNotifier extends _$AuthenticationStateNotifier with Aut }, ); } - - setStateData(currentData?.copyWith(isSigningIn: false)); } }