Flutter Keep login state and get Authorization bearer token

eye-catchDart and Flutter

I wrote Flutter Google Login with Firebase before but its implementation doesn’t keep the login state. The login state is actually stored in Firebase but it doesn’t use the info. Therefore, a user always has to do the login process. It is not nice for a user.

We try to solve the problem in this article. The sample movie is at the end of this post.

This article is for mobile. A mobile device stores the login info, and hence signInSilently method works but I couldn’t find a way for the web version. It seems that a refresh token is needed to get a new access-token but Flutter module doesn’t provide it.

I will update this page if I find a solution for it.

Sponsored links

GoogleAuthClient, token and api providers

To use google API, an authorization header needs to be added to the request.

class _GoogleAuthClient extends http.BaseClient {
  final Map<String, String> _headers;
  final _client = new http.Client();

  _GoogleAuthClient(this._headers);

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    request.headers.addAll(_headers);
    return _client.send(request);
  }
}

final tokenProvider = StateProvider<String?>((ref) => null);
final apiProvider = StateProvider<DriveApi?>((ref) {
  final token = ref.watch(tokenProvider);
  if (token == null) {
    return null;
  }

  final headers = {"Authorization": "Bearer $token"};
  return DriveApi(_GoogleAuthClient(headers));
});

The token is null until a user logins to Google. Once it gets a token, a new DriveApi instance is provided.

Login page

The code for the Login page is as follows. It’s not necessary to put logic to go to another page because it is handled in SplashScreen class which is shown later.

If you don’t know about scopes property, the following posts might be helpful.
Flutter Upload data to Google Drive
Flutter Google Login with Firebase

final _google = GoogleSignIn.standard(scopes: [
  DriveApi.driveAppdataScope,
  DriveApi.driveFileScope,
]);

class _LoginPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    _autoLogin(ref);
    return Scaffold(
      appBar: AppBar(
        title: Text("Login page"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ElevatedButton(
              onPressed: () async {
                await _login(ref);
                // not necessary to put nagivator here
              },
              child: const Text("Login"),
            ),
          ],
        ),
      ),
    );
  }

  Future<void> _login(WidgetRef ref) async {
    final googleUser = await _google.signIn();

    if (googleUser == null) {
      return;
    }

    try {
      _afterLoginProcess(ref, googleUser);
    } catch (e) {
      print(e);
    }
  }
}

Future<void> _afterLoginProcess(
  WidgetRef ref,
  GoogleSignInAccount googleUser,
) async {
  final googleAuth = await googleUser.authentication;
  ref.read(tokenProvider.state).state = googleAuth.accessToken;
  final credential = GoogleAuthProvider.credential(
    accessToken: googleAuth.accessToken,
    idToken: googleAuth.idToken,
  );

  final UserCredential loginUser =
      await FirebaseAuth.instance.signInWithCredential(credential);
  assert(loginUser.user?.uid == FirebaseAuth.instance.currentUser?.uid);
}

I tried to store the access token into shared preferences but it didn’t work as expected. It worked until the access token was expired. However, I couldn’t handle the case if a user closes the app and opens it again after the access token is expired. If the access token is expired, we need to re-authenticate. It means that we need to call either signIn() or signInSilently() method.

Prepare a Splash screen

Go to another page depending on the current state

What we want to achieve is to open

  • login page when the user has not login
  • home page when the user has already login

To check the current state, the app must fetch the data to Firebase which is done asynchronously. While it’s in progress, our app shows a splash screen. To make it simple, it just shows a text.

class GoogleAutoLogin extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SplashScreen();
  }
}

class SplashScreen extends ConsumerWidget {
  const SplashScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Don't add await keyword
    _onAuthStateChange(context, ref);
    return const Scaffold(
      body: Center(child: Text("your splash screen")),
    );
  }

  Future<void> _onAuthStateChange(BuildContext context, WidgetRef ref) async {
    // this delay is used to make the view visible
    Future.delayed(Duration(seconds: 1), () {
      FirebaseAuth.instance.authStateChanges().listen((user) async {
        try {
          if (user == null) {
            navigatorKey.currentState?.pushReplacement(
              MaterialPageRoute(
                builder: (context) => _LoginPage(),
              ),
            );
            return;
          }

          navigatorKey.currentState?.pushReplacement(
            MaterialPageRoute(
              builder: (context) => _Home(),
            ),
          );
        } catch (e) {
          print(e);
        }
      });
    });
  }
}

_onAuthStateChange returns Future but we don’t add await keyword in the build method because it doesn’t need the result. FirebaseAuth.instance.authStateChanges().listen() notifies the current user. user is null at first but it is not null if a user has logged in before.
Every time the login state changes, it triggers the listener. It means that page transition is handled here when a user does login/logout. We don’t have to add additional code to the other pages.

You might think why it’s not using Navigator.of(context). Once page transition is done here, the context will be no longer valid. However, the listener is triggered if the login state changes. As a result, an error Null check operator used on a null value occurs here because the context is not valid. We want to do the page transition without using context. For that reason, the global navigator is used here.

The navigator key must be specified in MaterialApp.

final navigatorKey = new GlobalKey<NavigatorState>();

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  runApp(
    ProviderScope(
      child: MaterialApp(
        ... // something else
        navigatorKey: navigatorKey,
      ),
    ),
  );
}

Handling auto login after re-opening the app

When a user is not logged in, the login page is shown and a user does log in. The app can get an access token for Google API and tokenProvider is updated. Then, DriveApi instance is provided with the right token. However, the state is initialized when a user closes the app. If the user doesn’t log out, the login page is not shown because FirebaseAuth.instance.authStateChanges().listen() gives us a login user. The app shows the Home page where Google API uses without an access token. Of course, the app doesn’t read anything without an access token. We somehow need to handle this case.

We don’t want to have a user do the login process again, therefore the app should try login silently. I thought this could be used only if the app had called signIn() method before in the same session. In other words, I thought it could be used only when _google.currentUser is not null but it seems not to be the case.

class SplashScreen extends ConsumerWidget {
  Future<void> _onAuthStateChange(BuildContext context, WidgetRef ref) async {
    // Added 
    _autoLogin(ref);

    Future.delayed(Duration(seconds: 1), () {
      ...
    });
  }
}

Future<void> _autoLogin(WidgetRef ref) async {
  final googleUser = await _google.signInSilently(reAuthenticate: true);
  if (googleUser == null) {
    return;
  }
  _afterLoginProcess(ref, googleUser);
}

Future<void> _afterLoginProcess(
  WidgetRef ref,
  GoogleSignInAccount googleUser,
) async {
  final googleAuth = await googleUser.authentication;
  ref.read(tokenProvider.state).state = googleAuth.accessToken;
  final credential = GoogleAuthProvider.credential(
    accessToken: googleAuth.accessToken,
    idToken: googleAuth.idToken,
  );

  final UserCredential loginUser =
      await FirebaseAuth.instance.signInWithCredential(credential);
  assert(loginUser.user?.uid == FirebaseAuth.instance.currentUser?.uid);
}

After this silent login, the listener registered in FirebaseAuth.instance.authStateChanges().listen() is triggered with non-null user, and thus Home page is shown.

By the way, if a new token is required every time the app tries to call Google API, we should pass a property like this.

final googleUser = await _google.signInSilently(reAuthenticate: true);

Get a new access token when it is expired

The access token has an expiration time. The expiration time seems to be 30 minutes. According to the official page, It seems to be possible to change the expiration time but I haven’t found the way in Flutter.

This Home page shows a file list of Google Drive. It looks simple page.

home-with-logout
class _Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final appBar = AppBar(
      title: Text("Home"),
      actions: [
        TextButton(
          onPressed: () async {
            await _google.signOut();
            await FirebaseAuth.instance.signOut();
          },
          child: Text(
            "Logout",
            style: TextStyle(color: Colors.white),
          ),
        )
      ],
    );

    return Scaffold(
      appBar: appBar,
      body: FutureBuilder<List<File>?>(
        future: _fetchList(ref),
        builder: (context, snapshot) {
          final data = snapshot.data;
          if (data == null) {
            return Center(child: CircularProgressIndicator());
          }

          if (data.isEmpty) {
            return Center(child: Text("No data found"));
          }

          return ListView.builder(
            itemCount: data.length,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text("${data[index].name}"),
              );
            },
          );
        },
      ),
    );
  }
  ...
}

If you don’t know how to use FutureBuilder widget, Flutter Add TextField widget dynamically might be helpful.

I haven’t found a method that checks whether the access token is expired or not. It might not exist. The way I could do is to catch the Invalid Credentials error. If DetailedApiRequestError is thrown, the status is 401, and the message is Invalid Credentials, signInSilently() is called to get a new access token.

class _Home extends ConsumerWidget {
  ...
  Future<List<File>> _fetchList(WidgetRef ref) async {
    final api = ref.watch(apiProvider.state).state;
    if (api == null) {
      return [];
    }

    try {
      final fileList = await api.files.list(
        spaces: 'appDataFolder',
        $fields: 'files(name)',
      );
      return fileList.files ?? [];
    } on DetailedApiRequestError catch (e) {
      print(e);
      if (e.status == 401 && e.message == "Invalid Credentials") {
        final googleUser = await _google.signInSilently();
        if (googleUser == null) {
          return [];
        }
        _afterLoginProcess(ref, googleUser);
      }
    } catch (e) {
      print(e);
    }
    return [];
  }
}

In _afterLoginProcess method, tokenProvider is updated and thus apiProvider is also updated. Since tokenProvider is watched in the _fetchList method, the whole process is repeated because the widget tree is rebuilt. Therefore, it can fetch the file list correctly for the second try.

It is better to put the re-authentication logic in _GoogleAuthClient class because all messages are sent from here. I tried to do that but it was not possible because it is not a widget, and thus it doesn’t have WidgetRef which is required by Riverpod provider.

Comments

Copied title and URL