Reputation: 4050
I am trying to implement a access token refresh with a Dio interceptor. I have looked at examples I could find, none of which seem to work.
Here is my attempt:
class AuthInterceptor extends QueuedInterceptor {
final Dio dio;
final AuthService authService;
AuthInterceptor(this.dio, this.authService);
@override
void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
var accessToken = await TokenRepository.getAccessToken();
if (accessToken != null) {
logDebug('Access-Token: $accessToken');
options.headers['Authorization'] = 'Bearer $accessToken';
}
return handler.next(options);
}
@override
void onError(DioError err, ErrorInterceptorHandler handler) async {
if (err.type == DioErrorType.response && err.response?.statusCode == 401) {
var accessToken = await TokenRepository.getAccessToken();
if (accessToken != null) {
final refreshToken = await TokenRepository.getRefreshToken();
final refreshResult = await authService
.refresh(RefreshRequest(accessToken, refreshToken!));
await TokenRepository.setAccessToken(refreshResult.accessToken);
handler.resolve(await _retry(err.requestOptions));
} else {
di<NavigationHelper>().navigateClearHistory(LoginPage());
handler.reject(err);
}
} else {
handler.next(err);
}
}
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
final options = Options(
method: requestOptions.method,
headers: requestOptions.headers,
);
return dio.request<dynamic>(requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options);
}
}
It kind of works, I do get a new access token. But there is something wrong with how I end the onError override. I do not think the handler gets properly finished? I think the handler.resolve is wrong, but how should I this be implemented? How can I finish this handler properly and perform the retry call with the new access token?
Upvotes: 11
Views: 15682
Reputation: 1820
Here is the simple use in my application:
create an AuthInterceptor.dart
class AuthInterceptor extends QueuedInterceptorsWrapper {
AuthInterceptor(this._tokenService);
final TokenService _tokenService;
@override
void onError(DioError err, ErrorInterceptorHandler handler) async {
if (err.response!.statusCode == 403 || err.response!.statusCode ==401) {
final options = err.requestOptions;
final accessToken = await _tokenService.refreshToken();
if (accessToken == null || accessToken.isEmpty) {
return handler.reject(err);
}
else {
options.headers.addAll({'Authorization': accessToken});
try {
final _res = await _tokenService.fetch(options);
return handler.resolve(_res);
} on DioError catch (e) {
handler.next(e);
return;
}
}
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler
handler) async {
final String? accessToken = await _tokenService.getToken();
if (accessToken != null) {
options.headers.addAll({'Authorization': accessToken});
}
log("NEW ACCESS TOKENN $accessToken");
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler{
handler.next(response);
}
}
create TokenService.dart
Class TokenService extends ApiClient {
late final DataBaseHandler _dataBaseHandler;
TokenService(this._dataBaseHandler) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
receiveTimeout: 60000,
connectTimeout: 60000,
responseType: ResponseType.json,
headers: <String, dynamic>{
'Accept': 'application/json',
'Content-Type': 'application/json',
},
));
}
late Dio _dio;
Future<Response<dynamic>> fetch(RequestOptions options) =>
_dio.fetch(options);
Future<String?> refreshToken() async {
Response response;
// we use completer to get future value when we get the response
final tokenSubscription = Completer<String>();
final Uri apiUrl = Uri.parse(baseUrl);
var refresh token = await _dataBaseHandler.read(key: "refreshToken");
_dio.options.headers["Authorization"] = "Bearer
${refreshToken?.object}";
try {
response = await _dio.postUri(apiUrl);
if (response.statusCode == 200) {
//if your refresh token is a model with extra info, decoding it.
RefreshTokenResponse refreshTokenResponse =
RefreshTokenResponse.fromJson(jsonDecode(response.toString()));
databaseHandler.write(key:'accessToken',
value:refreshTokenResponse.data.accessToken);
//let's complete the completer with refresh token here
tokenSubscription.complete(refreshTokenResponse.data.accessToken);
}
} catch (e) {
log(e.toString());
}
// don't forget to retun future here: this will updte the future
// listener with accessToken
return tokenSubscription.future;
}
// helper method to get previous accessToken
Future<String?> getToken() async {
var accessToken = await _dataBaseHandler.read(key:
ApiStrings.accessToken);
return accessToken?.object.toString();
}
}
Finally you can use interceptor on the original Dio client.
DataBaseHandler is just a local storage service here:
class DioClient {
late Dio _dioClient;
DioClient() {
_dioClient = Dio()
..interceptors.addAll([
AuthInterceptor(TokenService(locator<DataBaseHandler>())),
if (kDebugMode) LogInterceptor(responseBody: true,
requestBody: true),
])
..options.baseUrl = baseUrl
..options.headers = headers;
}
}
Upvotes: 0
Reputation: 7118
Although there is not much information about how to get a new token in case of expiration using the Dio interceptor, I will try my best to help you.
HttpClient interceptors aim to modify, track and verify HTTP requests and responses from and to the server.
As you can see from the scheme, the interceptor is part of the client, and is the most edge feature in our application.
Since interceptors are the last part of sending HTTP requests to the servers, It's a good place to handle request retries, and get new tokens in case of expiration.
Let's talk a bit about your code, and try to break each part of it.
onRequest:
Although this part should work just fine, It's inefficient using await
to get the access token on each HTTP request. It will drastically slow your app and the duration you retrieve your HTTP response.
I recommend you create a loadAccessToken()
function that will be responsible loading your token to a cached repository, and use that cached token on every request.
Note, that I don't know what your TokenRepository.getAccessToken()
function does behind the scene. But in case it's an HTTP request, be aware that it will be intercepted too! And if you have problem to reach your server, you will get into infinite loop of trying to get your token.
As I will explain later, I'm using the flutter_secure_storage package to save new tokens securely within the app, while loading that token, to cache, on the first HTTP request.
onError:
I recommend you to use the jwt_decode package, to identify expired tokens before sending them to the server (onRequest). That way, you will retrieve new tokens only in case of expiration, and each HTTP request will be sent with verified tokens only.
Thanks to @FDuhen comment, it's still good to mention that it's important to identify and handle 401 responses (since not all 401 are related to token expiration). You can do that via your interceptor for global behavior (like redirecting your users back to the Login flow), or per request (and handle it from your ApiRepository
). For the simplicity of the example, I won't override the onError
Interceptor function.
Now let's take a look at my code. It's not perfect, but hopefully, it will help you to understand better how to handle tokens using interceptors.
First, I have created a loadAccessToken()
function as part of my tokenRepository
class, which aims to arm the token into the cache config repository.
That function works with three steps:
On each step (1, 2) I verify that the token is not expired using the jwt_decoder package. If it's expired, then I get it from the server.
Each time you are getting a new token from the server, you need to save it locally using the flutter_secure_storage (we don't want to save it using shared_preferences since it's not secure). That way, your token will be saved securely, and retrieving it will be fast.
My loadAccessToken
function:
/// Tries to get accessToken from [AppConfig], localSecureStorage or Keycloak
/// servers, and update them if necessary
Future<String?> get loadAccessToken async {
// get token from cache
var accessToken = _config.accessToken;
if (accessToken != null && !tokenHasExpired(accessToken)) {
return accessToken;
}
// get token from secure storage
accessToken =
await LocalSecureStorageRepository.get(SecureStorageKeys.accessToken);
if (accessToken != null && !tokenHasExpired(accessToken)) {
// update cache
_config.accessToken = accessToken;
return accessToken;
}
// get token from Keycloak server
final keycloakTokenResponse = await _accessTokenFromKeycloakServer;
accessToken = keycloakTokenResponse.accessToken;
final refreshToken = keycloakTokenResponse.refreshToken;
if (!tokenHasExpired(accessToken) && !tokenHasExpired(refreshToken)) {
// update secure storage
await Future.wait([
LocalSecureStorageRepository.update(
SecureStorageKeys.accessToken,
accessToken,
),
LocalSecureStorageRepository.update(
SecureStorageKeys.refreshToken,
refreshToken,
)
]);
// update cache
_config.accessToken = accessToken;
return accessToken;
}
return null;
}
And here is the tokenHasExpired
function (using the jwt_decoder package):
bool tokenHasExpired(String? token) {
if (token == null) return true;
return Jwt.isExpired(token);
}
Now, it will be much easier to handle access tokens using our interceptor. As you can see below (in my interceptor example), I'm passing a singleton AppConfig
instance and a tokenRepository
that contains the loadAccessToken()
function we talked about earlier.
All I'm doing on my override onRequest
function, is to
AppConfig
instance).loadAccessToken
function (first from storage, only then from server token provider).My interceptor:
class ApiProviderTokenInterceptor extends Interceptor {
ApiProviderTokenInterceptor(this._config, this._tokenRepository);
final AppConfig _config;
final TokenRepository _tokenRepository;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (options.headers['requires-token'] == 'false') {
// if the request doesn't need token, then just continue to the next
// interceptor
options.headers.remove('requiresToken'); //remove the auxiliary header
return handler.next(options);
}
var token = _config.accessToken;
if (token == null || _tokenRepository.tokenHasExpired(token)) {
token = await _tokenRepository.loadAccessToken;
}
options.headers.addAll({'authorization': 'Bearer ${token!}'});
return handler.next(options);
}
@override
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
return handler.next(response);
}
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
// <-- here you can handle 401 response, which is not related to token expiration, globally to all requests
return handler.next(err);
}
}
In case you are getting an error on retrieving a new token from the server (on the loadAccessToken()
function). Then handle the error on the tokenRepository
and decide what to present to your client (something general like "There is a problem with our servers, please try again later", should be fine).
You can add the built-in Dio LogInterceptor
to print every request and response (really helpful for debugging).
Or you can use the pretty_dio_logger package, to print beautiful, colored, request and response logs.
_ApiProvider(
dio
..interceptors.add(authInterceptor)
// ..interceptors.add(LogInterceptor())
..interceptors.add(
PrettyDioLogger(
requestBody: true,
requestHeader: true,
),
),
);
Upvotes: 29
Reputation: 16699
Here is the code working in ap in the production, and never had any issues with it.
@override
Future<void> onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
if (isAccessTokenExpired) {
try {
final accessToken = await accessTokenRepo?.refresh();
options.headers.setOrRemove(
_authorizationHeader, _authorizationHeaderValue(accessToken));
saveAccessToken(accessToken);
} catch (error) {
saveAccessToken(null);
handler.reject(DioError(requestOptions: options, error: error));
return;
}
}
return super.onRequest(options, handler);
}
@override
Future<void> onError(DioError err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401 && _accessToken != null) {
if (!isAccessTokenExpired) {
_updateHeader(
_authorizationHeader, _authorizationHeaderValue(_accessToken));
}
// here, I construct and send a new request based on the old one.
handler.resolve(await _sendRequest(err.requestOptions));
return;
}
try {
// here, I check the retry count
if (shouldRetry(err)) {
await Future.delayed(_retryDelay);
handler.resolve(await _sendRequest(err.requestOptions));
} else {
super.onError(err, handler);
}
} catch (_) {
super.onError(err, handler);
}
}
All the other code is implementation specific.
Hope it helps.
Upvotes: 0
Reputation: 4816
I guess you're almost good to go. The only difference with my current code is that I'm using a new instance of Dio
to retry the request.
The logic I'm following to implement a Token refresh is :
1- Catch the network error if it's a 401 Unauthorized
.
2- If I do have an AccessToken
, execute my RefreshToken flow
3a- On success (of the refresh token), proceed the original request in a retry with a new dio instance
3b- On error, proceed the original error
Here is the AuthInterceptor
(don't mind the apiInterceptor
) I'm using :
class AuthInterceptor extends QueuedInterceptorsWrapper {
AuthInterceptor(this._authentManager, this._apiInterceptor);
final AuthentManager _authentManager;
final Interceptor _apiInterceptor;
@override
// ignore: avoid_void_async
void onError(DioError err, ErrorInterceptorHandler handler) async {
//On Unauthorized, the AccessToken or RefreshToken may be outdated
if (err.response?.statusCode == HttpStatus.unauthorized) {
debugPrint('AuthInterceptor - Error 401');
final accessToken = await _authentManager.getAccessTokenFromStorage();
//Happens on first request if badly handled
//Or if the user cleaned his local storage at Runtime
if (accessToken == null || accessToken.isEmpty) {
debugPrint('AuthInterceptor - No Local AccessToken');
return handler.next(err);
}
try {
debugPrint('AuthInterceptor - Starting Refresh Flow');
//Refresh token Flow
final refreshResult = await _authentManager.refreshToken();
final token = refreshResult.dataOrThrow as TokenJWT;
//Getting the retry request
final response = await _getRetryRequest(err, token);
debugPrint('AuthInterceptor - Response retrieved, proceeding');
return handler.resolve(response);
} on DioError catch (e) {
debugPrint('AuthInterceptor - Error when retrying with a new Refresh');
//API Key is Expired OR
//Refresh token is Expired
if (e.response?.statusCode == 401) {
debugPrint('AuthInterceptor - Error 401 retrying with a new Refresh');
}
} on NetworkError catch (e) {
if (e.type == NetworkErrorType.unauthorized) {
debugPrint('AuthInterceptor - Error 401 retrying with a new Refresh');
}
} catch (e) {
debugPrint('Error during retry - $e');
}
}
handler.next(err);
}
Future<Response> _getRetryRequest(DioError err, TokenJWT token) async {
debugPrint('AuthInterceptor - Building Retry Request');
//Can crash but we're in a try catch
final requestOptions = err.response!.requestOptions;
requestOptions.headers[ApiHeaders.authorization] =
'Bearer ${token.accessToken}';
final options = Options(
method: requestOptions.method,
headers: requestOptions.headers,
);
final dioRefresh = Dio(
BaseOptions(
baseUrl: requestOptions.baseUrl,
headers: <String, String>{
HttpHeaders.acceptHeader: 'application/json',
},
),
);
//Need to inject our custom API Interceptor
//Because of the temp instance of Dio
dioRefresh.interceptors.add(_apiInterceptor);
debugPrint(
'AuthInterceptor - Triggering Request with '
'${requestOptions.path}, '
'${requestOptions.data}, '
'${requestOptions.queryParameters}, '
'${options.headers}',
);
final response = await dioRefresh.request<dynamic>(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
);
return response;
}
@override
// ignore: avoid_void_async
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
//Getting cached Access Token, or getting it from storage and caching it
var accessToken = _authentManager.currentAccessToken;
accessToken ??= await _authentManager.getAccessTokenFromStorage();
options.headers.addAll(
<String, String>{
ApiHeaders.authorization: 'Bearer $accessToken',
HttpHeaders.contentTypeHeader: 'application/json',
},
);
handler.next(options);
}
}
Upvotes: 4
Reputation: 5973
I found one example of this, where I am able to refresh the access token using the Dio Interceptor.
class DioHelper {
final Dio dio;
DioHelper({@required this.dio});
final CustomSharedPreferences _customSharedPreferences =
new CustomSharedPreferences();
static String _baseUrl = BASE_URL;
String token = "";
void initializeToken(String savedToken) {
token = savedToken;
_initApiClient();
}
Future<void> initApiClient() {
dio.interceptors
.add(InterceptorsWrapper(onRequest: (RequestOptions options) {
options.headers["Authorization"] = "Bearer " + token;
return options;
}, onResponse: (Response response) {
return response;
}, onError: (DioError error) async {
RequestOptions origin = error.response.request;
if (error.response.statusCode == 401) {
try {
Response<dynamic> data = await dio.get("your_refresh_url");
token = data.data['newToken'];
_customSharedPreferences.setToken(data.data['newToken']);
origin.headers["Authorization"] = "Bearer " + data.data['newToken'];
return dio.request(origin.path, options: origin);
} catch (err) {
return err;
}
}
return error;
}));
dio.options.baseUrl = _baseUrl;
}
Future<dynamic> get(String url) async {
try {
final response = await dio.get(url);
var apiResponse = ApiResponse.fromJson(response.data);
if (apiResponse.status != 200) {
throw Exception(apiResponse.message);
}
return apiResponse.data;
} on DioError catch (e) {
// debugging purpose
print('[Dio Helper - GET] Connection Exception => ' + e.message);
throw e;
}
}
Future<dynamic> post(String url,
{Map headers, @required data, encoding}) async {
try {
final response =
await dio.post(url, data: data, options: Options(headers: headers));
ApiResponse apiResponse = ApiResponse.fromJson(response.data);
if (apiResponse.status != 200) {
throw Exception(apiResponse.message);
}
return apiResponse.data;
} on DioError catch (e) {
// debugging purpose
print('[Dio Helper - GET] Connection Exception => ' + e.message);
throw e;
}
}
}
can you look into that
Upvotes: 0