Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Refactor Sealed classes - private classes, use generated `when` function instead of switch.

<!-- ################################################## -->
Expand Down
2 changes: 2 additions & 0 deletions lib/common/data/entity/exception/custom_exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
23 changes: 13 additions & 10 deletions lib/common/usecase/authentication/sign_in_anonymously_use_case.dart
Original file line number Diff line number Diff line change
@@ -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<UserEntity>((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<void> 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);
}
}
46 changes: 24 additions & 22 deletions lib/common/usecase/authentication/sign_in_completion_use_case.dart
Original file line number Diff line number Diff line change
@@ -1,36 +1,38 @@
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';

part 'sign_in_completion_use_case.g.dart';

@riverpod
Future<UserEntity> 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);
}
}
78 changes: 48 additions & 30 deletions lib/common/usecase/authentication/sign_in_with_apple_use_case.dart
Original file line number Diff line number Diff line change
@@ -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<UserEntity>((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<void> 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);
}
}

This file was deleted.

75 changes: 43 additions & 32 deletions lib/common/usecase/authentication/sign_in_with_google_use_case.dart
Original file line number Diff line number Diff line change
@@ -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<String> _scopes = <String>[
'email',
];

final signInWithGoogleUseCase = FutureProvider<UserEntity>((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<void> 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.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');

// 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);
}
}
6 changes: 4 additions & 2 deletions lib/common/usecase/authentication/sign_out_use_case.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ Future<void> signOutUseCase(Ref ref) async {
try {
await GoogleSignIn.instance.disconnect();
} 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
Expand Down
2 changes: 2 additions & 0 deletions lib/features/authentication/authentication_event.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter_app/common/data/entity/exception/custom_exception.dart';
import 'package:flutter_app/core/riverpod/event_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
Expand All @@ -7,6 +8,7 @@ part 'authentication_event.freezed.dart';
@freezed
sealed class AuthenticationEvent with _$AuthenticationEvent {
const factory AuthenticationEvent.signedIn() = AuthenticationEventSignedIn;
const factory AuthenticationEvent.error(CustomException exception) = _Error;
}

final authenticationEventNotifierProvider = NotifierProvider.autoDispose<EventNotifier<AuthenticationEvent?>, AuthenticationEvent?>(
Expand Down
6 changes: 2 additions & 4 deletions lib/features/authentication/authentication_page_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);
Expand All @@ -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(),
),
],
Expand Down
Loading