Using Interceptor in Dio for Flutter to Refresh Token

I am trying to use Interceptor with Dio in flutter, I have to handle Token expire. following is my code

Future<Dio> getApiClient() async {
    token = await storage.read(key: USER_TOKEN);
    _dio.interceptors.clear();
    _dio.interceptors
        .add(InterceptorsWrapper(onRequest: (RequestOptions options) {
      // Do something before request is sent
      options.headers["Authorization"] = "Bearer " + token;
      return options;
    },onResponse:(Response response) {
        // Do something with response data
        return response; // continue
    }, onError: (DioError error) async {
      // Do something with response error
      if (error.response?.statusCode == 403) {
        // update token and repeat
        // Lock to block the incoming request until the token updated

        _dio.interceptors.requestLock.lock();
        _dio.interceptors.responseLock.lock();
        RequestOptions options = error.response.request;
        FirebaseUser user = await FirebaseAuth.instance.currentUser();
        token = await user.getIdToken(refresh: true);
        await writeAuthKey(token);
        options.headers["Authorization"] = "Bearer " + token;

        _dio.interceptors.requestLock.unlock();
        _dio.interceptors.responseLock.unlock();
        _dio.request(options.path, options: options);
      } else {
        return error;
      }
    }));
    _dio.options.baseUrl = baseUrl;
    return _dio;
  }

problem is instead of repeating the network call with the new token, Dio is returning the error object to the calling method, which in turn is rendering the wrong widget, any leads on how to handle token refresh with dio?


I have found a simple solution that looks like the following:

this.api = Dio();

    this.api.interceptors.add(InterceptorsWrapper(
       onError: (error) async {
          if (error.response?.statusCode == 403 ||
              error.response?.statusCode == 401) {
              await refreshToken();
              return _retry(error.request);
            }
            return error.response;
        }));

Basically what is going on is it checks to see if the error is a 401 or 403, which are common auth errors, and if so, it will refresh the token and retry the response. My implementation of refreshToken() looks like the following, but this may vary based on your api:

Future<void> refreshToken() async {
    final refreshToken = await this._storage.read(key: 'refreshToken');
    final response =
        await this.api.post('/users/refresh', data: {'token': refreshToken});

    if (response.statusCode == 200) {
      this.accessToken = response.data['accessToken'];
    }
  }

I use Flutter Sercure Storage to store the accessToken. My retry method looks like the following:

  Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
    final options = new Options(
      method: requestOptions.method,
      headers: requestOptions.headers,
    );
    return this.api.request<dynamic>(requestOptions.path,
        data: requestOptions.data,
        queryParameters: requestOptions.queryParameters,
        options: options);
  }

If you want to easily allows add the access_token to the request I suggest adding the following function when you declare your dio router with the onError callback:

onRequest: (options) async {
          options.headers['Authorization'] = 'Bearer: $accessToken';
          return options;
        },

I solved it using interceptors in following way :-

  Future<Dio> getApiClient() async {
    token = await storage.read(key: USER_TOKEN);
    _dio.interceptors.clear();
    _dio.interceptors
        .add(InterceptorsWrapper(onRequest: (RequestOptions options) {
      // Do something before request is sent
      options.headers["Authorization"] = "Bearer " + token;
      return options;
    },onResponse:(Response response) {
        // Do something with response data
        return response; // continue
    }, onError: (DioError error) async {
      // Do something with response error
      if (error.response?.statusCode == 403) {
        _dio.interceptors.requestLock.lock();
        _dio.interceptors.responseLock.lock();
        RequestOptions options = error.response.request;
        FirebaseUser user = await FirebaseAuth.instance.currentUser();
        token = await user.getIdToken(refresh: true);
        await writeAuthKey(token);
        options.headers["Authorization"] = "Bearer " + token;

        _dio.interceptors.requestLock.unlock();
        _dio.interceptors.responseLock.unlock();
        return _dio.request(options.path,options: options);
      } else {
        return error;
      }
    }));
    _dio.options.baseUrl = baseUrl;
    return _dio;
  }

Dio 4.0.0 Support

    dio.interceptors.add(
          InterceptorsWrapper(
            onRequest: (request, handler) {
              if (token != null && token != '')
                request.headers['Authorization'] = 'Bearer $token';
              return handler.next(request);
            },
            onError: (e, handler) async {
              if (e.response?.statusCode == 401) {
                try {
                  await dio
                      .post(
                          "https://refresh.api",
                          data: jsonEncode(
                              {"refresh_token": refreshtoken}))
                      .then((value) async {
                    if (value?.statusCode == 201) {
                      //get new tokens ...
                      print("access token" + token);
                      print("refresh token" + refreshtoken);
                      //set bearer
                      e.requestOptions.headers["Authorization"] =
                          "Bearer " + token;
                      //create request with new access token
                      final opts = new Options(
                          method: e.requestOptions.method,
                          headers: e.requestOptions.headers);
                      final cloneReq = await dio.request(e.requestOptions.path,
                          options: opts,
                          data: e.requestOptions.data,
                          queryParameters: e.requestOptions.queryParameters);
    
                      return handler.resolve(cloneReq);
                    }
                    return e;
                  });
                  return dio;
                } catch (e, st) {
                  
                }
              }
           },
        ),
    );

I think that a better approach is to check the token(s) before you actually make the request. That way you have less network traffic and the response is faster.

EDIT: Another important reason to follow this approach is because it is a safer one, as X.Y. pointed out in the comment section

In my example I use:

http: ^0.13.3
dio: ^4.0.0
flutter_secure_storage: ^4.2.0
jwt_decode: ^0.3.1
flutter_easyloading: ^3.0.0 

The idea is to first check the expiration of tokens (both access and refresh). If the refresh token is expired then clear the storage and redirect to LoginPage. If the access token is expired then (before submit the actual request) refresh it by using the refresh token, and then use the refreshed credentials to submit the original request. In that way you minimize the network traffic and you take the response way faster.

I did this:

AuthService appAuth = new AuthService();

class AuthService {
  Future<void> logout() async {
    token = '';
    refresh = '';

    await Future.delayed(Duration(milliseconds: 100));

    Navigator.of(cnt).pushAndRemoveUntil(
      MaterialPageRoute(builder: (context) => LoginPage()),
      (_) => false,
    );
  }

  Future<bool> login(String username, String password) async {
    var headers = {'Accept': 'application/json'};
    var request = http.MultipartRequest('POST', Uri.parse(baseURL + 'token/'));
    request.fields.addAll({'username': username, 'password': password});
    request.headers.addAll(headers);
    http.StreamedResponse response = await request.send();

    if (response.statusCode == 200) {
      var resp = await response.stream.bytesToString();
      final data = jsonDecode(resp);
      token = data['access'];
      refresh = data['refresh'];
      secStore.secureWrite('token', token);
      secStore.secureWrite('refresh', refresh);
      return true;
    } else {
      return (false);
    }
  }

  Future<bool> refreshToken() async {
    var headers = {'Accept': 'application/json'};
    var request =
        http.MultipartRequest('POST', Uri.parse(baseURL + 'token/refresh/'));
    request.fields.addAll({'refresh': refresh});

    request.headers.addAll(headers);

    http.StreamedResponse response = await request.send();

    if (response.statusCode == 200) {
      final data = jsonDecode(await response.stream.bytesToString());
      token = data['access'];
      refresh = data['refresh'];

      secStore.secureWrite('token', token);
      secStore.secureWrite('refresh', refresh);
      return true;
    } else {
      print(response.reasonPhrase);
      return false;
    }
  }
}

After that create the interceptor

import 'package:dio/dio.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import '../settings/globals.dart';


class AuthInterceptor extends Interceptor {
  static bool isRetryCall = false;

  @override
  void onRequest(
      RequestOptions options, RequestInterceptorHandler handler) async {
    bool _token = isTokenExpired(token);
    bool _refresh = isTokenExpired(refresh);
    bool _refreshed = true;

    if (_refresh) {
      appAuth.logout();
      EasyLoading.showInfo(
          'Expired session');
      DioError _err;
      handler.reject(_err);
    } else if (_token) {
      _refreshed = await appAuth.refreshToken();
    }
    if (_refreshed) {
      options.headers["Authorization"] = "Bearer " + token;
      options.headers["Accept"] = "application/json";

      handler.next(options);
    }
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) async {
    handler.next(response);
  }

  @override
  void onError(DioError err, ErrorInterceptorHandler handler) async {
    handler.next(err);
  }
}

The secure storage functionality is from:

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

SecureStorage secStore = new SecureStorage();

class SecureStorage {
  final _storage = FlutterSecureStorage();
  void addNewItem(String key, String value) async {
    await _storage.write(
      key: key,
      value: value,
      iOptions: _getIOSOptions(),
    );
  }

  IOSOptions _getIOSOptions() => IOSOptions(
        accountName: _getAccountName(),
      );

  String _getAccountName() => 'blah_blah_blah';

  Future<String> secureRead(String key) async {
    String value = await _storage.read(key: key);
    return value;
  }

  Future<void> secureDelete(String key) async {
    await _storage.delete(key: key);
  }

  Future<void> secureWrite(String key, String value) async {
    await _storage.write(key: key, value: value);
  }
}

check expiration with:

bool isTokenExpired(String _token) {
  DateTime expiryDate = Jwt.getExpiryDate(_token);
  bool isExpired = expiryDate.compareTo(DateTime.now()) < 0;
  return isExpired;
}

and then the original request

var dio = Dio();

Future<Null> getTasks() async {
EasyLoading.show(status: 'Wait ...');
    
Response response = await dio
    .get(baseURL + 'tasks/?task={"foo":"1","bar":"30"}');
    
if (response.statusCode == 200) {
    print('success');
} else {
    print(response?.statusCode);
}}

As you can see the Login and refreshToken request use http package (they don't need the interceptor). The getTasks use dio and it's interceptor in order to get its response in one and only request