Authentication is a basic necessity when building a messaging app with Stream. It helps secure the messaging environment and also provides a customized experience on a per-user basis.
Stream uses JWT (JSON Web Tokens) to authenticate users. Generally, to generate and provide these authentication tokens to your app, you need to maintain a backend server.
But in this article, you’ll learn how you can easily handle the whole authentication process in a serverless environment using Firebase Authentication and Cloud Functions.
Introduction to Firebase Authentication and Cloud Functions
Firebase provides pre-configured backend services to help you build serverless applications. Firebase Authentication and Cloud Functions are two of the services provided by Firebase.
Firebase Authentication lets you secure your application without having to maintain any backend infrastructure. It supports the traditional email-password authentication, as well as integration with various identity providers like Google, Apple, Facebook, and GitHub.
Cloud Functions allow you to run backend code on the serverless infrastructure managed by Firebase. You can trigger functions based upon the events of any Firebase service, or you can also define functions that need to be triggered by HTTP requests.
We’ll explore these in detail throughout the article. Let’s get started by creating a new Flutter project.
Create Your Flutter app
You can create a new Flutter project directly using your IDE (like VS Code, IntelliJ, Android Studio), or from your terminal using the following command:
1flutter create stream_auth_firebase
Note: This tutorial was created using version 2.5.3 of Flutter from the stable channel.
Open the project using your preferred IDE and navigate to the pubspec.yaml
file. Add the following dependencies:
1dependencies: firebase_core: ^1.10.0 firebase_auth: ^3.2.0 cloud_functions: ^3.1.1 stream_chat_flutter: ^3.2.0
Go to your lib/main.dart
file, and replace it with the following code:
12345678910111213141516171819202122232425262728293031323334353637import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(); runApp(MyApp()); } class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'Stream Auth', theme: ThemeData( primarySwatch: Colors.blue, ), debugShowCheckedModeBanner: false, home: LoginPage(), ); } } class LoginPage extends StatefulWidget { const LoginPage({Key? key}) : super(key: key); _LoginPageState createState() => _LoginPageState(); } class _LoginPageState extends State<LoginPage> { Widget build(BuildContext context) { return Container(); } }
Inside the main()
method, Firebase is initialized with the Firebase.initializeApp()
call. You should call this method before using any other Firebase services, otherwise, it may result in an error.
The LoginPage
is empty for now (we will work on it in a while to build the sign-in and sign-up interface). But before that, let’s complete the Firebase setup.
Stream Setup
You will need to create a Stream app to access chat messaging features. If you don’t already have a Stream account, you can start your free trial for Stream’s Chat Messaging.
Working on a personal project? You can register for a Stream Maker Account and access Stream Chat for free indefinitely.
From the Stream dashboard:
- Click Create App.
- Enter your app name.
- Select a server location.
- Set your Environment as Development.
- Click Create App.
This will create a new Stream App. You can navigate to the app from the Stream dashboard:
Two important things that you’ll need in order to access Stream from your app and cloud functions:
- API Key: App identifier, safe to share publicly
- Secret: Helps generate user tokens, should be kept private
Create a new Firebase Project
You have to create a new Firebase project to integrate it with your Stream Flutter app and access the services. Follow the steps below to create a new Firebase project:
- Go to the Firebase console.
- Click Add project.
- Enter a Project name and click Continue.
- Next, you will be asked whether you want to enable Google Analytics for the project. You won’t need analytics as this is just a sample project. Click Create project.
If you want to enable Google Analytics, you can select a Google Analytics account on the next screen:
Once the project is created, Firebase will navigate you to your project’s Firebase dashboard.
Configure Firebase for Android and iOS
After creating your Firebase project, you can integrate Firebase with the Android and iOS platforms. This will allow you to access Firebase services within your app.
Android configuration
- From the Firebase dashboard, click on the Android icon.
- Enter the Android package name, an App nickname, and the SHA-1. Click Register app.
- Download the
google-services.json
file and place it in your android -> app directory. Click Next.
- Follow the steps and add the required code snippets to your project. Click Next.
- Finally, click Continue to console to return to your Firebase dashboard.
You have successfully configured Firebase for Android.
iOS configuration
- Click Add app and select the iOS icon.
- Enter your iOS bundle ID and an App nickname. Click Register app.
- Download the
GoogleService-Info.plist
file. Click Next.
-
Back in your project:
- Open the
ios
folder using Xcode. - Drag and drop the file that you downloaded into the Runner subfolder.
- When a dialog box appears, make sure that Runner is selected in the Add to targets box. Click Finish.
- Open the
- You can skip steps three and four, as they are automatically configured by the Flutter Firebase plugin.
- Click Continue to console to go back to the Firebase dashboard.
You have successfully configured Firebase for the iOS platform as well.
Set Up Firebase Cloud Functions
You should upgrade your Firebase project to the Blaze Plan to access the Cloud Functions service.
You can upgrade your project by going to the Firebase dashboard and clicking on the Modify button on the left menu beside the current plan (every Firebase project uses the Spark Plan by default).
Once you have the project upgraded, select Functions from the left sidebar and click Get Started.
A Set up Functions dialog box will open with Install and Deploy steps.
If you don’t have Firebase tools installed on your system, run the Install step:
1$ npm install -g firebase-tools
Click Continue. In the Deploy step, you will find commands for initializing and deploying functions:
Follow the steps below to get started writing cloud functions:
- Navigate to your Flutter project directory, and initialize the project there using:
1firebase init
- When prompted to select the Firebase features to use, select Functions.
- In Project Setup, select Use an existing project and choose the Firebase project that you created earlier.
- In Functions Setup, choose:
- Language: JavaScript
- ESLint enable: Yes
- Install dependencies: Yes
Once the Firebase initialization process finishes, it will generate a new folder inside your project directory called functions
with the following content:
The package.json
contains the dependencies for your Cloud Functions. You will need to define the functions inside the index.js
file.
Defining Cloud Functions
You need to install the stream-chat Node.js dependency to access the Stream APIs.
Run the following command:
1npm install stream-chat --save-prod
This command will install the dependency and add it to the package.json
file.
Navigate to the functions/index.js
file and import the required dependencies:
123const StreamChat = require("stream-chat").StreamChat; const functions = require("firebase-functions"); const admin = require("firebase-admin");
Before using any Firebase services, you will need to initialize the app:
1admin.initializeApp();
Create an instance of the Stream client:
1234const serverClient = StreamChat.getInstance( functions.config().stream.key, functions.config().stream.secret, );
To create the server-client, you will need the Stream Key and Stream Secret
Note:
functions.config()
helps in passing these as arguments while deploying the functions.
Create a Stream User
When a user registers in the app, a new Stream user needs to be generated using a cloud function trigger. Let’s define a function called createStreamUserAndGetToken
:
1234567exports.createStreamUserAndGetToken = functions.https.onCall(async (data, context) => { if (!context.auth) { // Throw an error message } else { // Create a new user } });
Here, you use context.auth
to check if the user is authenticated. In case the user is not authenticated, throw an error:
1234throw new functions.https.HttpsError("failed-precondition", "The function must be called " + "while authenticated." );
If the user is authenticated, a new Stream user can be generated:
12345678910try { await serverClient.upsertUser({ id: context.auth.uid, name: context.auth.token.name, email: context.auth.token.email, image: context.auth.token.image, }); return serverClient.createToken(context.auth.uid); } catch (err) { console.error(
You can create a new Stream user by calling the upsertUser()
method on the serverClient
object, passing the user data retrieved from the authenticated user.
The most important parameter here is id
. To make sure the id
is always unique, we use the uid
generated by Firebase (uid
is a unique identifier for every Firebase authenticated user).
Once the user is created, you can retrieve the token using the createToken()
method. You will need this token inside the Flutter app to get access to the user and make requests to the Stream API.
Get Stream User Token
For registered users, you don’t need to generate a new Stream user, just a new token. You can define the following function for the signed-in users:
12345678exports.getStreamUserToken = functions.https.onCall((data, context) => { if (!context.auth) { throw new functions.https.HttpsError("failed-precondition", "The function must be called " + "while authenticated."); } else { try { return serverClient.createToken(context.auth.uid); } catch (err) { console.error(
If the user is authenticated, the authenticated user’s uid
is used to generate a new token. Otherwise, an error is thrown.
Revoke Stream User token
If a user signs out of Firebase within the app, you should revoke the Stream user token using a cloud function:
12345678exports.revokeStreamUserToken = functions.https.onCall((data, context) => { if (!context.auth) { throw new functions.https.HttpsError("failed-precondition", "The function must be called " + "while authenticated."); } else { try { return serverClient.revokeUserToken(context.auth.uid); } catch (err) { console.error(
The revokeUserToken()
method is used by passing the authenticated user’s uid
. This will revoke the current Stream user token.
Delete Stream User
Finally, we will define a cloud function to delete the Stream user when its respective user is deleted from Firebase:
123exports.deleteStreamUser = functions.auth.user().onDelete((user, context) => { return serverClient.deleteUser(user.uid); });
You won’t need to call this function explicitly, this will automatically trigger when an account is deleted from Firebase.
Deploying Functions to Firebase
You must deploy the functions to Firebase before you can trigger them from the Flutter app.
Before deploying your functions, you need to store the Stream Key and the Stream Secret as environment variables. Use the following command:
1firebase functions:config:set stream.key="app-key" stream.secret="app-secret"
Replace the app-key and app-secret placeholders with their appropriate values.
Finally, deploy your functions using:
1firebase deploy --only functions
After successfully deploying your cloud functions, you will be able to see them on the Firebase Functions page.
(You can find this page in your Firebase project dashboard by selecting Functions from the left menu.)
Integrate Firebase Authentication
Let’s return to the Flutter app to implement Firebase Authentication.
Create a new Dart file called authentication.dart
and define a new class called Authentication
:
123456789101112131415161718class Authentication { FirebaseAuth auth = FirebaseAuth.instance; FirebaseFunctions functions = FirebaseFunctions.instance; static User? firebaseUser; // Custom snackbar widget static SnackBar customSnackBar({required String content}) { return SnackBar( backgroundColor: Colors.black, content: Text( content, style: TextStyle(color: Colors.redAccent, letterSpacing: 0.5), ), ); } // ... }
Firebase Auth and Firebase Functions are instantiated. Also, a variable of type User
is defined, inside which the user information is stored after authentication.
The customSnackBar
is defined to easily display a custom error during the authentication process.
Register Using Email/Password
Define a method called registerUsingEmailPassword()
that accepts a few parameters like name
, email
, password
, and context
:
12345678Future<void> registerUsingEmailPassword({ required String name, required String email, required String password, required BuildContext context, }) async { // Define the user registration process }
You can define a new user registration process like this:
12345678910111213141516171819202122232425262728293031try { UserCredential userCredential = await auth.createUserWithEmailAndPassword( email: email, password: password, ); firebaseUser = userCredential.user; if (firebaseUser != null) { await firebaseUser!.updateDisplayName(name); await firebaseUser!.reload(); firebaseUser = auth.currentUser; } else { throw ('Firebase user is null'); } final callable = functions.httpsCallable('createStreamUserAndGetToken'); final results = await callable(); String? token = results.data; if (token != null) { print('Stream token retrieved (registered)'); StreamClient.initialize(token, context); } } on FirebaseAuthException catch (e) { // Handle an error caused by Firebase } catch (e) { // In case of any other kind of error print(e); }
In the above code snippet, the createUserWithEmailAndPassword()
method is called on the auth
object to create a new user and get the credentials.
The authenticated user can be retrieved from the credentials. To set the name of the user, the updateDisplayName()
method is used.
Once you have the authenticated user, you can call the createStreamUserAndGetToken
cloud function using functions.httpsCallable()
.
The cloud function returns the generated Stream user token, which is used to initialize Stream in the Flutter app using StreamClient.initialize()
. (The StreamClient
is a custom class that we’ll define after the Authentication
class is complete.)
The Firebase Exceptions can be handled like this:
123456789101112131415161718192021try { // ... } on FirebaseAuthException catch (e) { if (e.code == 'weak-password') { print('The password provided is too weak.'); ScaffoldMessenger.of(context).showSnackBar( Authentication.customSnackBar( content: 'The password provided is too weak.', ), ); } else if (e.code == 'email-already-in-use') { print('The account already exists for that email.'); ScaffoldMessenger.of(context).showSnackBar( Authentication.customSnackBar( content: 'The account already exists for that email.', ), ); } } catch (e) { print(e); }
Sign-In
The method for Firebase Sign-In using email/password is similar to the registration process.
But here, you’ll need to use the signInWithEmailAndPassword()
method on the auth
object to get the user credentials and use the getStreamUserToken
cloud function to generate a new token for the user.
Then similarly, use the token to initialize Stream Chat in the app:
12345678910111213141516171819202122232425262728293031323334353637383940Future<void> signInUsingEmailPassword({ required String email, required String password, required BuildContext context, }) async { String? token; try { UserCredential userCredential = await auth.signInWithEmailAndPassword( email: email, password: password, ); firebaseUser = userCredential.user; final callable = functions.httpsCallable('getStreamUserToken'); final results = await callable(); token = results.data; if (token != null) { print('Stream token retrieved (signed in)'); StreamClient.initialize(token, context); } } on FirebaseAuthException catch (e) { if (e.code == 'user-not-found') { print('No user found for that email.'); ScaffoldMessenger.of(context).showSnackBar( Authentication.customSnackBar( content: 'No user found for that email. Please create an account.', ), ); } else if (e.code == 'wrong-password') { print('Wrong password provided.'); ScaffoldMessenger.of(context).showSnackBar( Authentication.customSnackBar( content: 'Wrong password provided.', ), ); } } }
Sign Out
Use this method to sign out of Firebase. While a user tries to sign out of Firebase, you should also revoke the current Stream token of the user by calling the revokeStreamUserToken
cloud function.
Once the token is revoked, close the Stream client connection by calling the closeConnection()
method before signing out of Firebase:
12345678910111213Future<void> signOut() async { // Revoke Stream user token final callable = functions.httpsCallable('revokeStreamUserToken'); await callable(); print('Stream user token revoked'); // Close connection StreamClient.client.closeConnection(); // Sign out Firebase await auth.signOut(); print('Firebase signed out'); }
Define Stream Client
Create a new file called stream_client.dart
and define the StreamClient
class. This class will be responsible for instantiating the StreamChatClient
, connecting to the Stream user, and navigating to the Stream Chat page.
To instantiate the client:
123456class StreamClient { static final client = StreamChatClient( streamKey, logLevel: Level.OFF, ); }
Replace streamKey with your Stream app API Key.
Define the initialize()
method:
123456789101112131415161718192021222324252627282930313233343536class StreamClient { // ... static initialize(String token, BuildContext context) async { final authenticatedUser = Authentication.firebaseUser!; await client.connectUser( User( id: authenticatedUser.uid, extraData: { 'name': authenticatedUser.displayName, 'image': authenticatedUser.photoURL, }, ), token, ); final channel = client.channel('messaging', id: 'general'); await channel.watch(); Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => MaterialApp( builder: (context, widget) { return StreamChat( child: widget, client: client, ); }, debugShowCheckedModeBanner: false, home: ChannelListPage(channel), ), ), ); } }
In the above code snippet, you:
- Retrieve the Firebase authenticated user.
- Connect to the Stream user with the
client.connectUser()
method. - Set up a channel for messaging.
- Navigate to the
ChannelListPage
containing a list of all channels of your Stream app.
Building the Login and Register Pages
Now that you have defined most of the functionalities, you can start building the interface of the app. There will be two pages responsible for authenticating the user:
- Login Page: For users who are already registered in the app
- Register Page: For new users who want to access the app
Let’s start building the Login Page UI.
This page will mainly consist of two TextField
widgets for taking the email and password as user inputs, and a button for triggering the email/password sign-in process.
Define the LoginPage
as a StatefulWidget:
12345678910111213141516171819class LoginPage extends StatefulWidget { _LoginPageState createState() => _LoginPageState(); } class _LoginPageState extends State<LoginPage> { final _authentication = Authentication(); final _loginFormKey = GlobalKey<FormState>(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); bool _isSigningIn = false; Widget build(BuildContext context) { return Scaffold(); } }
Here, you instantiate the Authentication
class and define some variables needed to build the UI.
Next, define two validators to verify if the email and password are entered in the correct format and that they are not empty:
12345678910111213_emailValidator(String? email) { if (email == null || email.isEmpty) { return 'Please enter a valid email'; } return null; } _passwordValidator(String? password) { if (password == null || password.isEmpty) { return 'Please enter a password of 6 characters or more'; } return null; }
Now, you can define the UI inside the build()
method:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Stream Login'), ), body: Padding( padding: const EdgeInsets.all(16.0), child: Form( key: _loginFormKey, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ TextFormField( controller: _emailController, validator: (value) => _emailValidator(value), decoration: InputDecoration(hintText: 'Email'), ), SizedBox(height: 16.0), TextFormField( controller: _passwordController, obscureText: true, validator: (value) => _passwordValidator(value), decoration: InputDecoration(hintText: 'Password'), ), SizedBox(height: 16.0), _isSigningIn ? CircularProgressIndicator() : ElevatedButton( onPressed: () async { if (_loginFormKey.currentState!.validate()) { setState(() { _isSigningIn = true; }); await _authentication.signInUsingEmailPassword( context: context, email: _emailController.text, password: _passwordController.text, ); setState(() { _isSigningIn = false; }); } }, child: const Text('Sign In'), ), SizedBox(height: 16.0), TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute( builder: (context) => RegisterPage(), ), ), child: Text('Don\\'t have an account? Sign Up'), ), ], ), ), ), ); }
Inside the onPressed(
) callback of the Sign In button, the Firebase sign-in process is triggered (defined in the Authentication
class).
A TextButton
widget is added so that new users can navigate to the RegisterPage
and create their accounts.
The
RegisterPage
code is quite similar to theLoginPage
. Check the Stream Auth Firebase GitHub repo to find the user interface code forRegisterPage
.
Implement Stream Messaging
You can easily integrate a full-fledged chat messaging service using Stream.
First, you will need to add the ChannelListPage
where users are navigated to after authenticated. This page will display a list of channels where the current user is a member.
Create a new file called channel_list_page.dart
and add the following code:
123456789101112131415161718192021222324class ChannelListPage extends StatelessWidget { final Channel channel; ChannelListPage(this.channel); Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Stream Chat'), ), body: ChannelsBloc( child: ChannelListView( filter: Filter.in_('members', [StreamChat.of(context).currentUser!.id]), sort: [SortOption('last_message_at')], channelWidget: Builder( builder: (context) => ChannelPage(channel), ), ), ), ); } }
On this page, you can also add an action
to the AppBar
for signing out:
First, create an instance of the Authentication
class:
1final _authentication = Authentication();
Add the Sign Out action like this:
1234567891011121314151617181920212223AppBar( title: Text('Stream Chat'), actions: [ PopupMenuButton<String>( onSelected: (_) async { await _authentication.signOut(); Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => LoginPage(), ), ); }, itemBuilder: (BuildContext context) { return [ PopupMenuItem<String>( value: 'sign_out', child: Text('Sign out'), ) ]; }, ), ], )
Next, you need to define a page to display messages and allow the user to send new messages.
Create a new file called channel_page.dart
and add the following code:
12345678910111213141516171819class ChannelPage extends StatelessWidget { final Channel channel; const ChannelPage(this.channel); Widget build(BuildContext context) { return Scaffold( appBar: ChannelHeader(), body: Column( children: <Widget>[ Expanded( child: MessageListView(), ), MessageInput(), ], ), ); } }
Congratulations 🎉 ! You successfully implemented serverless authentication to your Stream messaging app using Firebase.
Bonus: Auto Login
You will notice that the current version of the app requires the user to log in every time they launch the app after closing, even if they signed in previously.
To improve the user experience, you can automatically log in users if they were in a signed-in state in their previous session:
Add a new method to the Authentication
class:
123456789101112131415161718192021Future<bool> isSignedIn(BuildContext context) async { firebaseUser = auth.currentUser; bool isSignedIn = false; if (firebaseUser != null) { isSignedIn = true; try { final callable = functions.httpsCallable('getStreamUserToken'); final results = await callable(); String? token = results.data; if (token != null) { StreamClient.initialize(token, context); } } catch (e) { print('Error in fetching token: $e'); } } return isSignedIn; }
This method will return a boolean indicating whether the user was previously signed in.
Go to the LoginPage
class and add the following method to check for signed-in users:
1234567891011bool _isCheckingUser = true; bool _isSignedIn = false; checkIfUserSignedIn() async { bool signedInState = await _authentication.isSignedIn(context); setState(() { _isCheckingUser = false; _isSignedIn = signedInState; }); }
Call this method in the initState
of this class:
12345void initState() { super.initState(); checkIfUserSignedIn(); }
As you start the app, it loads up the LoginPage
class, where it checks if the user is already signed-in. If the user is signed-in previously, it automatically navigates to the ChannelListPage
.
More to Explore
In this article, you learned how to implement Firebase authentication using email/password and integrate it with your Stream Messaging app.
But, you can use the same principles to integrate other authentication providers as well, like Google, Apple, Facebook, and GitHub.
Learn more about Stream and Firebase from these links:
You can find this sample app repository on the author's GitHub.