Neigaard
Neigaard

Reputation: 4050

Dio interceptor for refreshing access token

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&lt;Response&lt;dynamic&gt;&gt; _retry(RequestOptions requestOptions) async {
    final options = Options(
      method: requestOptions.method,
      headers: requestOptions.headers,
    );
    return dio.request&lt;dynamic&gt;(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

Answers (5)

Jiten Basnet
Jiten Basnet

Reputation: 1820

Here is the simple use in my application:

  1. 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);
    }
    

    }

  2. 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();
     }
    

    }

  3. 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

genericUser
genericUser

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.

What is a HttpClient Interceptor?

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.

enter image description here

Token management using Interceptors

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.

Problem with your attempt

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.

My approach

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:

  1. Checks if the token is in cache config.
  2. If not, then get try to get the token from local storage (using flutter_secure_storage).
  3. If it does not exist locally, then try to get that token from the server.

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

  1. Verify that the request should be used with an access token (not that relevant to our discussion).
  2. Get the token from the cache config (using the AppConfig instance).
  3. In case it does not exist on the cache (probably first HTTP request within app open), or the cached token has expired, then in both situations load a new access token using the 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).

Bonus

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

Pavlo Ostasha
Pavlo Ostasha

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

FDuhen
FDuhen

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

Shirsh Shukla
Shirsh Shukla

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

Related Questions