Going full-stack with Flutter and Supabase - Part 1: Authentication
Tags: dart, flutter, supabase
Published: 2021-05-14 13:02:02

# Introduction
In this tutorial series, we will build a simple notes app, powered by Supabase. Supabase is a product similar to Firebase, which provides services such as authentication and a database, as well as a client which allows you to authenticate and query the database directly from your Flutter app. By combining Flutter with Supabase, you could build production-ready* apps without having to write or maintain a back-end server.

This series will consist of three parts, each one going over a specific service by Supabase. First, we'll start with **Authentication**, which will allow users to sign in the app. Then, we'll move on to the **Database**, where we'll create a simple schema for our notes app, and allow users to create and view their notes. Last, we'll check out Storage, which we'll make use of by allowing users to attach files to their notes.

This tutorial assumes you already have some experience with Flutter (if not, go [here](https://flutter.dev/docs/get-started/install) to get started!). Code snippets will be shared as we go along, but you can also check out the full source code at the repository linked at the end of this post.

\* Note that Supabase is still in public beta phase. However, it's still an exciting service, and now is a good time to experiment with it!

## What is Supabase?
Supabase calls itself an open source alternative to Firebase. It offers several things: a relational database which you can access directly from the client, built-in user authentication so you can manage users and permissions, as well as storage so you can store large files. Functions are also in the works, which will allow you to run your own code without needing a server.

## Why not Firebase?
While Supabase is considered an alternative, it's also very different. As always, there is no one right choice, and the best service to use depends on your use case. But Supabase has two stand-out features that Firebase does not have, which a lot of developers might prefer.

* Relational database: Supabase is built on top of [PostgreSQL](https://www.postgresql.org/), a powerful relational database that has been around for over 30 years. Firebase's Firestore and Realtime Database offerings are non-relational, so the choice between Supabase and Firebase might be as easy as relational vs non-relational, though there's more to it than that. Firebase is not open source, and has not been around as long as Postgres.
* It's all open source: Supabase is built on top of other popular open-source packages, such as [PostgREST](https://postgrest.org/) for accessing your database directly from the client, and [GoTrue](https://github.com/netlify/gotrue) for user authentication. All that, along with a nice admin interface so you can manage everything. If you wanted, you could host and manage these services on your own. If PostgREST does not fit your use case, you could host a server that will communicate with your Postgres database directly. And if you ever decide that Supabase is not for you, you could always migrate all your data somewhere else. At the end of the day, it's all in a Postgres database.

# Getting Started
## Setting up Supabase

Getting started with Supabase is simple, so we won't go into much detail about its setup. Just head to the [Supabase website](https://supabase.io/) and create a project.

You can spend some time to get familiar with the [admin interface](https://app.supabase.io/), but we won't be needing it a lot for this first part. All we will need is your Supabase project's URL and API key, which you can find your project's API settings.

In auth settings, you can also have some options to change your site URL, which is included in emails sent by Supabase when a user signs up (which you can disable), as well as customize the email templates sent for password resets, magic links(!), and invitations.

You can also set up and run Supabase locally for local development by following the guide [here](https://supabase.io/docs/guides/local-development).

## Creating a new Flutter app

Now that we've got Supabase all set up, it's time to start building the app. Let's create a new Flutter app. Since this is a notes app built on Supabase, I'll be calling it Supanotes!

```sh
flutter create supanotes
```

Note: if null safety was not already enabled by default, you can do this by running the command below.

```sh
cd supanotes
dart migrate --apply-changes
```

We'll be making use of Dart's null safety features, but it for whatever reason you don't want to enable it for your app, you should still be able to follow along.

# Part 1: Authentication

Now, let's make a very simple login page. Here is the UI code so far:

```dart
class SupanotesApp extends StatelessWidget {
  static const supabaseGreen = Color.fromRGBO(101, 217, 165, 1.0);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Supanotes',
      theme: ThemeData(
        brightness: Brightness.dark,
        primarySwatch: toMaterialColor(supabaseGreen),
      ),
      home: HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage();

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  
  void _signUp() {
    // Sign up logic will go here
  }

  void _signIn() {
    // Sign in logic will go here
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).primaryColor,
        title: Text('Supanotes'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: TextField(
                controller: _emailController,
                keyboardType: TextInputType.emailAddress,
                decoration: InputDecoration(hintText: 'Email'),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: TextField(
                controller: _passwordController_,
                obscureText: true,
                decoration: InputDecoration(hintText: 'Password'),
              ),
            ),
            ElevatedButton.icon(
              onPressed: _signIn,
              icon: Icon(Icons.login),
              label: Text('Sign in'),
            ),
            ElevatedButton.icon(
              onPressed: _signUp,
              icon: Icon(Icons.app_registration),
              label: Text('Sign up'),
            ),
          ],
        ),
      ),
    );
  }
  
  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}
```

The `toMaterialColor` function is taken from [this post here](https://blog.usejournal.com/creating-a-custom-color-swatch-in-flutter-554bcdcb27f3) (thanks Filip!).

## Adding the Supabase package

Let's implement the logic for sign in and sign up. First, we add the `supabase` package to our app.

```sh
flutter pub add supabase
```
Next, we'll create an `AuthService` that will handle anything auth related. In order to pass this service to any widget that needs it, we'll create an `InheritedWidget` that will hold all services we might need.

```dart
class Services extends InheritedWidget {
  final AuthService authService;

  Services._({
    required this.authService,
    required Widget child,
  }) : super(child: child);

  factory Services({required Widget child}) {
    final client = SupabaseClient(supabaseUrl, supabaseUrl);
    final authService = AuthService(client.auth);
    return Services._(authService: authService, child: child);
  }

  @override
  bool updateShouldNotify(InheritedWidget oldWidget) {
    return false;
  }

  static Services of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<Services>()!;
  }
}
```

In this `InheritedWidget`, we initialize the Supabase client, and create the `AuthService`. To initialise the `SupabaseClient`, simply pass the URL and API keys from the API settings on the Supabase admin interface.

## The GoTrue client
Supabase uses GoTrue for authentication. `client.auth` returns the GoTrue client, which is all the `AuthService` will need.

```dart
class AuthService {
  final GoTrueClient _client;

  AuthService(this._client);

  Future<bool> signUp(String email, String password) {
    // sign up logic goes here
  }

  Future<bool> signIn(String email, String password) {
    // sign in logic goes here
  }

  Future<bool> signOut() {
    // sign out logic goes here
  }
}
```

All three functions will return `true` if the response was successful, otherwise false.

Let's start with the `signUp` function:

```dart
// AuthService
Future<bool> signUp(String email, String password) async {
  final response = await _client.signUp(email, password);
  if (response.error == null) {
    log('Sign up was successful for user ID: ${response.user!.id}');
    return true;
  }
  log('Sign up error: ${response.error!.message}');
  return false;
}
```

This function will accept the email and password which we will pass from the text field widgets, and create a user. The client will return a `GotrueSessionResponse`, which will contain an error if something goes wrong, or a `User` and `Session` object.

We don't currently need to do anything with the `User` and `Session` objects, though it contains useful information we could make use of, such as the user's ID, last sign in date, and email confirmation date. However, these will always be accessible through `GoTrueClient#currentUser` and `GoTrueClient#currentSession` so we can grab them from the client when we need them.

Below are also the `signIn` and `signOut` functions, which are very similar to `signUp`.

```dart
// AuthService
Future<bool> signIn(String email, String password) async {
  final response = await _client.signIn(email: email, password: password);
  if (response.error == null) {
    log('Sign in was successful for user ID: ${response.user!.id}');
    return true;
  }
  log('Sign in error: ${response.error!.message}');
  return false;
}

Future<bool> signOut() async {
  final response = await _client.signOut();
  if (response.error == null) {
    return true;
  }
  log('Log out error: ${response.error!.message}');
  return false;
}
```

Now, let's call these `AuthService` functions in `HomePage`. Since the service is part of the `Services` `InheritedWidget`, we can freely access it through `Services.of(context).authService`. But to do that, we should first wrap `MaterialApp` with the `Services` widget. Our top level app widget should look like this:

```dart
class SupanotesApp extends StatelessWidget {
  static const supabaseGreen = Color.fromRGBO(101, 217, 165, 1.0);

  const SupanotesApp();

  @override
  Widget build(BuildContext context) {
    return Services(
      child: MaterialApp(
        title: 'Supanotes',
        theme: ThemeData(
          brightness: Brightness.dark,
          primarySwatch: toMaterialColor(supabaseGreen),
        ),
        home: HomePage(),
      ),
    );
  }
}
```

Now, we can call `AuthService` functions from any widget. Let's start with the logic for sign up.

```dart
// HomePage
void _signUp() async {
  final success = await Services.of(context)
      .authService
      .signUp(_emailController.text, _passwordController.text);
  if (success) {
    await Navigator.pushReplacement(
        context, MaterialPageRoute(builder: (_) => NotesPage()));
  } else {
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text('Something went wrong.')));
  }
}
```
If the response was successful, we navigate to a new page, the `NotesPage`, where we will display the user's notes. We call `pushReplacement` rather than `push`, since we don't want the user pressing back and ending up in the home page by mistake.

If the response fails, we just show a generic snack bar. This could display more useful information, based on the actual error.

The function for signing in is pretty much the same. We can reuse some of the code for both functions, and our code now looks like this:

```dart
// HomePage
void _signUp() async {
  final success = await Services.of(context)
      .authService
      .signUp(_emailController.text, _passwordController.text);
  await _handleResponse(success);
}

void _signIn() async {
  final success = await Services.of(context)
      .authService
      .signIn(_emailController.text, _passwordController.text);
  await _handleResponse(success);
}

Future<void> _handleResponse(bool success) async {
  if (success) {
    await Navigator.pushReplacement(
        context, MaterialPageRoute(builder: (_) => NotesPage()));
  } else {
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text('Something went wrong.')));
  }
}
```

Now let's build the `NotesPage`. For now, it'll just be an empty page, with a button to sign out. Here's the full code:
```dart
class NotesPage extends StatelessWidget {
  const NotesPage();

  Future<void> _signOut(BuildContext context) async {
    final success = await Services.of(context).authService.signOut();
    if (success) {
      Navigator.pushReplacement(
          context, MaterialPageRoute(builder: (_) => HomePage()));
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('There was an issue logging out.')));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Supanotes'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: Text('Your notes will show up here.'),
            ),
            ElevatedButton.icon(
              onPressed: () async {
                await _signOut(context);
              },
              icon: Icon(Icons.login),
              label: Text('Sign out'),
            ),
          ],
        ),
      ),
    );
  }
}
```
And that's it! We now have a proper authentication system set up for our Supanotes app. Now any requests to the Supabase client will include the signed in user info as well, so we can continue with the next part: creating and listing notes.

## GoTrue and JSON Web Tokens
GoTrue uses JSON Web Tokens (JWT) for authentication. On a successful sign in/sign up, the response contains an access token, and a refresh token. The access token is included in every request to Supabase. When decoded, this token contains the user's unique ID, and this is how Supabase knows which user is performing the request. Access tokens are short-lived, and expire by default every one hour (you can configure this through the admin interface, but only do this if you have a good reason). You can read more about JWT [here](https://jwt.io/).

The access token is only valid for a short time, and we definitely don't want the user having to sign in every one hour. This is what the refresh token is for. The refresh token is valid for longer than an hour, and can be used to request new tokens. Let's take a look at some of the source code of the `GoTrueClient`:

```dart
void _saveSession(Session session) {
  currentSession = session;
  currentUser = session.user;
  final tokenExpirySeconds = session.expiresIn;

  if (autoRefreshToken && tokenExpirySeconds != null) {
    if (_refreshTokenTimer != null) _refreshTokenTimer!.cancel();

    final timerDuration = Duration(seconds: tokenExpirySeconds - 60);
    _refreshTokenTimer = Timer(timerDuration, () {
      _callRefreshToken();
    });
  }
}
```

If `autoRefreshToken` is set to `true`, a timer is set to automatically "refresh" the tokens by using the refresh token, one minute before the access token expires. `autoRefreshToken` is `true` by default, and can be changed through an optional parameter in the `GoTrueClient` constructor.

Now, what happens if the user exits the app? We lose access to both tokens, and have no way of requesting new tokens either, so the user would have to sign in again.

## Keeping users signed in

To keep users signed in even after re-opening the app, we need to somehow request new tokens. `GoTrueClient` has a `recoverSession` function, which uses the refresh token mentioned before to request new tokens.

In order to recover the session, we need to persist the session data of a successful sign in. In this example, we'll use the `shared_preferences` package, but you might also want to use something like `flutter_secure_storage` if there are security concerns. Data stored in shared preferences is only accessible by the app which stored them, but this is not the case for rooted devices.

```sh
flutter pub add shared_preferences
```

Conveniently, the `Session` object in the `GoTrueResponse` contains a function to get the JSON string to persist. So all we have to do is store this string in `SharedPreferences`, and fetch it when we want to recover the session. Here's the updated `signUp` and `signIn` functions:

```dart
// AuthService
static const supabaseSessionKey = 'supabase_session';

Future<bool> signUp(String email, String password) async {
  final response = await _client.signUp(email, password);
  if (response.error == null) {
    log('Sign up was successful for user ID: ${response.user!.id}');
    _persistSession(response.data!);
    return true;
  }
  log('Sign up error: ${response.error!.message}');
  return false;
}

Future<bool> signIn(String email, String password) async {
  final response = await _client.signIn(email: email, password: password);
  if (response.error == null) {
    log('Sign in was successful for user ID: ${response.user!.id}');
    _persistSession(response.data!);
    return true;
  }
  log('Sign in error: ${response.error!.message}');
  return false;
}

Future<void> _persistSession(Session session) async {
  final prefs = await SharedPreferences.getInstance();
  log('Persisting session string');
  await prefs.setString(supabaseSessionKey, session.persistSessionString);
}
```

Note that it might make sense to initialize `SharedPreferences` once and pass it to any services that might need it, but let's keep it like this for simplicity.

Okay, so we have all we need to recover the session after the user exists the app. What now? In order to decide which page widget to show (`HomePage` if user is not signed in, `NotesPage` otherwise), we can check if there is a persisted session string, and attempt to recover the session with `GoTrueClient#recoverSession`. If there's no session string stored, or if recovering the session fails, then we can show the `HomePage`. If recovering the session works, then the user is successfully signed in! We can go straight to the `NotesPage`.

Let's introduce a new function in `AuthService`, that will attempt to recover the session.

```dart
// AuthService
Future<bool> recoverSession() async {
  final prefs = await SharedPreferences.getInstance();
  if (prefs.containsKey(supabaseSessionKey)) {
    log('Found persisted session string, attempting to recover session');
    final jsonStr = prefs.getString(supabaseSessionKey)!;
    final response = await _client.recoverSession(jsonStr);
    if (response.error == null) {
      log('Session successfully recovered for user ID: ${response.user!.id}');
      _persistSession(response.data!);
      return true;
    }
  }
  return false;
}
```

Next, let's call this function to decide which page to show first. Here's the updated `build` function of the root `SupanotesApp` widget:

```dart
@override
Widget build(BuildContext context) {
  return Services(
    child: MaterialApp(
      title: 'Supanotes',
      theme: ThemeData(
        brightness: Brightness.dark,
        primarySwatch: toMaterialColor(supabaseGreen),
      ),
      home: Builder(
        builder: (context) {
          return FutureBuilder<bool>(
            future: Services.of(context).authService.recoverSession(),
            builder: (context, snapshot) {
              final sessionRecovered = snapshot.data ?? false;
              return sessionRecovered ? NotesPage() : HomePage();
            },
          );
        },
      ),
    ),
  );
}
```

Note that we are wrapping the `FutureBuilder` with a `Builder`, as the `Services` object will not be available in the context of `SupanotesApp`.

And that's it! If you now try to perform a hot restart, or exit and re-open the app, you'll see that you'll still be signed in! If the refresh token has expired, the `recoverSession` function will attempt to refresh the tokens, otherwise it will resume with the persisted session.

The `GoTrueClient` is not a big class, so I'd recommend taking a better look at the source code if you're interested in seeing how it all works behind the scenes.

There is one more thing we need to do. When a user signs out, we should clear this persisted session string. Otherwise, if a user signs out and then re-opens the app, they could be signed back in!

Here's the updated `signOut` function:

```dart
Future<bool> signOut() async {
  final response = await _client.signOut();
  if (response.error == null) {
    log('Successfully logged out; clearing session string');
    final prefs = await SharedPreferences.getInstance();
    prefs.remove(supabaseSessionKey);
    return true;
  }
  log('Log out error: ${response.error!.message}');
  return false;
}
```

## More auth features

Supabase authentication also supports third party logins, but we won't show an example of how to use this functionality in this post. These OAuth providers are supported:
* Google
* GitHub
* GitLab
* Azure
* Facebook
* BitBucket
* Apple
* Twitter

There is also support for magic links! If you call the `GoTrueClient#signIn` function without a password, this will send the user an email with a one-time login link. Both third party logins and magic links could be good potential blog posts, so watch this space!

## Wrapping up
That's all for the first part of this tutorial series on Supabase with Flutter, focusing on **Authentication**. You can find the full code on [GitHub](https://github.com/dartling/supanotes/tree/pt1-auth). I hope you found it helpful, and would love it if you could share some feedback in the comments! In the next part, we will take a look at Supabase's **Database** offering.

If you'd like to be notified when the next part of this series is published, and for any future tutorials, please sign up with your email [here](https://www.getrevue.co/profile/dartling).