Flutter and GraphQL with Authentication

Alish Giri - Jun 2 - - Dev Community

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.

GitHub logo alishgiri / flutter-graphql-authentication

Flutter and GraphQL with Authentication Tutorial.

Flutter & GraphQL with Authentication Tutorial.

Place your Base URL

Global search on the project on VSCode or any IDE and replace the following with your base url.

http://localhost:8000/graphql

Steps to download your graphql schema from the backend.

  • Install packages from package.json file.
    yarn install
    
    # or
    
    npm install
Enter fullscreen mode Exit fullscreen mode
  • Give permission to run the script.
    chmod +x ./scripts/gen-schema.sh
Enter fullscreen mode Exit fullscreen mode
  • Run the script to download your_app.schema.graphql
   ./scripts/gen-schema.sh
Enter fullscreen mode Exit fullscreen mode
  • Run build_runner to convert graphql files from lib/graphql/queries to dart types.
    dart run build_runner build
Enter fullscreen mode Exit fullscreen mode



Tools we will be using:

  1. get-graphql-schema
    This npm package will allow us to download GraphQL schema from our backend.

    GitHub logo prisma-labs / get-graphql-schema

    Fetch and print the GraphQL schema from a GraphQL HTTP endpoint. (Can be used for Relay Modern.)

    get-graphql-schema npm version

    Fetch and print the GraphQL schema from a GraphQL HTTP endpoint. (Can be used for Relay Modern.)

    Note: Consider using graphql-cli instead for improved workflows.

    Install

    npm install -g get-graphql-schema
    Enter fullscreen mode Exit fullscreen mode

    Usage

      Usage: get-graphql-schema [OPTIONS] ENDPOINT_URL > schema.graphql
    
      Fetch and print the GraphQL schema from a GraphQL HTTP endpoint
      (Outputs schema in IDL syntax by default)
    
      Options:
        --header, -h    Add a custom header (ex. 'X-API-KEY=ABC123'), can be used multiple times
        --json, -j      Output in JSON format (based on introspection query)
        --version, -v   Print version of get-graphql-schema
    
    Enter fullscreen mode Exit fullscreen mode

    Help & Community Slack Status

    Join our Slack community if you run into issues or have questions. We love talking to you!

  2. graphql_flutter
    We will make GraphQL API requests using this package.

    graphql_flutter | Flutter package

    A GraphQL client for Flutter, bringing all the features from a modern GraphQL client to one easy to use package.

    favicon pub.dev
  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.

graphql_codegen | Dart package

Simple, opinionated, codegen library for GraphQL. It allows you to generate serializers and client helpers to easily call and parse your data.

favicon pub.dev

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

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
Enter fullscreen mode Exit fullscreen mode

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,

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
Enter fullscreen mode Exit fullscreen mode

Add the following content to your build.yaml file,

targets:
  $default:
    builders:
      graphql_codegen:
        options:
          assetsPath: lib/graphql/**
          outputDirectory: __generated__
          clients:
            - graphql_flutter
Enter fullscreen mode Exit fullscreen mode

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.

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

# Install using npm
npm install -g get-graphql-schema
Enter fullscreen mode Exit fullscreen mode
npx get-graphql-schema http://localhost:8000/graphql > lib/graphql/your_app.schema.graphql
Enter fullscreen mode Exit fullscreen mode

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).

# 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
}
Enter fullscreen mode Exit fullscreen mode

The Implementation!

Run the following command to generate all dart types for our .graphql files.

dart run build_runner build
Enter fullscreen mode Exit fullscreen mode

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.

// 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());
}
Enter fullscreen mode Exit fullscreen mode

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

// 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(),
        ),
      ),
    );
  }
Enter fullscreen mode Exit fullscreen mode

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

// core/models/auth_data.model.dart

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

  const AuthData({
    required this.accessToken,
    required this.refreshToken,
  });
}
Enter fullscreen mode Exit fullscreen mode
// 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!,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Our secure storage service file saves authentication information:

// 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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

// 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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.";
  }
}
Enter fullscreen mode Exit fullscreen mode

Our base view and base view model:

// 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),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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)),
          ],
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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"),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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

// 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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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(...)
}
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . .