By Tim Freebern, Mobile Engineer at Oddball
Experienced developers understand that securing a web or mobile application is more difficult than just requiring a user to enter their username and password. No matter how you decide to secure your application, there will always be trade-offs to consider. For our Flutter application we would be supporting Android, iOS and web. We ultimately decided on the OAuth 2.0 Authorization Code with PKCE flow due to the extra layer of security it provides over other OAuth flows.
The PKCE flow builds on top of the authorization code flow by using three additional parameters known as code verifier, code challenge, and code challenge method. The code verifier is a random string generated by the application. The code challenge is based off of the code verifier and is typically a base 64 encoding of an SHA256 hash made with the client verifier. The code challenge method tells the authorization server how the code verifier was transformed. During the final step of authentication the server will need to validate the code verifier against the code challenge using the same method that generated the code challenge. If successful the server will return an access token to the client application.
Implementing OAuth flow with the PKCE extension can be a rather difficult problem to solve for Flutter Web developers. When I was trying to implement this flow in our Flutter Web application, I was not able to find a clear solution for the problem. Adding to this difficulty, I found a handful of OAuth Dart packages available on pub.dev that all had different features and levels of support. In this post I’d like to lay out how I solved this issue in the hopes that I can help someone else avoid these challenges.
It’s very important that you understand the basics of the OAuth PKCE flow before attempting to implement it in your application. Here is a great place to start.
In our implementation we used Keycloak as our authorization server. Some of our server settings may differ from yours, but the general concepts should still apply. I’d also like to point out that our application was built using the Stacked plugin which uses a MVVM architecture and Get It for dependency injection. I’ll list out the plugins we are using at the end of this post and highlight anything specific in each example code piece.
The star of the show is the OAuth2 plugin. This plugin abstracts a lot of the implementation details and necessary steps in the OAuth PKCE flow. It creates the code challenge, authorization url, and even handles both the outgoing and incoming requests to and from the authorization server.
Here is a sequence diagram demonstrating the first few steps of this flow:
When our user hits the login button we will create and save our code verifier. You can choose a plugin like shared preferences to store this value to be used later. Then we will create the Authorization Code Grant by passing in the code verifier, client id, authorization endpoint, token endpoint, and an http client.
Once the Authorization Code Grant is created we will use it to generate the authorization url with all of the necessary query parameters the authorization server is looking for. We will need to pass in the redirect uri and user scopes to the getAuthorizationUrl method in order to generate the authorization url. This tells the authorization server where to redirect the user to when they have entered a successful username and password combination.
Finally we will then pass the authorization URL to the launch function which is available from the url_launcher plugin.
import 'package:oauth2/oauth2.dart' as oauth2; import 'package:http/http.dart' as http; static const String _charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; static String _createCodeVerifier() { return List.generate( 128, (i) => _charset[Random.secure().nextInt(_charset.length)]).join(); final codeVerifier = _createCodeVerifier(); saveCodeVerifier(codeVerifier); final grant = oauth2.AuthorizationCodeGrant( clientId, Uri.parse(authorizationEndpoint), Uri.parse(tokenEndpoint), httpClient: http.Client(), codeVerifier: codeVerifier); final authorizationUrl = grant.getAuthorizationUrl(Uri.parse('${Uri.base.origin}/#${redirectEndpoint}'), scopes: authStrings.scopeStrings); await _openAuthorizationServerLogin(authorizationUrl); }
We need to tell the browser to open this url in the same tab as the application by passing in the string ‘_self’ to the webOnlyWindowName parameter.
Future<void> _openAuthorizationServerLogin(Uri authUri) async { var authUriString = 'https://${authUri.toString()}'; if (await canLaunch(authUriString)) { await launch(authUriString, webOnlyWindowName: '_self'); } else { throw 'Could not launch $authUri'; } }
If you get a CORs error from your authorization server you will need to specify what origins you allow requests to come from. For instance, in the Keycloak admin panel there is a Web Origins field in the Client settings where you can pass a specific URL where you expect your authorization requests to come from. For testing purposes you can just put a wildcard value ( * ) to get the flow working.
If successful this should direct our user to the login page of our authorization server with the following query parameters:
response_type client_id redirect_uri code_challenge code_challenge_method scope
From here we allow the authorization server to handle the username and password validation from the login page. If the username and password are successfully validated we will need to handle the redirect coming from the authorization server. I’ve gone ahead and created a sequence diagram to better visualize how we handle the redirect.
The redirect uri we passed into the AuthorizationCodeGrant object should match our named route in our application. Below is a simple code sample of how you can set up named routing in Flutter.
void main() async { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter oAuth', initialRoute: '/', routes: { '/': (context) => LoginScreen(), '/login': (context) => LoginScreen(), '/auth-redirect': (context) => AuthRedirectView(), '/landing-page': (context) => LandingPage() }, home: LoginScreen(), ); } }
In Stacked every view is powered by a view model that handles your business logic and provides your view with data. When the user is redirected to our ‘/auth-redirect’ route the model.initialize() method is called. Normally when the initialize method is completed the view would render, but we are going to direct the user to our landing page in our application.
class AuthRedirectView extends StatelessWidget { @override Widget build(BuildContext context) { return ViewModelBuilder<AuthRedirectViewModel>.reactive( viewModelBuilder: () => AuthRedirectViewModel(), onModelReady: (model) async { model.initialize(); }, builder: (context, model, child) => Scaffold(body: Center(child: CircularProgressIndicator()))); } }
Here is a peek at the initialize method inside of the AuthRedirect View Model. Dart has a URI abstract class with a property named base. When you call Uri.base while in a browser it will get the current URL of the current page. We pass in the base property from the Uri class to our handleOAuthRedirectWeb method in the Authorization Service.
The locator object is an instantiation of the Get It class from the get_it plugin.
import 'package:flutter_app/locator.dart'; import 'package:flutter_app/services/auth_service.dart'; import 'package:flutter_app/services/navigation_service.dart'; class AuthRedirectViewModel extends BaseViewModel { final _authService = locator<AuthService>(); final _navigationService = locator<NavigationService>(); void initialize() async { await _authService.handleOAuthRedirectWeb(Uri.base); _navigationService.navigateTo('/landing-page'); } }
In our Authorization Service we will instantiate a new AuthorizationCodeGrant object. Once instantiated we can then pass the query parameters to the handleAuthorizationResponse method. This method will make a request to the authorization server’s token endpoint and compare the code verifier against the code challenge.
Future<void> handleOAuthRedirectWeb(Uri redirectUri) async { String codeVerifier = getCodeVerifier(); final grant = oauth2.AuthorizationCodeGrant( clientId, Uri.https(baseUrl, authorizationEndpointKey), Uri.https(baseUrl, tokenEndpointKey), httpClient: http.Client(), codeVerifier: codeVerifier); grant.getAuthorizationUrl(Uri.parse('${Uri.base.origin}/#${redirectEndpoint}')); try { final client = await grant.handleAuthorizationResponse(redirectUri.queryParameters); String jwt_bearer = client.credentials.accessToken; String refresh_token = client.credentials.refreshToken; storeTokens(jwt_bearer, refresh_token); } catch (e) { throw Exception(e); } }
If the authorization server determines that you sent the correct code verifier you should receive both an access and refresh token. You will need to save these tokens in order to access your protected resources. For Flutter web you can use the shared preferences plugin to save your tokens but be aware that this will store them in local storage of the user’s browser leaving your application open to XSS attacks.
Congratulations – You should now be able to login and be authorized to access protected endpoints in your api!
Tim is a father of two, former retail manager turned software developer, gamer and lover of all things tech.
Resources: https://oauth.net/2/pkce/ https://pub.dev/packages/stacked https://pub.dev/packages/get_it https://pub.dev/packages/oauth2 https://pub.dev/packages/url_launcher https://pub.dev/packages/shared_preferences https://pub.dev/packages/http