native

Flutter

Flutter

In this quickstart, you are going to build a Flutter mobile application and integrate it with FusionAuth. You’ll be building it for ChangeBank, a global leader in converting dollars into coins. It’ll have areas reserved for users who have logged in as well as public facing sections. It uses Flutter and sets up an OAuth authorization code grant flow, allowing users to log in to the application.

The Docker Compose file and source code for a complete application are available at https://github.com/FusionAuth/fusionauth-quickstart-flutter-native.

Prerequisites

For this Quickstart, you’ll need.

To make sure your Flutter development environment is working correctly, run the following command in your terminal window.

flutter doctor

If everything is configured properly, you will see something like the following result in your terminal window:

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.13.12)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 14.3.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2022.3)
[✓] IntelliJ IDEA Community Edition (version 2022.2)
[✓] Connected device (2 available)
[✓] Network resources

 No issues found!

General Architecture

While this sample application doesn't have login functionality without FusionAuth, a more typical integration will replace an existing login system with FusionAuth.

In that case, the system might look like this before FusionAuth is introduced.

UserApplicationView HomepageClick Login LinkShow Login FormFill Out and Submit Login FormAuthenticates UserDisplay User's Account or OtherInfoUserApplication

Request flow during login before FusionAuth

The login flow will look like this after FusionAuth is introduced.

UserApplicationFusionAuthView HomepageClick Login Link (to FusionAuth)View Login FormShow Login FormFill Out and Submit Login FormAuthenticates UserGo to Redirect URIRequest the Redirect URIIs User Authenticated?User is AuthenticatedDisplay User's Account or OtherInfoUserApplicationFusionAuth

Request flow during login after FusionAuth

In general, you are introducing FusionAuth in order to normalize and consolidate user data. This helps make sure it is consistent and up-to-date as well as offloading your login security and functionality to FusionAuth.

Getting Started

Start by getting FusionAuth up and running and creating a Flutter application.

Clone The Code

First, grab the code from the repository and change into that directory.

git clone https://github.com/FusionAuth/fusionauth-quickstart-flutter-native.git
cd fusionauth-quickstart-flutter-native

All shell commands in this guide can be entered in a terminal in this folder. On Windows, you need to replace forward slashes with backslashes in paths.

The files you’ll create in this guide already exist in the complete-application folder, if you prefer to copy them.

Run FusionAuth Via Docker

You'll find a Docker Compose file (docker-compose.yml) and an environment variables configuration file (.env) in the root directory of the repo.

Assuming you have Docker installed, you can stand up FusionAuth on your machine with the following.

docker compose up -d

Here you are using a bootstrapping feature of FusionAuth called Kickstart. When FusionAuth comes up for the first time, it will look at the kickstart/kickstart.json file and configure FusionAuth to your specified state.

If you ever want to reset the FusionAuth application, you need to delete the volumes created by Docker Compose by executing docker compose down -v, then re-run docker compose up -d.

FusionAuth will be initially configured with these settings:

  • Your client Id is e9fdb985-9173-4e01-9d73-ac2d60d1dc8e.
  • Your client secret is super-secret-secret-that-should-be-regenerated-for-production.
  • Your example username is richard@example.com and the password is password.
  • Your admin username is admin@example.com and the password is password.
  • The base URL of FusionAuth is http://localhost:9011/.

You can log in to the FusionAuth admin UI and look around if you want to, but with Docker and Kickstart, everything will already be configured correctly.

If you want to see where the FusionAuth values came from, they can be found in the FusionAuth app. The tenant Id is found on the Tenants page. To see the Client Id and Client Secret, go to the Applications page and click the View icon under the actions for the ChangeBank application. You'll find the Client Id and Client Secret values in the OAuth configuration section.

The .env file contains passwords. In a real application, always add this file to your .gitignore file and never commit secrets to version control.

Set Up A Public URL For FusionAuth

Your FusionAuth instance is now running on a different machine (your computer) than the mobile app will run (either a real device or an emulator), which means that it won’t be able to access localhost.

If the device and your computer are not connected to the same network or if you have something that blocks connections (like a firewall), learn how to expose a local FusionAuth instance to the internet. In summary, the process entails configuring ngrok on your local system, starting your FusionAuth instance on port 9011, and subsequently executing the following command.

ngrok http --request-header-add 'X-Forwarded-Port:443' 9011

This will generate a public URL that you can use to access FusionAuth when developing the app.

If the device (either real or emulator) and your computer are connected to the same network, you can use the local IP Address for your machine (for example, 192.168.15.2). Here are a few articles to help you find your IP address, depending on the operating system you are running:

Create Your Flutter Application

In the directory where the project should live, run the following command to create and set up the new Flutter app.

flutter create --org com.fusionauth flutterdemo --platforms=ios,android

After the installation process completes, you will see that the flutterdemo directory contains all the Flutter starter app configuration. Open the project directory with the text editor of your choice.

You can run your new project on the actual device or an emulator to confirm everything is working before you customize any code. Do so by running the following command in the project directory.

cd flutterdemo
flutter run

From the list of emulators or devices that can be used, choose the one that you want to run the code on. Stay in this directory for the rest of this tutorial. Stay in this directory for the rest of this tutorial.

To run the project on iOS and Android together, you can use the following command.

flutter run -d all

The build process takes a while the first time an app is built. After a successful build, you will get the boilerplate Flutter app running in your emulators.

Now that you have a basic working application running, you can add authentication.

Authentication

AppAuth is a popular OAuth package that can be used in both native and cross-platform mobile applications. In this project, you will be storing the access token using the secure storage package. Since such tokens allow your application to access protected resources such as APIs, you need to take care they are stored as securely as possible.

Run the command below to install the project dependencies.

flutter pub add http flutter_appauth flutter_secure_storage flutter_svg

The packages added to the application with the command above do the following:

  • The http package provides a way for the Flutter application to interact with web services and APIs.
  • flutter_appauth allows you to perform OAuth 2.0 authentication flows in the application.
  • flutter_secure_storage provides a secure storage for sensitive data like tokens in Flutter apps.
  • flutter_svg allows you to render Scalable Vector Graphics (SVG) images in your application. You will make use of the package to render the application logo.

Setting Up AppAuth

Now you can add the previously configured callback URL to the Android and iOS directories with native configuration.

Let’s look at Android first.

Android Setup

In your editor, go to the android/app/build.gradle file for your Android app to specify the custom redirect scheme. Find the code block similar to the code below and add com.fusionauth.flutterdemo as the appAuthRedirectScheme in the manifestPlaceholders array.

    defaultConfig {
        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
        applicationId "com.fusionauth.flutterdemo"
        // You can update the following values to match your application needs.
        // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
        minSdkVersion flutter.minSdkVersion
        targetSdkVersion flutter.targetSdkVersion
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
        manifestPlaceholders += [
            'appAuthRedirectScheme': 'com.fusionauth.flutterdemo'
        ]
    }

iOS Setup

Now edit the ios/Runner/Info.plist file in the iOS app to specify the custom scheme. Find the section that looks similar to the following and add the FusionAuth URL com.fusionauth.flutterdemo.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleDevelopmentRegion</key>
	<string>$(DEVELOPMENT_LANGUAGE)</string>
	<key>CFBundleDisplayName</key>
	<string>Flutterdemo</string>
	<key>CFBundleExecutable</key>
	<string>$(EXECUTABLE_NAME)</string>
	<key>CFBundleIdentifier</key>
	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
	<key>CFBundleInfoDictionaryVersion</key>
	<string>6.0</string>
	<key>CFBundleName</key>
	<string>flutterdemo</string>
	<key>CFBundlePackageType</key>
	<string>APPL</string>
	<key>CFBundleShortVersionString</key>
	<string>$(FLUTTER_BUILD_NAME)</string>
	<key>CFBundleSignature</key>
	<string>????</string>
	<key>CFBundleVersion</key>
	<string>$(FLUTTER_BUILD_NUMBER)</string>
	<key>LSRequiresIPhoneOS</key>
	<true/>
	<key>UILaunchStoryboardName</key>
	<string>LaunchScreen</string>
	<key>UIMainStoryboardFile</key>
	<string>Main</string>
	<key>UISupportedInterfaceOrientations</key>
	<array>
		<string>UIInterfaceOrientationPortrait</string>
		<string>UIInterfaceOrientationLandscapeLeft</string>
		<string>UIInterfaceOrientationLandscapeRight</string>
	</array>
	<key>UISupportedInterfaceOrientations~ipad</key>
	<array>
		<string>UIInterfaceOrientationPortrait</string>
		<string>UIInterfaceOrientationPortraitUpsideDown</string>
		<string>UIInterfaceOrientationLandscapeLeft</string>
		<string>UIInterfaceOrientationLandscapeRight</string>
	</array>
	<key>CADisableMinimumFrameDurationOnPhone</key>
	<true/>
	<key>UIApplicationSupportsIndirectInputEvents</key>
	<true/>
	<key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleTypeRole</key>
            <string>Editor</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>com.fusionauth.flutterdemo</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

Make sure the ios/Runner/Info.plist file has CFBundleURLTypes and CFBundleURLSchemes keys as in the code above.

Dive Into The Code

Open the main.dart file in the lib directory of your project and paste the contents below into it.

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_svg/flutter_svg.dart';

const FlutterAppAuth appAuth = FlutterAppAuth();
const FlutterSecureStorage secureStorage = FlutterSecureStorage();

/// For a real-world app, this should be an Internet-facing URL to FusionAuth.
/// If you are running FusionAuth locally and just want to test the app, you can
/// specify a local IP address (if the device is connected to the same network
/// as the computer running FusionAuth) or even use ngrok to expose your
/// instance to the Internet temporarily.
const String FUSIONAUTH_DOMAIN = 'your-fusionauth-public-url-without-scheme';
const String FUSIONAUTH_SCHEME = 'https';
const String FUSIONAUTH_CLIENT_ID = 'e9fdb985-9173-4e01-9d73-ac2d60d1dc8e';
const String FUSIONAUTH_REDIRECT_URI =
    'com.fusionauth.flutterdemo://login-callback';
const String FUSIONAUTH_LOGOUT_REDIRECT_URI =
    'com.fusionauth.flutterdemo://logout-callback';
const String FUSIONAUTH_ISSUER = '$FUSIONAUTH_SCHEME://$FUSIONAUTH_DOMAIN';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

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

class MyAppState extends State<MyApp> {
  bool isBusy = false;
  bool isLoggedIn = false;
  String? errorMessage;
  String? email;

  @override
  void initState() {
    super.initState();
    initAction();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FusionAuth on Flutter',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
          primaryColor: const Color(0xFF085b21),
          bottomNavigationBarTheme: const BottomNavigationBarThemeData(
            selectedItemColor: Color(0xFF085b21),
          )),
      home: Scaffold(
        body: Center(
          child: isBusy
              ? const CircularProgressIndicator()
              : isLoggedIn
                  ? HomePage(logoutAction, email)
                  : Login(loginAction, errorMessage),
        ),
      ),
    );
  }

  Future<Map<String, dynamic>> getUserDetails(String accessToken) async {
    final http.Response response = await http.get(
      Uri.parse('$FUSIONAUTH_SCHEME://$FUSIONAUTH_DOMAIN/oauth2/userinfo'),
      headers: <String, String>{'Authorization': 'Bearer $accessToken'},
    );

    if (response.statusCode == 200) {
      return jsonDecode(response.body);
    } else {
      throw Exception('Failed to get user details');
    }
  }

  Future<void> loginAction() async {
    setState(() {
      isBusy = true;
      errorMessage = '';
    });

    try {
      final AuthorizationTokenResponse? result =
          await appAuth.authorizeAndExchangeCode(
        AuthorizationTokenRequest(
          FUSIONAUTH_CLIENT_ID,
          FUSIONAUTH_REDIRECT_URI,
          issuer: FUSIONAUTH_ISSUER,
          scopes: <String>['openid', 'email', 'profile', 'offline_access'],
        ),
      );
      if (result != null) {
        final Map<String, dynamic> profile =
            await getUserDetails(result.accessToken!);

        debugPrint('response: $profile');
        await secureStorage.write(
            key: 'refresh_token', value: result.refreshToken);
        await secureStorage.write(key: 'id_token', value: result.idToken);
        setState(() {
          isBusy = false;
          isLoggedIn = true;
          email = profile['email'];
        });
      }
    } on Exception catch (e, s) {
      debugPrint('login error: $e - stack: $s');

      setState(() {
        isBusy = false;
        isLoggedIn = false;
        errorMessage = e.toString();
      });
    }
  }

  Future<void> initAction() async {
    final String? storedRefreshToken =
        await secureStorage.read(key: 'refresh_token');
    if (storedRefreshToken == null) {
      return;
    }

    setState(() {
      isBusy = true;
    });

    try {
      final TokenResponse? response = await appAuth.token(TokenRequest(
        FUSIONAUTH_CLIENT_ID,
        FUSIONAUTH_REDIRECT_URI,
        issuer: FUSIONAUTH_ISSUER,
        refreshToken: storedRefreshToken,
        scopes: <String>['openid', 'offline_access'],
      ));

      if (response != null) {
        final Map<String, dynamic> profile =
            await getUserDetails(response.accessToken!);

        await secureStorage.write(
            key: 'refresh_token', value: response.refreshToken);

        setState(() {
          isBusy = false;
          isLoggedIn = true;
          email = profile['email'];
        });
      }
    } on Exception catch (e, s) {
      debugPrint('error on refresh token: $e - stack: $s');
      await logoutAction();
    }
  }

  Future<void> logoutAction() async {
    final String? storedIdToken = await secureStorage.read(key: 'id_token');
    if (storedIdToken == null) {
      debugPrint(
          'Could not retrieve id_token for actual logout. Deleting local cookies only...');
    } else {
      try {
        await appAuth.endSession(EndSessionRequest(
            idTokenHint: storedIdToken,
            postLogoutRedirectUrl: FUSIONAUTH_LOGOUT_REDIRECT_URI,
            issuer: FUSIONAUTH_ISSUER,
            allowInsecureConnections: FUSIONAUTH_SCHEME != 'https'));
      } catch (err) {
        debugPrint('logout error: $err');
      }
    }
    await secureStorage.deleteAll();
    setState(() {
      isLoggedIn = false;
      isBusy = false;
    });
  }
}

class Login extends StatelessWidget {
  final Future<void> Function() loginAction;
  final String? loginError;

  const Login(this.loginAction, this.loginError, {Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
        padding: const EdgeInsets.all(32),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            SvgPicture.asset(
              'assets/example_bank_logo.svg',
              width: 150,
              height: 100,
            ),
            const SizedBox(
              height: 30,
            ),
            Row(
              children: [
                Expanded(
                    child: ElevatedButton(
                  onPressed: () async {
                    await loginAction();
                  },
                  style: ElevatedButton.styleFrom(
                    backgroundColor: const Color(0xFF085b21),
                  ),
                  child: const Text('Login'),
                )),
              ],
            ),
            const SizedBox(
              height: 30,
            ),
            Text(
              loginError ?? '',
              style: const TextStyle(color: Colors.red),
            ),
          ],
        ));
  }
}

class HomePage extends StatefulWidget {
  final Future<void> Function() logoutAction;
  final String? email;

  const HomePage(this.logoutAction, this.email, {Key? key}) : super(key: key);

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

class HomePageState extends State<HomePage> {
  int _selectedIndex = 0;
  late List<Widget> _pages;

  @override
  void initState() {
    super.initState();
    _pages = [AccountPage(email: widget.email), ChangeCalculatorPage()];
  }

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: Colors.white,
        toolbarHeight: 100,
        title: SvgPicture.asset(
          'assets/example_bank_logo.svg',
          width: 150,
          height: 100,
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout, color: Colors.black),
            onPressed: () async {
              await widget.logoutAction();
            },
          ),
        ],
      ),
      body: _pages[_selectedIndex],
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.account_box),
            label: 'Account',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.monetization_on_outlined),
            label: 'Make Change',
          ),
        ],
        selectedFontSize: 18.0,
        unselectedFontSize: 18.0,
        currentIndex: _selectedIndex,
        onTap: _onItemTapped,
      ),
    );
  }
}

class AccountPage extends StatelessWidget {
  final String? email;

  const AccountPage({super.key, required this.email});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: SizedBox(
        width: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Welcome: $email',
              style: const TextStyle(fontSize: 20),
            ),
            const SizedBox(height: 50),
            const Text(
              'Your Balance',
              style: TextStyle(fontSize: 24),
            ),
            const SizedBox(height: 24),
            const Text(
              '\$0.00',
              style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 32),
          ],
        ),
      ),
    );
  }
}

class ChangeCalculatorPage extends StatefulWidget {

  ChangeCalculatorPage({super.key});

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

class ChangeCalculatorPageState extends State<ChangeCalculatorPage> {
  final TextEditingController _changeController = TextEditingController();
  String _result = 'We make change for \$0 with 0 nickels and 0 pennies!';

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          const SizedBox(height: 32),
          Text(
            _result,
            style: const TextStyle(fontSize: 18),
          ),
          const SizedBox(height: 24),
          TextField(
            controller: _changeController,
            keyboardType: TextInputType.number,
            decoration: const InputDecoration(
              labelText: 'Amount in USD',
              focusedBorder: OutlineInputBorder(
                borderSide: BorderSide(color: Color(0xFF085b21)),
              ),
              enabledBorder: OutlineInputBorder(
                borderSide: BorderSide(color: Color(0xFF085b21)),
              ),
              labelStyle: TextStyle(color: Color(0xFF085b21)),
            ),
          ),
          const SizedBox(height: 20),
          Row(
            children: [
              Expanded(
                child: ElevatedButton(
                  onPressed: () {
                    calculateChange();
                  },
                  style: ElevatedButton.styleFrom(
                    backgroundColor: const Color(0xFF085b21),
                  ),
                  child: const Text('Make Change'),
                ),
              ),
            ],
          ),
          const SizedBox(height: 32),
        ],
      ),
    );
  }

  void calculateChange() {
    try {
      double totalValue = double.tryParse(_changeController.text) ?? 0;
      int totalCents = (totalValue * 100).toInt();
      int nickels = totalCents ~/ 5;
      int pennies = totalCents % 5;
      setState(() {
        _result =
            'We make change for \$${_changeController.text} with $nickels nickels and $pennies pennies!';
      });
    } catch (e) {
      setState(() {
        _result = 'Please enter a valid number.';
      });
    }
  }

  @override
  void dispose() {
    _changeController.dispose();
    super.dispose();
  }
}

Putting all your logic in one file makes sense for a tutorial but for a larger application, you’ll probably want to split it up.

At the top of the file, change the FUSIONAUTH_DOMAIN constant to the public URL for your FusionAuth instance (the URL you used when configuring it).

For security, this code uses the system browser instead of an embedded webview. Current mobile best practices for OAuth require you to use the system browser rather than a webview, as a webview is controlled by the native application displaying it. From section 8.12 of that document:

This best current practice requires that native apps MUST NOT use embedded user-agents to perform authorization requests and allows that authorization endpoints MAY take steps to detect and block authorization requests in embedded user-agents.

Customization

The final step is to add a logo to be displayed in the app. To do that, run the following commands.

mkdir assets
curl -o assets/example_bank_logo.svg https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-flutter-native/main/complete-application/assets/example_bank_logo.svg

In pubspec.yaml, add an assets section that will allow the logo to be copied over to the application.

flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  assets:
     - assets/example_bank_logo.svg

Run The Application

Start up your emulators or real devices again.

flutter run -d all

Log in using richard@example.com and password.

Made it this far? Get a free t-shirt, on us!

Thank you for spending some time getting familiar with FusionAuth.

*Offer only valid in the United States and Canada, while supplies last.

fusionauth tshirt

Next Steps

This quickstart is a great way to get a proof of concept up and running quickly, but to run your application in production, there are some things you're going to want to do.

FusionAuth Customization

FusionAuth gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes:

Security

Tenant and Application Management

Additional Resources

Want to dive in further? Here are some additional resources for understanding auth in Flutter and mobile applications.

Troubleshooting

  • I get Error retrieving discovery document: A server with the specified hostname could not be found when I click the login button.

Ensure FusionAuth is running on a publicly accessible URL and that the FUSIONAUTH_DOMAIN variable in main.dart is set to the correct URL of your FusionAuth instance.

  • I get Resolving dependencies... Because flutterdemo requires SDK version >=3.0.0 <4.0.0, version solving failed.

Ensure you have the latest Flutter version and that the version of Dart is greater than 3.0.0 by running the following command.

flutter upgrade