developers

Flutter Authentication and Authorization with Auth0, Part 3: Adding Real-Time Chat to the App

In this tutorial, you’ll learn how to enhance your Flutter apps by enabling authentication, supporting federated identity providers, adding authorization by introducing roles and permissions, all leveraging Auth0.

Oct 4, 202119 min read

In the previous sections of this tutorial, you added authentication to MJ Coffee, a Flutter app, and then enhanced that authentication by adding refresh token rotation and social logins. You also looked at customizing the login page with your own branding, and user management in Auth0.

In this section, we’ll focus on authorization. Remember:

  • Authentication concerns itself with answering the question “Who are you?”
  • Authorization — this section’s topic — is all about answering the question “What are you allowed to do?”

We’ll cover authorization by adding real-time chat to MJ Coffee so that we can later see how the chat can be integrated with Auth0, limit its functionalities and UIs based on roles and permissions.

Look for the 🛠 emoji if you’d like to skim through the content while focusing on the build and execution steps.

Add Real-Time Chat to the Flutter Application

Having a real-time chat service in the MJ Coffee app can significantly improve how fast users can communicate with our customer service or employees. Having looked around, I found Stream, an exciting service with a solid Flutter SDK that you can integrate and customize quickly and easily.

Install Dependencies

Stream comes with several SDKs that can be used in Dart or Flutter applications. However, one of them comes with all features, a beautiful theme, and enough API so that you can customize it as much as you wish. We’ll use that one.

🛠 Open the

/pubspec.yaml
file and add
stream_chat_flutter
to your dependencies. The
dependencies
section of the file should end up looking like this:

// /pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  font_awesome_flutter: ^9.1.0
  flutter_svg: ^0.22.0
  google_fonts: ^2.1.0
  json_annotation: ^4.0.1
  http: ^0.13.3
  flutter_appauth: ^1.1.0
  flutter_secure_storage: ^4.2.0
  stream_chat_flutter: ^2.0.0

I recommend using a version later than 2.0, which is fully compatible with Flutter 2 and its support for null safety.

If you’re only targeting Android, you can ignore the next section.

If you’re targeting iOS

🛠 If you’re targeting iOS, you’ll need to take a few extra steps:

  • The Stream library uses the flutter file picker plugin to present the user with an interface to select files. You’ll need to follow the steps in the iOS section of this page to make use of it.
  • The library also uses the video_player plugin to display inline videos. Follow this guide to install and enable it.
  • Finally, the library uses the image_picker plugin to provide a user interface for selecting images. Follow [these instructions] to use this plugin.

🛠 Don’t forget to actually install the dependencies! Enter

flutter pub get
in the terminal or PowerShell, or
Pub get
in your IDE.

Create a Stream Account and App

Before you can integrate Stream with your app, you need a Stream account, which you’ll use to register the app.

🛠 Create your account if you don’t already have one, then log in and go to the Stream dashboard:

Stream dashboard.

🛠 Tap on the Create App button, then follow these steps:

  • Enter a name for your app in the App Name field. For this example, we’ll use
    MJCoffee
    .
  • In the Feeds Server Location menu, select the location closest to you.
  • Don’t select anything from the Clone Existing App menu — just leave it set to
    ---
    .
  • Select your environment. Usually, it's a good idea to create separate apps for production and development. In this tutorial, we’ll focus on production, so select Production.
  • Finally, click the Create App button.

You should see this:

A newly-created app in Stream.

🛠 Once your app has been created, make a note of your Stream key.

Notice that you will need a secret key to sign the user ID and get a production user token to connect users. You’ll learn how to do that with Auth0 Actions.

I recommend passing sensitive data and keys with

--dart-define
via the command line or adding to your editor or IDE run command rather than storing it in your app’s code.

You took this approach when implementing basic authentication for the app. You passed two sensitive values — your Auth0 domain and Auth0 client ID — as command-line arguments when launching the app. You’ll simply add the Stream key as a third argument, like so:

flutter run -d all --dart-define=AUTH0_DOMAIN=[YOUR DOMAIN] --dart-define=AUTH0_CLIENT_ID=[YOUR CLIENT ID] --dart-define=STREAM_API_KEY=[YOUR STREAM KEY]

In order for your application to use the Stream key argument, you’ll need to define a new constant.

🛠 Add the following to your

constants.dart
file, which is located in the in the
/lib/helpers/
directory:

// /lib/helpers/constants.dart

const STREAM_API_KEY = String.fromEnvironment('STREAM_API_KEY');

Integrate Chat Into the Flutter Application

Let's start by creating a new

StreamChatClient
in the
ChatService
singleton class. In doing this, there are three essential things that you need to take into account:

  1. Initializing the Dart API client with your API Key
  2. Setting the current user
  3. Passing the client to the top-level StreamChat widget

🛠 Open

chat_service.dart
(located in the
/lib/services/
directory) and initialize
StreamChatClient
by updating the
ChatService
class so that it appears as follows:

// /lib/services/chat_service.dart

class ChatService {
  static final ChatService instance = ChatService._internal();
  factory ChatService() => instance;
  ChatService._internal();

  final StreamChatClient client = StreamChatClient(
    STREAM_API_KEY,
    logLevel: isInDebugMode ? Level.INFO : Level.OFF,
  );

}

The only required positional parameter for

StreamChatClient()
is
STREAM_API_KEY
, but you have more options to configure your client. For instance, it might be pretty helpful to see all the logs during debugging. That's why we used the optional
logLevel
parameter to set the amount of logging that will take place, based on whether or not the app is in debug mode.

Now that you‘ve created the client, you need to ensure that your current user is connected appropriately.

🛠 Add the

connectUser()
to the
ChatService
class in
chat_service.dart
:

// /lib/services/chat_service.dart

class ChatService {

...

  Future<Auth0User> connectUser(Auth0User? user) async {
    if (user == null) {
      throw Exception('User was not received');
    }
    await client.connectUser(
      User(
        id: user.id,
        extraData: {
          'image': user.picture,
          'name': user.name,
        },
      ),
      // To be replaced with PRODUCTION TOKEN for user
      client.devToken(user.id).rawValue,
    );
    return user;
  }
  
...
  
}

This new method of the

ChatService
class,
connectUser()
, will handle the logic for connecting the current user. It accepts an
Auth0User
object. If it doesn‘t receive one, it means the authentication has probably failed and that the user should not be connected to the chat.

🛠 Open

/lib/screens/home.dart
, look for the
_HomeScreenState
class, and within it, the
setSuccessAuthState()
method. Update
setSuccessAuthState()
to the following:

// /lib/screens/home.dart

setSuccessAuthState() {
  setState(() {
    isProgressing = false;
    isLoggedIn = true;
    name = AuthService.instance.idToken?.name;
  });

  ChatService.instance.connectUser(AuthService.instance.profile);
  CoffeeRouter.instance.push(MenuScreen.route());
}

You need to pass two required positional parameters:

  • The
    User
    object with a defined user ID, and
  • the user token, a signed and encrypted hash string.

Typically, the user token should be generated on a back-end server to store the aforementioned secret key and to sign and get the token. You'll soon learn how Auth0 can handle that as your backend.

To continue, you need to:

  • Get a development token,
  • pass
    user.id
    to the
    devToken()
    method on the chat client, and
  • Get the
    rawValue
    which is the token string.

🛠 To ensure that

devToken
works, you need to disable auth checks. In the
GetStream
app’s chat dashboard, turn on the Disable Auth0 Checks option.

Stream dashboard.

When you move the app to production and you have a proper method to receive the production token, make sure that you turn off the Disable Auth0 Checks option.

Stream chat UI components will accept additional data that you provide while connecting a user. For example, you may create a

Map
containing the user‘s image and name, so they appear automatically and beautifully throughout the chat widget.

At the time of writing, Stream does not accept Auth0’s user ID format, which includes

|
between the Auth source and user hash ID. You’ll need to make a change to the
id
arrow function in the
Auth0User
model to account for this.

🛠 Open

/lib/models/auth0_user.dart
and make this change to the
id
arrow function:

// /lib/models/auth0_user.dart

String get id => sub.split('|').join('');

The next and final step is to create

StreamChat
, the root widget of the application.

🛠 Open the

/lib/main.dart
file, locate the call to
MaterialApp
, and add the
builder
parameter so that it returns a
StreamChat
object:

// /lib/main.dart

...
MaterialApp(
  debugShowCheckedModeBanner: false,
  themeMode: ThemeMode.system,
  home: HomeScreen(),
  navigatorKey: CoffeeRouter.instance.navigatorKey,
  theme: getTheme(),
  builder: (context, child) {
    return StreamChat(
      child: child,
      client: ChatService.instance.client,
    );
  },
),
...

The

StreamChat
object returned by
builder
is an inherited widget and aims to provide APIs for advanced customization. It requires a child and a client that you have initialized in the
ChatService
class.

That's it — your chat is ready! Restart the app, and let's continue to add the support screen and community screen using the prebuilt

StreamChat
UI widget to leverage the chat service that you added.

Implement the Support Chat Screen

Typically, a support chat consists of a user and an agent interacting in a channel created for both. Hence the support chat screen will do the following:

  • Create a private channel for the current user and one of the available agents
  • Listen to the channel for updates
  • Load existing chat content, if any
  • Upon chat completion, archive the chat history

Let's create a

createSupportChat()
method in the
ChatService
class.

🛠 First, add the following instance variable to the start of the

ChatService
class:

// /lib/services/chat_service.dart

String? _currentChannelId;

🛠 Then add the

createSupportChat()
method to the
ChatService
class:

// /lib/services/chat_service.dart

Future<Channel> createSupportChat() async {
  // To be replaced with EmployeeRole via Auth0
  final String employeeId = 'rootEmployeeId';
  final channel = client.channel(
    'support',
    id: _currentChannelId,
    extraData: {
      'name': 'MJCoffee Support',
      'members': [
        employeeId,
        client.state.user!.id,
      ]
    },
  );
  await channel.watch();
  _currentChannelId = channel.id;
  return channel;
}

There’s a lot going on here. Let's review it step by step.

First, to create a support chat channel for the current user, you need to know the ID of an available agent. In this tutorial, you will eventually learn how to create an API to get an available agent via Auth0 based on their roles. However, we’ll skip this part for now.

Second, you need to create a channel with a specific type. In this case, the type will be

support
. Stream provides default types for channels; however, you can define your types as needed. To create a channel type, navigate to the Stream dashboard, go to your app in the Chat overview tab, and add your channel types to the list.

Stream channel types.

New stream channel type.

Next, you can pass an existing channel ID to reconnect to a channel or leave it

null
to create a new channel. Stream will automatically assign an ID to new channels. Since this support chat is private and exclusive to one agent and a current user, you may add IDs to
members
in the
extraData
map.

Then, call the

watch()
method on the channel to create and listen to the events on that channel. The
watch()
method is a
Future
and will perform its tasks asynchronously. You can assign the channel ID to the
_currentChannelId
private instance variable so that you can reconnect to the channel when needed.

Later on, it might be a good idea to store the channel ID in an external database or local secure storage in the app so that it persists between user sessions.

Finally, the method returns the newly created channel.

With the

createSupportChat()
method implemented, you can now implement the chat UI.

🛠 Open the

/lib/screens/support.dart
file, where you’ll find the
SupportChatScreen
class. Update the file’s contents to the following:

// /lib/screens/support.dart

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';

class SupportChatScreen extends StatefulWidget {
  @override
  _SupportChatScreenState createState() => _SupportChatScreenState();
}

class _SupportChatScreenState extends State<SupportChatScreen> {
  Auth0User? profile = AuthService.instance.profile;
  Channel? channel;

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

  createChannel() async {
    final _channel = await ChatService.instance.createSupportChat();
    setState(() {
      channel = _channel;
    });
  }

  @override
  Widget build(BuildContext context) {
    return channel == null
        ? Center(
            child: Text('You are in the queue!, please wait...'),
          )
        : Scaffold(
            body: SafeArea(
              child: StreamChannel(
                channel: channel!,
                child: Column(
                  children: <Widget>[
                    Expanded(
                      child: MessageListView(),
                    ),
                    MessageInput(
                      disableAttachments: true,
                      sendButtonLocation: SendButtonLocation.inside,
                      actionsLocation: ActionsLocation.leftInside,
                      showCommandsButton: true,
                    ),
                  ],
                ),
              ),
            ),
          );
  }
}

Here’s what’s happening in this UI implementation:

  • This support chat screen,
    SupportChatScreen
    , is a
    StatefulWidget
    .
  • The
    profile
    variable is the user profile from
    AuthService
    that you created in the previous section.
  • The
    channel
    variable is used to detect if a support channel has been created.
  • The
    createChannel()
    method calls
    ChatService
    ’s
    createSupportChat()
    method, which you recently defined.
  • Once the channel is ready,
    setState()
    is called, which renders the chat UI.
  • In the
    build()
    method, you can show a message while the channel is being created or return a
    StreamChannel
    object, which provides information about the channel to the widget tree and passes a reference to the
    channel
    .
  • Typically, a child of
    SupportChatScreen
    should be a
    Column
    that includes
    MessageListView()
    and is wrapped by
    Expanded
    to ensure it takes up all the available space.
  • You can highly customize
    MessageInput()
    in a number of ways, such as disabling the file attachment or command buttons. You’ll do this later by using Auth0’s permissions and roles to determine which buttons should be enabled or disabled, so stay tuned.

Congratulations — you’ve implemented the support chat screen! Let's move on to the community view.

Implement the Community Screen

It's time to make a screen to allow agents to see all the messages they receive via support. Let's call this screen

CommunityScreen
.

🛠 Open the

/lib/screens/community.dart
file, and update the
CommunityScreen
class within to the following:

// /lib/screens/community.dart

class CommunityScreen extends StatelessWidget {
  final userId = ChatService.instance.client.state.user?.id as Object;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ChannelsBloc(
        child: ChannelListView(
          filter: Filter.in_(
            'members',
            [userId],// current user is employee (role/permission)
          ),
          sort: [SortOption('last_message_at')],
          pagination: PaginationParams(
            limit: 30,
          ),
          channelWidget: ChannelPage(),
        ),
      ),
    );
  }
}

class ChannelPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: ChannelHeader(),
      body: Column(
        children: <Widget>[
          Expanded(
            child: MessageListView(),
          ),
          MessageInput(),
        ],
      ),
    );
  }
}

The logic behind this page is relatively simple. You want to load all channels seen by the current user, an agent in this context, then open each channel to answer its respective customer.

ChannelsBloc
manages a list of channels with pagination, re-ordering, querying, and other operations associated with channels together with
ChannelListView()
. The
Bloc
is not related to the
Bloc
package or pattern; this is just a coincidence.

You need to filter all channels by the membership of the current user. If necessary, do custom sorting and pagination too.

Lastly, you need to ensure that the

ChannelPage()
is also passed to correctly as it makes channels routable. The
ChannelPage()
is nothing fancy — just a straightforward implementation of what you have done already in
SupportScreen
.

Perfect! You have created all screens for the app. However, we still need to implement permissions and roles to load each screen and functionalities based on what we can define and receive via Auth0 tokens. But if you are curious, you can simply add another tab to the

MenuScreen
and load these pages (although you might not see any channels yet).

🛠 In the file

/lib/screens/menu.dart
, locate
final List<Widget> tabs
in
MenuScreenState
and update it to the following:

// /lib/screens/menu.dart

...
  final List<Widget> tabs = [
    MenuList(coffees: coffees),
    SupportChatScreen(),
    ProfileScreen(),
  ];
...

🛠 In the same file, add a new icon to

bottomNavigationBar
by replacing the
items
parameter with the following:

// /lib/screens/menu.dart

items: <BottomNavigationBarItem>[
  BottomNavigationBarItem(
    icon: Icon(Icons.list_alt),
    label: "Menu",
  ),
  BottomNavigationBarItem(
    icon: Icon(Icons.support),
    label: "Support",
  ),
  BottomNavigationBarItem(
    icon: Icon(Icons.person),
    label: "Profile",
  ),
],

🛠 Before you can test the chat, you should temporarily add the

rootEmployeeId
user ID that you defined in
createSupportChat
in the Stream dashboard.

Custom user in the chat.

You can skip this step, but it’s still a good idea to have a root support user in case of a specific agent is unavailable. That way, the user with the absent agent can still get support.

Custom user in the chat.

🛠 Run your app in the emulator, go to the support chat screen, and try it out!

If you encounter an error regarding an outdated Kotlin version on the Android emulator, open

android/app/build.gradle
, and search for
ext.kotlin_version
. Change that line to
ext.kotlin_version = '1.5.10'
.

Auth0 Actions and Generating Stream User Production Tokens

Until now, for each user, you have been using a token generated for the development environment. In order to release your app, the app will have to request production tokens.

As I mentioned earlier, retrieving production tokens is usually done by your backend server. Auth0 can act as this service and retrieve those tokens for you, thanks to a potent tool called Auth0 Actions.

You can use Auth0 Actions to handle your application logic and fit it into a particular flow. They are secure, tenant-specific, versioned functions written in Node.js that execute at specific points when Auth0 is doing its work. This allows you to customize and extend Auth0's capabilities with custom logic. Think of Actions as serverless functions, such as AWS Lambda or Google Cloud Functions.

You can determine when and where to execute an Action in the Auth0 runtime environment. Your flows might be:

  • Login: Executed after a user logs in and when refresh tokens are issued.
  • Machine to Machine: Executed after a user logs in after the Client Credentials hook.
  • Pre-User Registration: Executed before a user is added to the database or passwordless connections.
  • Post-User Registration: Executed after a user is added to a database or passwordless connections. The execution is asynchronous and will not affect the transaction.
  • Post-Change Password: Executed after a password is changed for a database connection user.
  • Send Phone Message: Executed when using a custom MFA provider.

Actions flow

You’ve probably already thought of several use cases for each of these scenarios.

Let's create a custom action to exchange Stream production user tokens for users after they logged in and hooked it up to the ID token and user metadata received by the Flutter app.

First, you need to create a new action. Go to the Custom Actions menu under Actions in Auth0 Dashboard and hit the "Create" button.

Create action

🛠 You should provide a name for the action, select a trigger from the list, and then create it. For the purpose of generating a chat token and attaching it to

idToken
, select the Login / Post Login trigger, which allows you to use the action in the login flow.

Create action trigger

You'll see an editor where you can write your logic. You can run the code, try it out before deploying it, add your secrets to the environment instead of putting it in the application code, and even add almost all public NPM packages!

 action module

🛠 Start by adding the

getstream
npm package. At the time of writing, the current version of the package is
7.2.10
.

🛠 Go back to the Stream dashboard, copy your secret and client keys...

chat app secret

🛠 ...and then add them with the name of

GET_STREAM_CHAT_SECRET_KEY
and
GET_STREAM_CHAT_CLIENT_KEY
, respectively, to this function in Auth0.

 action secrets

🛠 Lastly, you can import

getStream
and connect and create a user token bypassing
user_id
.

// Javascript

const stream = require('getstream');

exports.onExecutePostLogin = async (event, api) => {
  const getStreamClient = stream.connect(
    event.secrets.GET_STREAM_CHAT_CLIENT_KEY,
    event.secrets.GET_STREAM_CHAT_SECRET_KEY
  );
  const getStreamToken = getStreamClient.createUserToken(
    `${event.user.user_id.split('|').join('')}` // getstream does not support `|` in the ID yet, so we have to omit it.
  );

  // api.user.setAppMetadata("stream_chat_token", getStreamToken);
  // api.user.setUserMetadata("stream_chat_token", getStreamToken);
  const namespace = 'https://getstream.mjcoffee.app';
  if (event.authorization) {
    api.idToken.setCustomClaim(`${namespace}/user_token`, getStreamToken);
    api.accessToken.setCustomClaim(`${namespace}/user_token`, getStreamToken);
  }
};

Let's explore the implementation above.

Once you get the token, you can set a user or app metadata named

stream_chat_token
with the token value.

Alternately, you can set a custom claim on

idToken
and
accessToken
. A custom claim must take the form of a URI, which means that
https://getstream.mjcoffee.app/user_token
is an acceptable name for a claim, while
user_token
alone is not.

Important: In Stream, only the characters

a
through
z
,
0
through
9
,
@
,
_
, and
-
are allowed in the user ID. Therefore, we shouldn’t use the
|
for
user_id
in Auth0.

Let's run and test this function before we deploy. Click on the play icon and run with the example event, which is automatically generated for you.

 action run

If everything goes well, you’ll get a generated token in JWT format. Its payload will contain

user_id
, and it will be signed with your secret key by the Stream servers.

Here’s an example of what you’ll get:

// response in Action run

[
  {
    name: 'https://getstream.mjcoffee.app/user_token',
    target: 'idToken',
    type: 'SetCustomClaim',
    value:
      'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYXV0aDA1ZjdjOGVjN2MzM2M2YzAwNGJiYWZlODIifQ.7ZIyr27skgrGm6REEz5o-WvoCArNblDnwiOdxXW4dp8',
  },
  {
    name: 'https://getstream.mjcoffee.app/user_token',
    target: 'accessToken',
    type: 'SetCustomClaim',
    value:
      'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYXV0aDA1ZjdjOGVjN2MzM2M2YzAwNGJiYWZlODIifQ.7ZIyr27skgrGm6REEz5o-WvoCArNblDnwiOdxXW4dp8',
  },
];

action run

🛠 Next, go back to Flow, select Login, and drag your newly-created custom action to the flow and apply the changes:

 action run

For the last step, the Flutter app needs to read this token and add it to the user model. That way, when you connect a user to Stream’s chat, instead of the development tokens you’ve been using, you can replace it with the token that Auth0 received.

🛠 Since it's a custom claim, it will show up in

idToken
; thus you’ll need to modify your
Auth0IdToken
model class (located in
/lib/models/auth0_id_token.dart
)...

// /lib/models/auth0_id_token.dart

@JsonSerializable()
class Auth0IdToken {
  Auth0IdToken({
  
  ....
  
    required this.streamChatUserToken,
  
  ....
  
})

....

  @JsonKey(name: 'https://getstream.mjcoffee.app/user_token')
  final String streamChatUserToken;

....

}

🛠 Then do the same with your

Auth0User
class (located in
/lib/models/auth0_user.dart
), as it will be part of the user details:

// m/lib/models/auth0_user.dart

@JsonSerializable()
class Auth0User {
  Auth0User({
  ....
      required this.streamChatUserToken,
  ....
})
....
  @JsonKey(name: 'https://getstream.mjcoffee.app/user_token')
  final String streamChatUserToken;
....
}

🛠 Once you are done, run the

build_runner
command to generate models again.

flutter pub run build_runner build --delete-conflicting-outputs

🛠 Locate

connectUser
in the
ChatService
class (located in
/lib/services/chat_service.dart
) and replace
devToken()
with the newly-received token on the user object.

// /lib/services/chat_service.dart

await client.connectUser(
  User(
    id: user.id,
    extraData: {
      'image': user.picture,
      'name': user.name,
    },
  ),
  // client.devToken(user.id).rawValue,
    user.streamChatUserToken,
);

Well done! Log out of the app, restart it. and log in again. This time, you'll receive a production-ready user token. When you go to the support chat screen, everything should work as expected.

Looking Ahead

Now that the app has a real-time chat is ready with a proper authentication flow, you can move on to the next section to focus on authorization, roles, and permissions.