Flutter and GraphQL with Authentication

Flutter and GraphQL with Authentication

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.


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

Give permission to run the script.

chmod +x ./scripts/gen-schema.sh

Run the script to download your_app.schema.graphql

./scripts/gen-schema.sh

Run build_runner to convert graphql files from lib/graphql/queries to dart types.

dart run build_runner build

Tools we will be using:

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


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

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

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

Help & Community

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

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.

pub.dev

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.

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

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

Add the following content to your build.yaml file,

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.

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

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

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

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

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.

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

// 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:

// core/models/auth_data.model.dart

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

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

// 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:

// 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:

// 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:

// 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:

// 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),
);
}
}

// 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:

// 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 🎉:

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

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