native

Flutter

Flutter

In this quickstart, you will build a Flutter mobile application and integrate it with FusionAuth. The application is for ChangeBank, a global leader in converting dollars into coins. It will have areas reserved for logged in users and public-facing sections. It uses Flutter and sets up an OAuth authorization code grant flow, allowing users to log in to the application.

Find the Docker Compose file and source code for the complete application 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 would introduce FusionAuth to normalize and consolidate user data, ensuring consistency and reliability, while 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 start FusionAuth on your machine with the following.

docker compose up -d

This will start three containers, one each for FusionAuth, Postgres, and Elasticsearch.

Here you are using a bootstrapping feature of FusionAuth, called Kickstart. When FusionAuth starts 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 system, delete the volumes created by docker-compose by executing docker compose down -v, then rerun docker compose up -d.

FusionAuth will be configured with these settings:

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.

The .env and kickstart.json files contain passwords. In a real application, always add these files 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:

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', '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.

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 with the user’s experience and your application’s integration. 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

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.

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