Flutter and GraphQL with Authentication
Tags: flutter, graphql
Published: 2024-06-02 13:16:57

You will learn,

- How to get the schema from the backend?
- How to use a code generation tool to make things easier?
- How to make a GraphQL API request?
- How to renew the access token?

I will jump right in!

## Code Links:
Get the source code from here.
{% embed https://github.com/alishgiri/flutter-graphql-authentication %}

## Tools we will be using:

1. **get-graphql-schema**
This npm package will allow us to download GraphQL schema from our backend.
{% embed https://github.com/prisma-labs/get-graphql-schema %}

2. **graphql_flutter**
We will make GraphQL API requests using this package.
{% embed https://pub.dev/packages/graphql_flutter %}

3. **graphql_codegen**
This code-generation tool will convert our schema (.graphql) to dart types (.dart).

There is another code-generation tool called _Artemis_ as well but I found this to be better.

{% embed https://pub.dev/packages/graphql_codegen %}

Additionally, we will be using,

- **Provider** — For state management.
- **flutter_secure_storage** — to store user auth data locally.
- **get_it** — to locate our registered services and view-models files.
- **build_runner** — to generate files. We will configure graphql_codegen with this to make code generation possible.


## Alternative package to work with GraphQL:

**Ferry**

I found this package very complicated but feel free to try this out.
This package will help in making GraphQL API requests. This will also be a code-generation tool to convert schema files (.graphql) to dart types (.dart).

[Ferry Setup](https://medium.com/r/?url=https%3A%2F%2Fferrygraphql.com%2Fdocs%2Fsetup)


## Files & Folder structure

```
lib
  - core
    - models
    - services
    - view_models
  - graphql
    - __generated__
    - your_app.schema.graphql // We will download this using get-graphql-schema
    - queries
      - __generated__
      - auth.graphql // this is equivalent to auth end-points in REST API
  - ui
    - widgets
    - views
  - locator.dart
  - main.dart
pubspec.yml
build.yaml
```

> Check the comments on top of every file below to place the files in their respective folders.

Make the following changes to your **pubspec.yml** file,

```yaml
dependencies:
  flutter_secure_storage: ^9.0.0
  jwt_decode: ^0.3.1
  provider: ^6.1.1
  graphql_flutter: ^5.2.0-beta.6
  get_it: ^7.6.7

dev_dependencies:
  build_runner: ^2.4.8
  flutter_gen: ^5.4.0
  flutter_lints: ^4.0.0
  graphql_codegen: ^0.14.0

flutter:
  generate: true
```

Add the following content to your **build.yaml** file,

```yaml
targets:
  $default:
    builders:
      graphql_codegen:
        options:
          assetsPath: lib/graphql/**
          outputDirectory: __generated__
          clients:
            - graphql_flutter
```

Here, in the _options_ section we have,
**assetsPath**: All the GraphQL-related code will be placed inside lib/graphql/ so we are pointing it to that folder.
**outputDirectory**: is where we want our generated code to reside. So create the following folders.

- _lib/graphql/__generated__/_
- _lib/graphql/queries/__generated__/_

## Getting the schema file
Install _get-graphql-schema_ globally using npm or yarn and run it from your project root directory.

```bash
# Install using yarn
yarn global add get-graphql-schema

# Install using npm
npm install -g get-graphql-schema
```

```bash
npx get-graphql-schema http://localhost:8000/graphql > lib/graphql/your_app.schema.graphql
```

> We are providing our graphql API link and asking **get-graphql-schema** to store it on the file **your_app.schema.graphql**

Modify the above as required!

## Adding the endpoints to auth.graphql file
The queries and mutations below are defined by the backend so please get the correct GraphQL schema (also called the end-points).

```graphql
# lib/graphql/queries/auth.graphql

mutation RegisterUser($input: UserInput!) {
  auth {
    register(input: $input) {
      ...RegisterSuccess
    }
  }
}

query Login($input: LoginInput!) {
  auth {
    login(input: $input) {
      ...LoginSuccess
    }
  }
}

query RenewAccessToken($input: RenewTokenInput!) {
  auth {
    renewToken(input: $input) {
      ...RenewTokenSuccess
    }
  }
}

fragment RegisterSuccess on RegisterSuccess {
  userId
}

fragment LoginSuccess on LoginSuccess {
  accessToken
  refreshToken
}

fragment RenewTokenSuccess on RenewTokenSuccess {
  newAccessToken
}
```
## The Implementation!
Run the following command to generate all dart types for our .graphql files.

```bash
dart run build_runner build
```

Now, setting up the **graphql**, **get_it** and initialising hive (used for caching) in our **main.dart** file,

Create a file _lib/locator.dart_ and add the following content.

```dart
// locator.dart

import 'package:get_it/get_it.dart';

import 'package:auth_app/core/view_models/login.vm.dart';
import 'package:auth_app/core/services/base.service.dart';
import 'package:auth_app/core/services/auth.service.dart';
import 'package:auth_app/core/services/secure_storage.service.dart';

final locator = GetIt.instance;

void setupLocator() async {
  locator.registerSingleton(BaseService());
  locator.registerLazySingleton(() => AuthService());
  locator.registerLazySingleton(() => SecureStorageService());
  locator.registerFactory(() => LoginViewModel());
}
```

In the _lib/main.dart_ we will call setupLocator() in the main() function as shown below.

```dart
// main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

import 'package:auth_app/locator.dart';
import 'package:auth_app/ui/views/login.view.dart';
import 'package:auth_app/core/services/base.service.dart';
import 'package:auth_app/core/services/auth.service.dart';

void main() async {
  // If you want to use HiveStore() for GraphQL caching.
  // await initHiveForFlutter();

  setupLocator();

  runApp(const App());
}

class App extends StatelessWidget {
  const App({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return GraphQLProvider(
      client: locator<BaseService>().clientNotifier,
      child: ChangeNotifierProvider.value(
        value: locator<AuthService>(),
        child: const MaterialApp(
          title: 'your_app',
          debugShowCheckedModeBanner: false,
          home: LoginView(),
        ),
      ),
    );
  }
```

Now we will create the remaining files.
Our data models:

```dart
// core/models/auth_data.model.dart

class AuthData {
  final String? accessToken;
  final String? refreshToken;

  const AuthData({
    required this.accessToken,
    required this.refreshToken,
  });
}
```

```dart
// core/models/auth.model.dart

import 'package:jwt_decode/jwt_decode.dart';

import 'package:auth_app/core/models/auth_data.model.dart';

class Auth {
  final String name;
  final String userId;
  final String accessToken;
  final String refreshToken;

  const Auth({
    required this.name,
    required this.userId,
    required this.accessToken,
    required this.refreshToken,
  });

  factory Auth.fromJson(Map<String, dynamic> data) {
    final jwt = Jwt.parseJwt(data["accessToken"]);
    return Auth(
      name: jwt["name"],
      userId: jwt["iss"],
      accessToken: data["accessToken"],
      refreshToken: data["refreshToken"],
    );
  }

  factory Auth.fromAuthData(AuthData data) {
    final jwt = Jwt.parseJwt(data.accessToken!);
    return Auth(
      name: jwt["name"],
      userId: jwt["iss"],
      accessToken: data.accessToken!,
      refreshToken: data.refreshToken!,
    );
  }
}
```

Our secure storage service file saves authentication information:

```dart
// core/services/secure_storage.service.dart

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

import 'package:auth_app/core/models/auth.model.dart';
import 'package:auth_app/core/models/auth_data.model.dart';

const accessToken = "access_token";
const refreshToken = "refresh_token";

class SecureStorageService {
  final _storage = FlutterSecureStorage(
    iOptions: _getIOSOptions(),
    aOptions: _getAndroidOptions(),
  );

  static IOSOptions _getIOSOptions() => const IOSOptions();

  static AndroidOptions _getAndroidOptions() => const AndroidOptions(
        encryptedSharedPreferences: true,
      );

  Future<void> storeAuthData(Auth auth) async {
    await _storage.write(key: accessToken, value: auth.accessToken);
    await _storage.write(key: refreshToken, value: auth.refreshToken);
  }

  Future<AuthData> getAuthData() async {
    final map = await _storage.readAll();
    return AuthData(accessToken: map[accessToken], refreshToken: map[refreshToken]);
  }

  Future<void> updateAccessToken(String token) async {
    await _storage.delete(key: accessToken);
    await _storage.write(key: accessToken, value: token);
  }

  Future<void> updateRefreshToken(String token) async {
    await _storage.write(key: refreshToken, value: token);
  }

  Future<void> clearAuthData() async {
    await _storage.deleteAll();
  }
}
```

Our base service file contains a configured graphql client which will be used to make the API requests to the server:

```dart
// core/services/base.service.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:jwt_decode/jwt_decode.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

import 'package:auth_app/locator.dart';
import 'package:auth_app/core/services/auth.service.dart';
import 'package:auth_app/core/services/secure_storage.service.dart';
import 'package:auth_app/graphql/queries/__generated__/auth.graphql.dart';
import 'package:auth_app/graphql/__generated__/your_app.schema.graphql.dart';

class BaseService {
  late GraphQLClient _client;
  late ValueNotifier<GraphQLClient> _clientNotifier;

  bool _renewingToken = false;

  GraphQLClient get client => _client;

  ValueNotifier<GraphQLClient> get clientNotifier => _clientNotifier;

  BaseService() {
    final authLink = AuthLink(getToken: _getToken);
    final httpLink = HttpLink("http://localhost:8000/graphql");

    /// The order of the links in the array matters!
    final link = Link.from([authLink, httpLink]);

    _client = GraphQLClient(
      link: link,
      cache: GraphQLCache(),
      //
      // You have two other caching options.
      // But for my example I won't be using caching.
      //
      // cache: GraphQLCache(store: HiveStore()),
      // cache: GraphQLCache(store: InMemoryStore()),
      //
      defaultPolicies: DefaultPolicies(query: Policies(fetch: FetchPolicy.networkOnly)),
    );

    _clientNotifier = ValueNotifier(_client);
  }

  Future<String?> _getToken() async {
    if (_renewingToken) return null;

    final storageService = locator<SecureStorageService>();

    final authData = await storageService.getAuthData();

    final aT = authData.accessToken;
    final rT = authData.refreshToken;

    if (aT == null || rT == null) return null;

    if (Jwt.isExpired(aT)) {
      final renewedToken = await _renewToken(rT);

      if (renewedToken == null) return null;

      await storageService.updateAccessToken(renewedToken);

      return 'Bearer $renewedToken';
    }

    return 'Bearer $aT';
  }

  Future<String?> _renewToken(String refreshToken) async {
    try {
      _renewingToken = true;

      final result = await _client.mutate$RenewAccessToken(Options$Mutation$RenewAccessToken(
        fetchPolicy: FetchPolicy.networkOnly,
        variables: Variables$Mutation$RenewAccessToken(
          input: Input$RenewTokenInput(refreshToken: refreshToken),
        ),
      ));

      final resp = result.parsedData?.auth.renewToken;

      if (resp is Fragment$RenewTokenSuccess) {
        return resp.newAccessToken;
      } else {
        if (result.exception != null && result.exception!.graphqlErrors.isNotEmpty) {
          locator<AuthService>().logout();
        }
      }
    } catch (e) {
      rethrow;
    } finally {
      _renewingToken = false;
    }

    return null;
  }
}
```

We will use **_client** in the file above to make the GraphQL API requests. We will also check if our access-token has expired before making an API request and renew it if necessary.

File _auth.service.dart_ contains all Auth APIs service functions:

```dart
// core/services/auth.service.dart

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

import 'package:auth_app/locator.dart';
import 'package:auth_app/core/models/auth.model.dart';
import 'package:auth_app/core/services/base.service.dart';
import 'package:auth_app/core/services/secure_storage.service.dart';
import 'package:auth_app/graphql/queries/__generated__/auth.graphql.dart';
import 'package:auth_app/graphql/__generated__/your_app.schema.graphql.dart';

class AuthService extends ChangeNotifier {
  Auth? _auth;
  final client = locator<BaseService>().client;
  final storageService = locator<SecureStorageService>();

  Auth? get auth => _auth;

  Future<void> initAuthIfPreviouslyLoggedIn() async {
    final auth = await storageService.getAuthData();
    if (auth.accessToken != null) {
      _auth = Auth.fromAuthData(auth);
      notifyListeners();
    }
  }

  Future<void> login(Input$LoginInput input) async {
    final result = await client.query$Login(Options$Query$Login(
      variables: Variables$Query$Login(input: input),
    ));

    final resp = result.parsedData?.auth.login;

    if (resp is Fragment$LoginSuccess) {
      _auth = Auth.fromJson(resp.toJson());
      storageService.storeAuthData(_auth!);
      notifyListeners();
    } else {
      throw gqlErrorHandler(result.exception);
    }
  }

  Future<void> registerUser(Input$UserInput input) async {
    final result = await client.mutate$RegisterUser(Options$Mutation$RegisterUser(
      variables: Variables$Mutation$RegisterUser(input: input),
    ));

    final resp = result.parsedData?.auth.register;

    if (resp is! Fragment$RegisterSuccess) {
      throw gqlErrorHandler(result.exception);
    }
  }

  Future<void> logout() async {
    await locator<SecureStorageService>().clearAuthData();
    _auth = null;
    notifyListeners();
  }

  // You can put this in a common utility functions so
  // that you can reuse it in other services file too.
  //
  String gqlErrorHandler(OperationException? exception) {
    if (exception != null && exception.graphqlErrors.isNotEmpty) {
      return exception.graphqlErrors.first.message;
    }
    return "Something went wrong.";
  }
}
```

Our base view and base view model:

```dart
// ui/shared/base.view.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'package:auth_app/locator.dart';
import 'package:auth_app/core/view_models/base.vm.dart';

class BaseView<T extends BaseViewModel> extends StatefulWidget {
  final Function(T)? dispose;
  final Function(T)? initState;
  final Widget Function(BuildContext context, T model, Widget? child) builder;

  const BaseView({
    super.key,
    this.dispose,
    this.initState,
    required this.builder,
  });

  @override
  BaseViewState<T> createState() => BaseViewState<T>();
}

class BaseViewState<T extends BaseViewModel> extends State<BaseView<T>> {
  final T model = locator<T>();

  @override
  void initState() {
    if (widget.initState != null) widget.initState!(model);
    super.initState();
  }

  @override
  void dispose() {
    if (widget.dispose != null) widget.dispose!(model);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<T>.value(
      value: model,
      child: Consumer<T>(builder: widget.builder),
    );
  }
}
```

```dart
// core/view_models/base.vm.dart

import 'package:flutter/material.dart';

class BaseViewModel extends ChangeNotifier {
  bool _isLoading = false;
  final scaffoldKey = GlobalKey<ScaffoldState>();

  bool get isLoading => _isLoading;

  setIsLoading([bool busy = true]) {
    _isLoading = busy;
    notifyListeners();
  }

  void displaySnackBar(String message) {
    final scaffoldMessenger = ScaffoldMessenger.of(
      scaffoldKey.currentContext!,
    );

    scaffoldMessenger.showSnackBar(
      SnackBar(
        content: Row(
          children: [
            const Icon(Icons.warning, color: Colors.white),
            const SizedBox(width: 10),
            Flexible(child: Text(message)),
          ],
        ),
      ),
    );
  }
}
```

In the above base view, we use the Provider as a state management tool. The base view model extends **ChangeNotifier** which notifies its view when the **notifyListeners()** function is called in the View Model.
Now, We will be using the base view and base view model for our login view and login view model:

```dart
// ui/views/login.view.dart

import 'package:flutter/material.dart';

import 'package:auth_app/ui/shared/base.view.dart';
import 'package:auth_app/core/view_models/login.vm.dart';

class LoginView extends StatelessWidget {
  const LoginView({super.key});

  @override
  Widget build(BuildContext context) {
    return BaseView<LoginViewModel>(
      builder: (context, loginVm, child) {
        return Scaffold(
          key: loginVm.scaffoldKey,
          body: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(20.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Form(
                    // Attach form key for validations. I won't be adding validations.
                    // key: loginVm.formKey,
                    child: Column(
                      children: [
                        Text("Auth App", style: Theme.of(context).textTheme.displayMedium),
                        const SizedBox(height: 30),
                        TextFormField(
                          onChanged: loginVm.onChangedEmail,
                          keyboardType: TextInputType.emailAddress,
                          decoration: const InputDecoration(hintText: "Email"),
                        ),
                        const Divider(height: 2),
                        TextFormField(
                          obscureText: true,
                          onChanged: loginVm.onChangedPassword,
                          decoration: const InputDecoration(hintText: "Password"),
                        ),
                        const SizedBox(height: 20),
                        TextButton(
                          onPressed: loginVm.onLogin,
                          child: loginVm.isLoading ? const CircularProgressIndicator() : const Text("Login"),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }
}
```

The final file in our tutorial and we are all done 🎉:

```dart
// core/view_models/login.vm.dart

import 'package:auth_app/locator.dart';
import 'package:auth_app/core/view_models/base.vm.dart';
import 'package:auth_app/core/services/auth.service.dart';
import 'package:auth_app/graphql/__generated__/your_app.schema.graphql.dart';

class LoginViewModel extends BaseViewModel {
  String? _email;
  String? _password;

  // Used for validation or any other purpose like clearing form and more...
  // final formKey = GlobalKey<FormState>();

  final _authService = locator<AuthService>();

  void onChangedPassword(String value) => _password = value;

  void onChangedEmail(String value) => _email = value;

  Future<void> onLogin() async {
    // Validate login details using [formKey]
    // if (!formKey.currentState!.validate()) return;

    try {
      setIsLoading(true);
      final input = Input$LoginInput(identifier: _email!, password: _password!);
      await _authService.login(input);
      displaySnackBar("Successfully logged in!");
    } catch (error) {
      displaySnackBar(error.toString());
    } finally {
      setIsLoading(false);
    }
  }
}
```


And always use **Provider** to access auth from the AuthService, this will make sure that your UI gets updated when you call _notifyListeners()_ in AuthService.

```dart
// Always access auth using Provider.of

Widget build(BuildContext context) {
    final auth = Provider.of<AuthService>(context).auth;
     
    // Set listen to false if you don't want to re-render the widget.
    //
    // final auth = Provider.of<AuthService>(context, listen: false).auth;

    // DO NOT DO THIS!
    // If you do this then your UI won't be updated,
    // when you call notifyListeners() in AuthService.
    //
    // final auth = locator<AuthService>().auth;

    return Scaffold(...)
}
```

I hope this gives you a complete idea about working with GraphQL in Flutter. If you have any questions feel free to comment.

Awesome! See you next time.