Docker, which is the quickest way to start FusionAuth. (There are other ways).
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!
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.
Request flow during login before FusionAuth
The login flow will look like this after FusionAuth is introduced.
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.
Start by getting FusionAuth up and running and creating a Flutter application.
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.
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:
e9fdb985-9173-4e01-9d73-ac2d60d1dc8e
.super-secret-secret-that-should-be-regenerated-for-production
.richard@example.com
and the password is password
.admin@example.com
and the password is password
.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.
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:
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.
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:
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.Now you can add the previously configured callback URL to the Android and iOS directories with native configuration.
Let’s look at Android first.
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'
]
}
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.
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.
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
Start up your emulators or real devices again.
flutter run -d all
Log in using richard@example.com
and password
.
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 gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes:
Want to dive in further? Here are some additional resources for understanding auth in Flutter and mobile applications.
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.
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