The reasons for implementing authorization cookies can be different, ranging from cross-platform in conjunction with a web-app, to a single Server API between different services and platforms.
In any case, when you're faced with a problem like this, it needs to be solved. Googling this question on the Internet, we found very little information on this topic, which would explain it clearly and in detail. Moreover, we could not call the proposed solutions safe enough. That's why we decided to share our vision of how to solve this problem in this article.
But first, let's remember flutter definition as well as how cookie authorization works in general.
What is a Flutter?
Flutter is an open-source framework developed by Google. It has many tools that allow you to develop cross-platform applications. With Flutter, you can create applications for Android, iOS, Windows, macOS, Linux, as well as web applications.
To more accurately visualize the usefulness of flutter, let's simulate a situation where you need to develop an app for two popular smartphone OSes, and also make sure that the app supports a web version. If you write the app for each OS separately, you need a large team of developers who know Swift, Kotlin, JavaScript, C# programming languages. Using Flutter, you only need to hire Flutter developers who will write cross-platform applications.
Flutter application development makes it easier to develop and deploy the application, as well as to further scale it up. In addition, Flutter development also allows you to focus the developer's efforts and all financial resources on the functionality of the app.
How does cookie authorization work?
When we ask for authorization from the API with an HTTP request, in the response to a successful authorization in the response header we have arguments such as “set-cookie”, in it usually Backend writes authorization Token, and other necessary user identification data. Then, for subsequent HTTP requests, it expects to receive this data back in the header argument as well, but with the “cookie” key. Thus, Backend understands, when asked, “who” addressed it and whether its session is valid.
This mechanism of communication between the server and the client was originally designed for web applications. That is where a web developer needs to do almost nothing to make everything work. Because modern browsers long ago learned to fully manage the processes associated with cookies, from recording and sending, to ensuring the security of storage.
But we're not web developers, and we don't have a browser that does everything for us, so we have to implement almost all the cookie management processes ourselves.
Implementing process
First, we need to globally monitor the responses from the API for the “set-cookie” argument in the header.
We can concentrate solely on the authorization endpoint, but that's not quite right and prevents us from scaling in case “set-cookie” is used for more than just authorization.
Interceptor
For this, HTTP Interceptors are ideal for us. In our opinion, the best way to do this is with the Dio HTTP client (the Flutter package).
Interceptor — in Dio package performs a role better known in other technologies as Middleware pattern. This is a class which provides an interface to perform some actions in the life cycle of an action. In our case, HTTP request.
We do not need to implement from scratch Interceptor working with cookies because there is a package with already written Interceptor for HTTP client Dio: dio_cookie_manager. Even though it's quite simple to implement, we don't see the point in “reinventing the wheel”. We recommend using this package to avoid unnecessary boilerplate, but we need to take a closer look at how it works.
As an interface and storage manager, this package uses cookie_jar, which essentially implements some rather complicated and abstract logic for interacting a cookie with some kind of storage. Regardless of whether you decide to implement Interceptor yourself or use the dio_cookie_manager package, we need this package. It will make your task a lot easier.
class CookieManager extends Interceptor {
final CookieJar cookieJar;
CookieManager(this.cookieJar);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
cookieJar.loadForRequest(options.uri).then((cookies) {
var cookie = getCookies(cookies);
if (cookie.isNotEmpty) {
options.headers[HttpHeaders.cookieHeader] = cookie;
}
handler.next(options);
}).catchError((e, stackTrace) {
var err = DioError(requestOptions: options, error: e);
err.stackTrace = stackTrace;
handler.reject(err, true);
});
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_saveCookies(response)
.then((_) => handler.next(response))
.catchError((e, stackTrace) {
var err = DioError(requestOptions: response.requestOptions, error: e);
err.stackTrace = stackTrace;
handler.reject(err, true);
});
}
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
if (err.response != null) {
_saveCookies(err.response!)
.then((_) => handler.next(err))
.catchError((e, stackTrace) {
var _err = DioError(
requestOptions: err.response!.requestOptions,
error: e,
);
_err.stackTrace = stackTrace;
handler.next(_err);
});
} else {
handler.next(err);
}
}
Future<void> _saveCookies(Response response) async {
var cookies = response.headers[HttpHeaders.setCookieHeader];
if (cookies != null) {
await cookieJar.saveFromResponse(
response.requestOptions.uri,
cookies.map((str) => Cookie.fromSetCookieValue(str)).toList(),
);
}
}
static String getCookies(List<Cookie> cookies) {
return cookies.map((cookie) => '${cookie.name}=${cookie.value}').join('; ');
}
}
Storage
Next, we need a reliable repository for the cookie that interacts with the cookie_jar package. By default, this package uses RAM as storage, but that doesn't work for us. This package also has a class called PersistCookieJar that can talk to static storage and serialize data from them after the application restarts. It, by default, writes data to a file in the application's directory and uses it as a repository. This is already better, but in our opinion, not reliable enough.
A rather reliable “candidate” for the role of storage is specialized secure storage of the platforms themselves. For IOS, it is Keychain, and for Android — KeyStore. For some reason, most Flutter developers forget about this storage when they need to save very important information, such as authentication Token. This is in vain because in this way the platform guarantees us the security of data storage.
You don't need to write native plugins for each platform to interact with these stores, because there is already a Flutter package. It helps to interact with these flutter_secure_storage stores.
To make cookie_jar interact with our storage, we have to write an “adapter” — a class that implements the interface of the storage's interaction with PersistCookieJar:
import 'package:cookie_jar/cookie_jar.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class CookieSecureStorage implements Storage {
FlutterSecureStorage _storage;
@override
Future<void> init(bool persistSession, bool ignoreExpires) async {
_storage = FlutterSecureStorage();
}
@override
Future<void> delete(String key) {
if (key == null) return Future.value(null);
return _storage.delete(key: key);
}
@override
Future<void> deleteAll([List<String> keys]) {
if (keys != null && keys.isNotEmpty) {
return Future.forEach<String>(keys, (key) => _storage.delete(key: key));
}
return _storage.deleteAll();
}
@override
Future<String> read(String key) {
if (key == null) return Future.value(null);
return _storage.read(key: key);
}
@override
Future<void> write(String key, String value) {
if (key == null) return Future.value(null);
return _storage.write(key: key, value: value);
}
}
Connecting
Most of the work is done! It remains to connect our repository to the cookie manager and pass it to Interceptor; connect Interceptor itself to the HTTP client:
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import './cookie_secure_storage.dart';
final httpClient = Dio();
final cookieSecureStorage = CookieSecureStorage();
final cookieJar = PersistCookieJar(storage: cookieSecureStorage);
httpClient.interceptors.add(CookieManager(cookieJar));
If you've done everything correctly, you can now use httpClient to make requests to your API and be sure that if there is a cookie in the response, the API will certainly get it in the next request.
Conclusion
This way we have achieved acceptance, processing and storing cookies securely by making sure that the platform stores the data safely.
If your application has the ability to set a PIN, then you can encrypt your cookies before storing them to provide additional security. For example, the hash with the key to decrypt will be the same PIN code of the user. Thus, if an attacker somehow manages to extract the cookie from the storage, he will encounter encrypted data, and it will take him a long time to decrypt it. During that time, the session will expire, and the attacker will be left with invalid data.
We hope that this article was useful for you. In follow-up articles we will share with you our personal experience in application development, testing and design. Stay tuned so you don't miss anything.
And if you have an interesting project, but you don't know yet how to start implementing it, leave your contacts in the form and our specialists will reach you.