Over a year ago, I published an article on how to set up an authentication mechanism for Flutter apps that makes use of the power of Microsoft’s Azure AD (you can find the blog post here
). Now, after some time has gone by, I want to revisit what me and my team did at that time. Specifically, I want to get into detail about how a mobile-only authentication solution can be transferred to the web.
Rethinking our authentication model
When going multi-platform (for us, this meant: mobile and web), we need to consider a way to get the authentication under one hood, nonetheless respecting the platform-specific authentication mechanisms. When authenticating, the users should experience no flow gaps or design breaks between several platforms.
How far the problem of a generic, multi-platform authentication mechanism reaches, becomes way more easier to catch if we consider that:
- On mobile phones, multi-factor authentication for AzureAD account usually happens through the Microsoft Authenticator app, which redirects the user back to the caller when the second factor has been verified.
- On the web, there might also be a multi-factor enforcement, but (and that is a big difference) there is a bigger context switch: the mobile app as a second factor check usually happens to be a different device than the computer the browser runs on (for the one and only exception if the web app is run in a mobile browser).
Let’s solve the multi-platform issue from a developer’s point of view.
When browsing Dart packages
, there is no package that integrates mobile and web platforms for AzureAD authentication with flutter. There are, however, several packages for specific platforms:
- msal_js
for the web, which basically is a dart2js wrapper around the native msal.js package.
- azure_ad_authentication
for mobile (we switched from Moodio’s package to this one due to faster null safety migration and created an internal fork out of it).
Defining stubs
Now that we have our packages defined, let’s go for a generic approach. First of all, we define an AuthenticationResult
that contains some basic information about the authenticated user:
class AuthenticationResult {
final String? accessToken;
final String? email;
final String? displayName;
final String? expiresOn;
AuthenticationResult(this.accessToken, this.email, this.displayName, this.expiresOn);
}
Most importantly, the accessToken
defines the token to get access to any API.
Then, we basically follow the idea of this stackoverflow post: How to import platform specific dependency in Flutter/Dart?
After having defined our authentication model, we also define an abstract AuthenticationService
:
abstract class AuthenticationService {
Future<AuthenticationResult?> acquireToken();
Future<void> signOut();
factory AuthenticationService() {
return getAuthenticationService();
}
Future<void> initialize();
}
There are two methods (one for signing in, one for signing out) defined that need to be implemented by any platform-specific implementation of the abstract class. The initialization method is needed only for mobile and can be ignored on the web (precisely, on Android, the Azure AD configuration is read through a configuration file, which — thus not blocking the UI — should be handled within a future). The secret sauce is the factory
constructor that allows us to implement platform-specifics.
Mobile and web authentication service
We now define a stub that needs to be implemented by both platforms:
AuthenticationService getAuthenticationService() => throw UnsupportedError('Cannot create a AuthenticationService without the packages dart:html or package:shared_preferences');
Then, for each platform, we implement the stub.
Mobile:
class MobileAuthenticationService implements AuthenticationService {
AzureAdAuthentication? _auth;
Future<AuthenticationResult?> acquireToken() async {
return (await authenticate()) ?? null;
}
Future<AuthenticationResult?> authenticate() async {
await initialize();
UserAdModel? userAdModel;
try {
userAdModel = await _auth?.acquireTokenSilent(scopes: [authScope]);
} on MsalException {
print('Error getting token silently. Unspecified reason');
try {
userAdModel = await _auth?.acquireToken(scopes: [authScope]);
} on MsalException {
print('Error getting token silently. Unspecified reason');
}
}
return AuthenticationResult(
userAdModel?.accessToken, userAdModel?.mail, userAdModel?.displayName, userAdModel?.expiresOn);
}
Future<void> initialize() async {
if (_auth != null) {
return;
}
// ...
_auth = await AzureAdAuthentication.createPublicClientApplication(
clientId: clientId, authority: authority, configPath: tempConfig.path);
}
Future<void> signOut() async {
await initialize();
try {
await _auth?.logout();
} on MsalException {
print('Error signing out');
} on PlatformException catch (e) {
print('some other exception ${e.toString()}');
}
}
}
AuthenticationService getAuthenticationService() => MobileAuthenticationService();
Web:
class WebAuthenticationService implements AuthenticationService {
PublicClientApplication? publicClientApp;
@override
Future<AuthenticationResult?> acquireToken() async {
AuthenticationResult? result;
AccountInfo;
await initialize();
try {
result = await publicClientApp!.acquireTokenSilent(SilentRequest()..scopes = [authScope]);
return AuthenticationResult(
result.accessToken, result.account?.username, result.account!.name, result.expiresOn.toString());
} on BrowserAuthException {
print('Error getting token silently. Unspecified reason');
try {
final List<AccountInfo> accounts = publicClientApp!.getAllAccounts();
if (accounts.isNotEmpty) {
publicClientApp!.setActiveAccount(accounts.first);
}
result = await publicClientApp!.acquireTokenPopup(PopupRequest()..scopes = [authScope]);
return AuthenticationResult(
result.accessToken, result.account?.username, result.account!.name, result.expiresOn.toString());
} on AuthException {
print('Error getting token silently. Unspecified reason');
}
}
return null;
}
void _loggerCallback(LogLevel level, String message, bool containsPii) {
if (containsPii) {
return;
}
print('MSAL: [$level] $message');
}
@override
Future<void> signOut() async {
await initialize();
try {
publicClientApp!.logoutRedirect();
} on AuthException catch (ex) {
print('Logout failed.');
throw ex;
}
}
@override
Future<void> initialize() async {
if (publicClientApp != null) {
return;
}
publicClientApp = PublicClientApplication(
Configuration()
..auth = (BrowserAuthOptions()
..clientId = clientId
..authority = authority)
..system = (BrowserSystemOptions()
..loggerOptions = (LoggerOptions()
..loggerCallback = _loggerCallback
..logLevel = LogLevel.error)),
);
}
}
AuthenticationService getAuthenticationService() => WebAuthenticationService();
Now, when using the authentication logic, we only call the stub and the methods on the abstract class definition. This can, for instance, be done by using Riverpod’s provider mechanism:
import 'authentication_service_stub.dart'
if (dart.library.html) 'package:okhome_flutter/auth/web_authentication_service.dart'
if (dart.library.io) 'package:okhome_flutter/auth/mobile_authentication_service.dart';
final _authenticationServiceProvider = Provider((ref) => AuthenticationService());
Mind the conditional import
statement that is needed for Dart to distinguish between the platform implementations. Now magically, depending on the platform the app runs on, the correct authentication logic is being called and used.