Step by Step Guide to Flutter Authorization (with BLoC)

June 14, 2022

Authorization is the core feature of almost every application. It is responsible for checking user’s permissions to get content from the database or modify it. In the front-end, we usually send credentials using the login or registration page, and then the application allows us (or not ;)) to see the next screens.

There are many different services to authorize users. Some are connected with Flutter’s plugins like Amplify or Firebase. But sometimes, we want to authorize users using custom backend solutions, and we need to create all elements from scratch. What do we need to do step by step?

In this article, I want to share with you a solution with BLoC to authorize user, get and save token, refresh token and log the user out. The UI is up to you, and it’s not in the scope of this article.

DISCLAIMER:

This example was created based on a real solution used in a production app. There is no repo included in this article - it's only a place where I share with you an info of what elements are in my opinion important when you are creating simple authorization and how you can approach this using BLoC. It's not a recipe, you need to fit these steps to your needs based on authorization you are using.

Elements to Create

The most often used elements are:

  • registration method
    for new users (if possible in an app): sends credentials - e.g., email + password with confirmation and returns the token
  • login method
    for existing users: sends credentials - e.g., email + password and returns the token
  • logout method
    depends on the backend - sometimes we need to manage logout inside of the app only, sometimes we send some information about token to logout user on the backend side too
  • refresh token method
    used to provide a new token every time the user opens an app - when they were logged in before - to avoid asking for credentials all the time

To create an authorization module inside the app, we need five elements:

  1. model → creating token model received from database
  2. interface → with methods for service
  3. service → connects us with the backend
  4. controllers → one cubit for login methods and one for managing token
  5. dio client → configuration for Dio with interceptor to trigger refreshing token on unauthorized error

Plugins used in this solution:

  • dio (http client)
  • flutter_bloc (for creating controllers - cubits)
  • hydrated_bloc (for creating a cubit which can save its state - for saving token)
  • freezed (for creating models and states of cubits)
  • get_it + injectable (for dependency injection)
  • dartz (for handling errors)

Token Model

Let’s start. What’s the first thing we need? Token model, of course! (depending on API or identity provider it can look different - this one is only a simple example)

import 'package:freezed_annotation/freezed_annotation.dart';
part 'token.freezed.dart';
part 'token.g.dart';

@freezed
class Token with _$Token {
    factory Token({
        required String accessToken,
        required String refreshToken,
        required DateTime expiredDate,
        required String idToken,
    }) = _Token;
    factory Token.fromJson(Map<String, dynamic> json) =>
        _$TokenFromJson(json);
}

Ok, so we have the token here! Let’s stop for a while and look at its elements.

The first string is accessToken - this is the one we need to add to the authorization header of the endpoints to prove that we are authenticated (without this, we will receive a 401 error from the backend).

The second one - refreshToken - is the one we need to use when our accessToken expires. RefreshToken is sent to the backend, and we can receive new accessToken in response.

The third element is an accessToken expiry date. After this date, our accessToken will be invalid, and if we don’t refresh it, we won’t be able to see any data.

The fourth one is used here to show an example with logout. IdToken is used by identity providers like OpenId Connect, Google, Facebook, and Auth0. It’s an artifact proving the user has been authenticated.

But wait! What’s the difference? Why two tokens? In short - idToken is a proof that a user has been authenticated (it can contain user data), and accessToken is a proof that the app has been authorized (and it’s used to call an API and check if the app is allowed to do something).

In this example, we will use idToken to send a logout request in service.

AuthException Model

Ok, the token is ready. Let’s create an AuthException model for handling errors when something goes wrong. It’s really important to let the user know what’s going on when we are having problems in an application! It could be information about a lack of Internet connection or just info about the wrong email address or password.

Here in this example we will not cover all exceptions and problems, but when you will build your authorization, you should remember about exception handling 🙂

import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_exception.freezed.dart';

@freezed
class AuthException with _$AuthException {
    const factory AuthException.serverError() = _ServerError;
    const factory AuthException.unauthorized() = _Unauthorized;
    const factory AuthException.unknown() = _Unknown;
    const factory AuthException.internetConnectionUnavailable() =
        _InternetConnectionUnavailable;
    const factory AuthException.wrongEmailOrPass() =
        _WrongEmailOrPass;
}

extension AuthExceptionX on AuthException {
    String get description {
        if (this is _Unauthorized) {
            return "Sorry, your session has expired. Please sign in again";
        } else if (this is _InternetConnectionUnavailable) {
            return "Sorry, there’s a problem with your internet connection. Please check your connection and try again.";
        } else if (this is _WrongEmailOrPass) {
            return "An email or password are incorrect";
        } else {
            return "Sorry, there’s a problem with the server connection. Please try again later.";
        }
    }
    bool get isUnauthorized => this is _Unauthorized;
}

If you prefer, you can create a shorter extension using the maybeWhen method like this:

extension AuthExceptionX on AuthException {
    String get description => maybeWhen(unauthorized: () => "Sorry, your session has expired. Please sign in again", internetConnectionUnavailable: () => "Sorry, there’s a problem with the server connection. Please try again later.", wrongEmailOrPass: () => "An email or password are incorrect", orElse: () => "Oh, an error occured, please try again later");
    bool get isUnauthorized => this is _Unauthorized;
}    

As you can see, we can use the freezed package here to create a union with various exceptions. I added an extension to let the user know what went wrong.

Interface

Now we can implement an interface and service (to register, login, and logout user).

An interface is an abstract class. Why do we need it? It’s essential for testing our classes and creating a test repository. An interface holds all the methods for us so that we remember about all of them. This is crucial when creating a service. Of course, we will return Eithers - the elements from the dartz package giving us either an Exception if something goes wrong or an Object when everything goes right.

import 'package:dartz/dartz.dart';
import 'package:my_app/models/auth_exception.dart';
import 'package:my_app/models/token.dart';
abstract class AuthInterface {
    Future<Either<AuthException, Token>> register(String email, String pass, String confirmPass);
    Future<Either<AuthException, Token>> login(String email, String pass);
    Future<Either<AuthException, Unit>> logout(String idToken);
    Future<Either<AuthException, Token>> refreshToken(String refreshToken);
}

Service

Let’s prepare dio client (the first part). We want to use it in the whole app so we inject it using getIt package. First we want to add base url only to connect with our backend.

import 'dart:async';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';

@Singleton()
class DioClient {
    late Dio _dio = Dio();
    
    DioClient() {
        _dio.options = BaseOptions(
        baseUrl: BASE_URL, // the place for your base url
        receiveTimeout: 10000,
        connectTimeout: 10000,
        sendTimeout: 10000,
        );
    }

    Dio get dio => _dio;
}

Now we can prepare service based on interface using dio client.

import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import 'package:my_app/models/auth_exception.dart';
import 'package:my_app/interface/auth_interface.dart';
import 'package:my_app/core/dio_client.dart';

@LazySingleton(as: AuthInterface)
class AuthService implements AuthInterface {
  AuthService();

  final dioClient = getIt.get<DioClient>();
  
  @override
  Future<Either<AuthException, Token>> register(String email, String pass, String confirmPass) async {
    try {
      final Token result =
      await dioClient.dio.post(
        YOUR_URL, body: {email: email, pass: pass, confirmPass: confirmPass});
      if (result.data != null) {
        return Right(result);
      } else {
        return const Left(AuthException.wrongEmailOrPass());
      } // here - I’m using API that gives me:
        // status code 200 and data: token and message: ‘ok’ when credentials are ok
        // status code 200 and data: null with the message: “email has been already taken”
        // Of course, you can have different data in response from your backend
    } on DioError catch (e, s) {
      //this is the place you can log your error e.g. in Sentry, Firebase Crashlytics, etc.
      return const Left(AuthException.serverError());
    }
  }

  @override
  Future<Either<AuthException, Token>> login(String email, String pass) async {
    try {
      final Token result =
      await dioClient.dio.post(
        YOUR_URL, body: {email: email, pass: pass});
      if (result.data != null) {
        return Right(result);
      } else {
        return const Left(AuthException.wrongEmailOrPass());
      }
    } on DioError catch (e, s) {
      return const Left(AuthException.serverError());
    }
  }

  @override
  Future<Either<AuthException, Unit>> logout(String idToken) async {
    try {
      final Token result =
      await dioClient.dio.post(
        YOUR_URL, body: {idToken: idToken});
      return right(unit);
    } on DioError catch (e) {
      return const Left(AuthException.serverError());
    }
  }

  @override
  Future<Either<AuthException, Token>> refresh(String refreshToken) async {
    try {
      final Token result =
      await dioClient.dio.post(
        YOUR_URL, body: {refreshToken: refreshToken});
      return Right(result);
    } on DioError catch (e, s) {
      return const Left(AuthException.serverError());
    }
  }
}

Controllers

Ok, we have a service. It’s time to use its methods in controllers to manage state (is the user authorized or not) and then go back to the DioClient to add accessToken to the header when the user is authorized.

First, we will create a hydrated cubit to store the token and its state. Why separate cubit for token only? It’s a good practice to only have the token in one place in the app. This controller will be responsible for storing the token and refreshing it when it is invalidated.

Let’s build the state first with all helper methods:

part of 'token_cubit.dart';
@freezed
class TokenState with _$TokenState {
  const factory TokenState.initial() = _Initial;
  const factory TokenState.authorized(Token token) = _Authorized;
  // we need only two states - initial (which means unauthorized) and authorized
  factory TokenState.fromJson(Map<String, dynamic>? json) {
    if (json == null) {
      return const TokenState.initial();
    } else {
      switch (json['type'] as String) {
        case 'authorized':
          return TokenState.authorized(
            Token(
              json['accessToken'] as String,
              json['refreshToken'] as String,
              DateTime.parse(json['expiration'] as String),
              json['idToken'] as String),
            );
        case 'initial':
          return const TokenState.initial();
      }
    }
    return const TokenState.initial();
  } // this factory is for hydrated bloc (which converts state to json and saves it to the storage - hive storage by default, but you can use whatever storage you want)
}

extension TokenStateX on TokenState {
  Token? get token => maybeWhen(authorized: (token) => token, orElse: () => null);
  bool isInitial() => maybeWhen(orElse: () => false, initial: () => true); //convenient way to check if user is unauthorized
  Map<String, String> toAuthorizationHeader() => maybeWhen(
    authorized: (token) => {
      "Authorization": "Bearer ${token.accessToken}",
    },
    orElse: () => {},
  ); //method to create header for dio easily
  Map<String, dynamic> json() => when(
    initial: () => {'type': 'initial'},
    authorized: (token) => {
      'type': 'authorized',
      'accessToken': token.accessToken,
      'refreshToken': token.refreshToken,
      'expiration': token.accessTokenExpirationDateTime?.toIso8601String(),
      'idToken': token.idToken,
      'tokenType': token.tokenType
    },
  );
}

And now we can create hydrated cubit → tokenCubit:

import 'package:my_app/interface/auth_interface.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:injectable/injectable.dart';
part 'token_cubit.freezed.dart';
part 'token_state.dart';

@LazySingleton()
class TokenCubit extends HydratedCubit<TokenState> {
  final AuthInterface service;
  TokenCubit(this.service) : super(const TokenState.initial());
  
  void logout() {
    emit(const AuthorizationState.initial());
  } //in this cubit we will only change token state after logout - real method will be invoked in login cubit
  
  void setToken(AuthorizationTokenResponse token) {
    emit(AuthorizationState.authorized(token));
  } //like above - real method (login and register) will be invoked in login cubit - here we only manage token state

  Future<Token?> refreshToken(Token token) async {
    final newToken = await service.refreshToken(token.refreshToken!);
    if (newToken.isRight()) {
      final newTokenResponse = newToken.getRightOrThrow();
      final authToken = newToken.fold((_)  throw Exception(Left is not Right), id);
      emit(AuthorizationState.authorized(authToken));
      return authToken;
    } else {
      emit(const AuthorizationState.initial());
      return null;
    }
  }

  @override
  AuthorizationState fromJson(Map<String, dynamic>? json) {
    return AuthorizationState.fromJson(json);
  }

  @override
    Map<String, dynamic>? toJson(AuthorizationState state) {
      return state.json();
    } //those two methods are required in hydrated cubit
  }
}

As you can see, in this cubit, we invoke only one method from the service - refreshToken. The rest is responsible for managing the state of the token only. Those methods will be used in the login cubit responsible for the user’s registration, login, and logout. When the user is authorized or logged out, token cubit’s methods will be used. Let’s create a login state and login cubit to see how it works.

part of 'login_cubit.dart';
@freezed
class LoginState with _$LoginState {
  const factory LoginState.initial() = _Initial;
  const factory LoginState.loading() = _Loading;
  const factory LoginState.authorized() = _Authorized;
  const factory LoginState.error(AuthException error) = _Error;
}

and cubit:

import 'package:bloc/bloc.dart';
import 'package:my_app/authorization_controllers/token_cubit.dart';
import 'package:my_app/models/auth_exception.dart';
import 'package:my_app/interface/auth_interface.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
part 'login_cubit.freezed.dart';
part 'login_state.dart';

@LazySingleton()
class LoginCubit extends Cubit<LoginState> {
  final AuthInterface service;
  final TokenCubit tokenCubit;
  
  LoginCubit(this.service, this.tokenCubit)
    : super(const LoginState.initial());
  //you can create a separate bloc for credentials or get them from textControllers in the UI - it’s up to you. Here we just get credentials from form and controllers (and validate them before register or login methods are invoked).
  
  Future<void> register(String email, String pass, String confirmPass) async {
    emit(const LoginState.loading());
    final token = await service.login(String email, String pass, String confirmPass);
    token.fold((l) => emit(LoginState.error(l)), (r) {
      tokenCubit.setToken(r); //this is the place we use token cubit method
      emit(const LoginState.authorized());
    });
  } //if you have more complex register form, e.g.. with many screens, it will be better to create separate bloc to  manage this and store variables in bloc’s state. Here I use only very simple method which gets credentials from the form
  
  Future<void> login(String email, String pass) async {
    emit(const LoginState.loading());
    final token = await service.login(String email, String pass);
    token.fold((l) => emit(LoginState.error(l)), (r) {
      tokenCubit.setToken(r);
      emit(const LoginState.authorized());
    });
  }
  
  Future<void> logout() async {
    final token = tokenCubit.state.token;
    emit(const LoginState.loading());
    await service.logout(token?.idToken);
    tokenCubit.logout();
    emit(const LoginState.initial());
  }
}

Dio Client

Ok. At this stage, we have an interface, service, and two controllers. Now it’s time to finish implementing the dioClient. Firstly we will add a stream subscription to observe token cubit and add accessToken to the header when the user is authorized. Then we will create an interceptor to catch an unauthorized error and invoke the refresh token method every time the accessToken expires. After these changes, our dioClient file will look more or less like this (of course, this is an example with only one interceptor, you can add more depending on your project).

import 'dart:async';
import 'package:dio/dio.dart';
import 'package:my_app/authorization_controllers/token_cubit.dart';
import 'package:injectable/injectable.dart';

@Singleton()
class DioClient {
  late Dio _dio = Dio();
  TokenCubit tokenCubit;
  late StreamSubscription tokenCubitSubscription;
  
  DioClient(this.tokenCubit) {
    _dio.options = BaseOptions(
    baseUrl: BASE_URL,
    receiveTimeout: 10000,
    connectTimeout: 10000,
    sendTimeout: 10000,
    );
  
  //add interceptor and when error is unauthorized - 401 - refresh token and set proper one as a header
    _dio.interceptors.add(
      InterceptorsWrapper(
        onError: (e, handler) async {
          if (!tokenCubit.state.isInitial() && e.response?.statusCode == 401) {
            if (e.response != null) {
              final result = await tokenCubit.refreshToken(tokenCubit.state.token!);
              if (result != null) {
                addToken(_dio, tokenCubit.state); *//the method to add token as a header defined below*
                final opts = Options(method: e.requestOptions.method);
                final response = await _dio.request(
                  e.requestOptions.path,
                  options: opts,
                  cancelToken: e.requestOptions.cancelToken,
                  onReceiveProgress: e.requestOptions.onReceiveProgress,
                  data: e.requestOptions.data,
                  queryParameters: e.requestOptions.queryParameters,
                );
              handler.resolve(response); *//invoke request again with new token*
              }
            }
          } else {
            //a place to handle other errors
            handler.reject(e);
          }
        },
      ),
    );
  
    //at the start of the app, inside constructor checks if user is authorized and if yes - adds the token to the header
    if (!tokenCubit.state.isInitial()) {
      addToken(_dio, tokenCubit.state);
    }

    //listens to changes of the token cubit and sets token to header when state changes to authorized
    tokenCubitSubscription = tokenCubit.stream.listen((event) {
      if (!event.isInitial()) {
        addToken(_dio, tokenCubit.state);
      }
    });
  }
  Dio get dio => _dio;
  void dispose() {
    tokenCubitSubscription.cancel();
  }
  void addToken(Dio dio, TokenState state) {
    dio.options.headers.addAll(state.toAuthorizationHeader());
  }
}

Summary

And this is it. You have register, login, and logout methods in the login cubit. They’re ready to be used in the UI. The token is managed (stored and refreshed) by the tokenCubit and dioClient. You don’t need to worry about it anymore. Your simple authorization with BLoC is ready. Of course, there are many options not covered by this article. You can build authorization with more complex solutions (like email codes, phone numbers, more steps, more values) when this simple authorization is not enough. But you can still use some concepts covered here and simply adjust them to your needs.

You can, of course, ask about other state managers? The answer is simple. You can replace controllers with Provider, Riverpod, or any state manager you want. I used BLoC here only as an example. It’s up to you what state manager to use and what will be the most efficient one for your app.

A Bonus

And one quick bonus info before we are done:

JWT token - how to use it in the Flutter app?

There is one more thing not used in the example above. It’s the JWT token. What is a JWT token, and how to use it? JWT tokens are the most often used tokens. In our example, the accessToken and idToken are JWT (but here, we don’t use the information they have)

JWT means “JSON web token.” Sometimes the JWT has some user information encoded (e.g., permissions, user data, etc.). You can easily decode your app’s JWT (which you get from the backend) using https://jwt.io and check the information it stores. To use this info in Flutter without additional requests, you can decode JWT using jwt_decoder package or create decoding methods - JWT is encoded with Base64.

Have a nice day, keep calm and code on 😊

What You have to say about this - don't hesitate to comment :)

comments powered by Disqus