Skip to content
GIfatahTH edited this page Sep 22, 2021 · 9 revisions

Authentication and authorization are yet other common tasks that states_rebuilder encapsulates to hide their implementation details and expose a clean and simple API to handle sign up, sign in, sign out, auto-sign in with a cached user, or auto sign out when a token has expired.

Table of Contents

IAuth interface:

Similar to RM.injectCRUD, you need to implement the IAuth interface.

class AuthRepository implements IAuth<User?, UserParam> {
  @override
  Future<void> init() {
    // Initialize pluggings
  }

  @override
  Future<User?> signUp(UserParam? param) async {
    // Sign app
    //You can call many signing up provider here.
    //use param to distinguish them
  }

  @override
  Future<User?> signIn(UserParam? param) {
    // sign in
  }

  @override
  Future<void> signOut(UserParam? param) {
    // Sign out
  }
  
  @override
  Future<User?> refreshToken(User? currentUser) {
    // Refresh token logic
  }

  @override
  void dispose() {
    // Dispose resources
  }
}

This is an example of an app that uses firebase auth.

Click to expand!
class UserParam {
  final String email;
  final String password;
  final SignIn signIn;
  final SignUp signUp;
  UserParam({
    this.email,
    this.password,
    this.signIn,
    this.signUp,
  });
}

enum SignIn {
  anonymously,
  withApple,
  withGoogle,
  withEmailAndPassword,
  currentUser,
}
enum SignUp { withEmailAndPassword }


class UserRepository implements IAuth<User?, UserParam> {
  final FirebaseAuth _firebaseAuth = FirebaseAuth.instance;
  final GoogleSignIn _googleSignIn = GoogleSignIn();

  @override
  Future<void> init() async {}

  @override
  Future<User?> signUp(UserParam param) {
    switch (param.signUp) {
      case SignUp.withEmailAndPassword:
        return _createUserWithEmailAndPassword(
          param.email,
          param.password,
        );
      default:
        throw UnimplementedError();
    }
  }

  @override
  Future<User?> signIn(UserParam param) {
    switch (param.signIn) {
      case SignIn.anonymously:
        return _signInAnonymously();
      case SignIn.withApple:
        return _signInWithApple();
      case SignIn.withGoogle:
        return _signInWithGoogle();
      case SignIn.withEmailAndPassword:
        return _signInWithEmailAndPassword(
          param.email,
          param.password,
        );
      case SignIn.currentUser:
        return _currentUser();

      default:
        throw UnimplementedError();
    }
  }

  @override
  Future<void> signOut(UserParam param) async {
    final GoogleSignIn googleSignIn = GoogleSignIn();
    await googleSignIn.signOut();
    return _firebaseAuth.signOut();
  }

  @override
  Future<User?>? refreshUser(User? currentUser) async {
    //Firebase sdk handle token refresh
  }

  @override
  void dispose() {
    // TODO: implement dispose
  }

  Future<User?> _signInWithEmailAndPassword(
    String email,
    String password,
  ) async {
    try {
      final AuthResult authResult =
          await _firebaseAuth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
      return _fromFireBaseUserToUser(authResult.user);
    } catch (e) {
      if (e is PlatformException) {
        throw SignInException(
          title: 'Sign in with email and password',
          code: e.code,
          message: e.message,
        );
      } else {
        rethrow;
      }
    }
  }

  Future<User?> _createUserWithEmailAndPassword(
    String email,
    String password,
  ) async {
    try {
      AuthResult authResult =
          await _firebaseAuth.createUserWithEmailAndPassword(
        email: email,
        password: password,
      );
      return _fromFireBaseUserToUser(authResult.user);
    } catch (e) {
      if (e is PlatformException) {
        throw SignInException(
          title: 'Create use with email and password',
          code: e.code,
          message: e.message,
        );
      } else {
        rethrow;
      }
    }
  }

    .
    .
    .
}

InjectedAuth

suppose our InjectedAuth state is named user.

InjectedAuth<T, P> user =  RM.injectAuth<T, P>(
    IAuth<T, P> Function() repository, {
    T? unsignedUser,
    P Function()? param,
    Duration Function(T user)? autoRefreshTokenOrSignOut,
    FutureOr<Stream<T>> Function(IAuth<T, P> repo)? onAuthStream,
    PersistState<T> Function()? persist,
    //
    void Function(T s)? onSigned,
    void Function()? onUnsigned,
    SnapState<T>? Function(SnapState<T> currentSnap, SnapState<T> nextSnap)?
        stateInterceptor,
    SideEffects<T>? sideEffects,
    //
    String? debugPrintWhenNotifiedPreMessage,
    String Function(T?)? toDebugString,
  })

repository:

This is the repository that implements the IAuth interface.

unsignedUser

This is the unsignedUser object. It is used internally in the decision logic of the library. Usually, the UnsignedUser is null or extends your User class.

example:

class User{
  final String name;
  .
  .
}

class UnsignedUser extends User{}

param:

This is the default param. It is used to parametrize the query that is sent to the backend to authenticate. It may be used to switch between many authentication providers. signUp, signIn, and signOut methods may override it. (See later).

autoRefreshTokenOrSignOut

A callback that exposes the signed user and returns a duration. A timer is set to the return duration, and when the timer ends, the user refreshes the token or sign out.

The duration can be obtained from the exposed user token.

final user = RM.injectAuth<User?, UserParam>(
  () => FireBaseAuth(),
  autoRefreshTokenOrSignOut: (user) {
    //get time to expire from the exposed user
    final timeToExpiry = user.token.expiryDate
        .difference(
          DateTime.now(),
        )
        .inSeconds;
    //Return a Duration object
    return Duration(seconds: timeToExpiry);
  },
);

onAuthStream

It is used to listen to a stream from the repository. The stream emits the value of the currentUser. Depending on the emitted user, sign in or sign out hooks will be invoked.

persist

When persist is defined the signed user information is persisted and when the app starts up, the user information is retrieved from the local storage and it is automatically signed in if it has no expired token.

Example:

final user = RM.injectAuth<User, UserParam>(
  () => FireBaseAuth(),
  unsignedUser: UnsignedUser(),
  persist: () => PersistState<User>(
    key: '__User__',
    toJson: (user) => user.toJson(),
    fromJson: (json) {
      final user = User.fromJson(json);
      return user.token.isNotExpired ? user : UnsignedUser();
    },
  ),
  autoRefreshTokenOrSignOut: (user) {
    //get time to expire from the exposed user
    final timeToExpiry = user.token.expiryDate
        .difference(
          DateTime.now(),
        )
        .inSeconds;
    //Return a Duration object
    return Duration(seconds: timeToExpiry);
  },
);

onSigned:

Hook to be invoked when the user is signed in or up. It exposes the current user. This is the right place to navigate to the home or user page. This onSigned is considered as the default callback when the user is signed. It can be overridden when calling signIn and signUp methods (See later).

onUnsigned:

Hook to be invoked when the user is signed out. This is the right place to navigate to the auth page.

example:

final user = RM.injectAuth(
  () => UserRepository(),
  unsignedUser: UnSignedUser(),
  onSigned: (_) => RM.navigate.toReplacement(HomePage()),
  onUnsigned: () => RM.navigate.toReplacement(SignInPage()),
  //Display error message on signing failure
  sideEffects: SideEffects.onError(
    (err) => AlertDialog(
      title: Text(ExceptionsHandler.errorMessage(err).title),
      content: Text(ExceptionsHandler.errorMessage(err).message),
    ),
  ),
);

For the remainder of the parameters see Injected API.

OnAuthBuilder

To listen to the InjectedAuth state use OnAuthBuilder

OnAuthBuilder(
    listenTo: user,
    onInitialWaiting: ()=> Text('Waiting  on initial..')
    onWaiting: ()=> Text('Waiting..'),
    onUnsigned: ()=> AuthPage(),
    onSigned: ()=> HomeUserPage(),
    useRouteNavigation: false,
    sideEffects: SideEffects( ... ),
    key: Key(),
);

OnAuthBuilder listens to the [InjectedAuth] and waits until the authentication ends.

onInitialWaiting is called once when the auth state is initialized.

onWaiting is called whenever the auth state is waiting for authentication.

onUnsigned is called when the user is signed out. Typically used to render Auth page.

onSigned is called when the user is signed. Typically used to render User home page.

By default, the switch between the onSinged and the onUnsigned pages is a simple widget replacement. To use the navigation page transition animation, set [userRouteNavigation] to true. In this case, you need to set the [RM.navigate.navigatorKey].


## signUp
To signUp, mutate the state and notify the listener, you use the signUp method.

```dart
Future<T> user.auth.signUp(
  P Function(P? parm)? param, {
    void Function()? onAuthenticated, 
    void Function(dynamic err)? onError
  }
)

param

If param is not defined the default param as defined when injecting the state is used.

The exposed Parm in the callback is the default param, you can use it to copy it and return a new param to be used for this particular call.

onAuthenticated

Called when use is signed up successfully. If it is defined here, it will override the onSigned callback defined globally when injecting the user.

onError

Called when the sign up fails, it exposes the thrown error.

example:

user.auth.signUp(
  (_) => UserParam(
    signUp: SignUp.withEmailAndPassword,
    email: _email.state,
    password: _password.state,
  ),
);

signIn

Future<T> signIn(
    P Function(P? param)? param, {
    void Function()? onAuthenticated,
    void Function(dynamic error)? onError,
})

Similar to signUp.

example:

user.auth.signIn(
  (_) => UserParam(signIn: SignIn.withApple),
),


user.auth.signIn(
  (_) => UserParam(signIn: SignIn.withGoogle),
)

user.auth.signIn(
  (_) => UserParam(
    signIn: SignIn.withEmailAndPassword,
    email: _email.state,
    password: _password.state,
  ),
)

signOut

Future<void> signOut({
  P Function(P? param)? param,
  void Function()? onSignOut,
  void Function(dynamic error)? onError,
})

onSignOut

Called when use is signed out successfully. If it is defined here, it will override the onUnsigned callback defined globally when injecting the user.

If the user is persisted, when signing out the persisted user is deleted.

refreshToken

To be able to refresh the token you have first to override the IAuth.refreshToken method.

class UserRepository implements IAuth<User?, UserParam> {
  ....
    @override
     Future<User?>? refreshToken(User? currentUser) async {
       final url = 'https://securetoken.googleapis.com/v1/token?key=$webApiKey';
       
       //The refresh toke is obtained from the exposed currentUser
       final response = await http.post(
         Uri.parse(url),
         body: json.encode(
           {
             'grant_type': 'refresh_token',
             'refresh_token': currentUser!.token.refreshToken,
           },
         ),
       );
   
       if (response.statusCode == 200) {
         final responseData = json.decode(response.body);
   
         return currentUser!.copyWith(
          token: responseData['id_token'],
          refreshToken: responseData['refresh_token'],
          tokenExpiration: DateTime.now().add(
              Duration(seconds: responseData[expires_in] ),
          ),
        );
       }
     }

    // If refresh token is expired return null for unsigned user.
    return null;
   }

    .
    .
    .
}

Now if you defined the autoRefreshTokenOrSignOut parameter in RM.injectAuth, the refreshToken is invoked after the return duration.

If the user is persisted, and when the app starts, it will automatically refresh the token if it is expired.

You can manually refresh the taken by calling user.auth.refreshToken().

In other repositories that request secure date form the server using the token, you should use getter to get the valid auth token and to be sure that it is always the refreshed token:

class MyRepository implements IMyReposInterface {
  //Use getter to bu sure to use the refreshed token
  String get authToken => authBloc.user!.token!;

  @override
  Future<List<Items>> getItems() async {
      final response = await http.get(
        Uri.parse('$baseUrl/$userId.json?auth=$authToken'),
      );
      ..... 
      ..... 
  }
}

Get the repository

Update: Before version 4.1.0 getRepoAs return a Future of the repository. And from version 4.1.0 the getRepoAs return the repository object.

If you have custom defined methods in the repository, you can invoke them after getting the repository.

Example from todo app:

//getting the repository
final repo = user.getRepoAs<FireAuthRepository>();

Testing and injectAuthMock

UPDATE: From version 4.1.0, default mock must be put inside the setUp method.

It is very easy to test an app built with states_rebuilder. You only have to implement your repository with a fake implementation.

Example from todo app:

//Fake implementation of SqfliteRepository
class FakeAuthRepository implements FireAuthRepository {
  
  final dynamic error;
  User fakeUser;
  FakeUserRepository({this.error});

  @override
  Future<void> init() async {}

  @override
  Future<User> signUp(UserParam param) async {
    switch (param.signUp) {
      case SignUp.withEmailAndPassword:
        await Future.delayed(Duration(seconds: 1));
        if (error != null) {
          throw error;
        }
        return User(uid: '1', email: param.email);
      default:
        throw UnimplementedError();
    }
  }

  @override
  Future<User> signIn(UserParam param) async {
    switch (param.signIn) {
      case SignIn.withEmailAndPassword:
        await Future.delayed(Duration(seconds: 1));
        if (error != null) {
          throw error;
        }
        throw SignInException(
          title: 'Sign in with email and password',
        );
        return User(uid: '1', email: param.email);
      case SignIn.anonymously:
      case SignIn.withApple:
      case SignIn.withGoogle:
        await Future.delayed(Duration(seconds: 1));
        if (error != null) {
          throw error;
        }
        return _user;
      case SignIn.currentUser:
        await Future.delayed(Duration(seconds: 2));
        return fakeUser ?? UnSignedUser();

      default:
        throw UnimplementedError();
    }
  }

  @override
  Future<void> signOut(UserParam param) async {
    await Future.delayed(Duration(seconds: 1));
  }

  @override
  void dispose() {}

  User _user = User(
    uid: '1',
    displayName: "FakeUserDisplayName",
    email: '[email protected]',
  );

}

In test:

void main() async {
  setUp((){
    //Default and cross test mock must be put in the setUp method
    user.injectAuthMock(() => FakeAuthRepository());
  });


  testWidgets('test 1', (tester) async {
    .
    .
  });

  testWidgets('test 2', (tester) async {
    //mock with some stored Todos
    user.injectCRUDMock(() => FakeAuthRepository(
       error: Exception('Invalid email or password')
       ));
    
    .
    .
  });