Flutter DIO Refresh Token Loop

246 Views Asked by At

I'm building a Flutter App that primarily utilises API calls to function, however am running into 401 Unauthorised errors after the bearer token expires (every 4 hours).

I have tried to implement a DIO interceptor to handle the request in this scenario, but looking at the network debug logs the request repeatedly sends, even when receiving a Status Code 200 back. (I have replicated a 401 and 200 by using my login page and sending a incorrect password in the requestToken function and hard coding the correct password in the refreshToken function)

I have put all API calls in a separate file apihelper.dart a portion of code looks like the below:

class APIHelper {
  StorageService storageService = StorageService();
  Dio dio = Dio(
    BaseOptions(
      connectTimeout: Duration(seconds: 120),
      receiveTimeout: Duration(seconds: 120),
    ),
  );

  final _storage = const FlutterSecureStorage();

  APIHelper() {
    dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) async {
          final bearerToken = await _storage.read(key: 'token');
          final tenantId = await _storage.read(key: 'tenant');
          if (options.path != '/api/tokens') {
            // Add the access token to the request header
            options.headers['Authorization'] = bearerToken;
            options.headers['tenant'] = tenantId;
            return handler.next(options);
          }
          options.headers['tenant'] = tenantId;
          return handler.next(options);
        },
        onError: (DioException e, handler) async {
          if (e.response?.statusCode == 401) {
            // If a 401 response is received, refresh the access token
            String newAccessToken = await refreshToken();

            // Update the request header with the new access token
            e.requestOptions.headers['Authorization'] =
                'Bearer $newAccessToken';

            // Repeat the request with the updated header
            return handler.resolve(await dio.fetch(e.requestOptions));
          }
        },
      ),
    );
  }

My initial RequestToken function is below:

Future requestToken(String username, String password, String tenant) async {
    var credsdata = {"email": username, "password": password};
    String baseUrl = await getBaseUrl();
    dio.options.baseUrl = baseUrl; // Set the dynamic base URL
    print('requestToken() Called');
    print('requestToken() Body: $credsdata');

    try {
      Response response = await dio.post('/api/tokens', data: credsdata);

      Map<String, dynamic> data = response.data;

      var token = data['token'];
      var refreshToken = data['refreshToken'];

      await storageService.saveStorageData('token', token);
      await storageService.saveStorageData('refreshtoken', refreshToken);

      print(refreshToken);

      return 'Authentication Success';
    } on DioException catch (e) {
      return e.response?.statusMessage;
    }
  }

My RefreshToken function looks like:

Future refreshToken() async {
    final savedUser = await _storage.read(key: 'username');
    final savedPasswd = await _storage.read(key: 'password');

    var credsdata = {"email": savedUser, "password": 'TEMP HARDCODED PASSWORD'};
    print('refreshToken() Called');
    print('refreshToken() Body: $credsdata');

    String baseUrl = await getBaseUrl();
    dio.options.baseUrl = baseUrl; // Set the dynamic base URL

    try {
      Response response = await dio.post('/api/tokens', data: credsdata);

      Map<String, dynamic> data = response.data;

      var token = data['token'];
      var refreshToken = data['refreshToken'];

      await storageService.saveStorageData('token', token);
      await storageService.saveStorageData('refreshtoken', refreshToken);

      return token;
    } on DioException catch (e) {
      return e.response?.statusMessage;
    }
  }

screenshot of Network Debug The records with a status code 200 have the hardcoded password from refreshToken() and the 401 logs have the password that is being passed using the UI form.

How can I get it to stop trying once a status code 200 is returned?

2

There are 2 best solutions below

0
On

I have provided how I handled it below. This might help.

  @override
  void onError(err, handler) async {
    if (err.response?.statusCode == 401) {
      final prefAccessToken = await _preferenceManager.getAccessToken();
      try {
        String? errAccessToken = err.requestOptions.headers["accesstoken"];
        if (errAccessToken != null &&
            errAccessToken.isNotEmpty &&
            prefAccessToken == errAccessToken) {
          await _preferenceManager.updateTokens(
              accessToken: null, accessTokenExp: null);
        }
        var value = await dio.post(
          "${DioProvider.baseUrl}/user/refresh-token",
          options: Options(
            headers: {
              'refreshtoken': await _preferenceManager.getRefreshToken(),
              ...getAdditionalHeaders(),
            },
          ),
        );
        if (value.statusCode == 201 || value.statusCode == 200) {
          var data = value.data["data"];
          var accessToken = data["accessToken"];
          var refreshToken = data["refreshToken"];
          var accessTokenExp = data["accessTokenExp"];
          await _preferenceManager.updateTokens(
            accessToken: accessToken,
            refreshToken: refreshToken,
            accessTokenExp: accessTokenExp,
          );

          await AuthController.instance().getSettings();

          return handler.resolve(
              await _cloneDioErrorRequestWithAccesstoken(err, accessToken));
        } else {
          throw "";
        }
      } catch (e) {
        /** 
         * Means refresh token is invalid!
         * But, there is a possibility of concurrent refresh-token api call
         * So, let's check if access token is there!
         * */
        if (prefAccessToken.isEmpty) {
          /** Means accesstoken (or, new accesstoken in parallel case) and refreshtoken both are expired */
          await _preferenceManager.updateTokens(
            accessToken: null,
            refreshToken: null,
            accessTokenExp: null,
          );
          await AuthController.instance().getSettings();
          await goToNamedAndClearAll(AppRoutes.start);
        } else {
          /** Means new accesstoken is not expired */
          return handler.resolve(
              await _cloneDioErrorRequestWithAccesstoken(err, prefAccessToken));
        }
      }
    } else {
      super.onError(err, handler);
    }
  }
0
On

You need a variable (e.g isRefreshing) to prevent spam call refresh token flow, when you have multiple requests at the same time (and they're all unauthorized)

Assume you have 3 requests are called at the same time

  • Check the first incoming response, which error == 401 (unauthorized)
  • If there isn’t any refresh processing, start refresh flow & set isRefreshing = true (to prevent spam call refresh), also save this request to list retry
  • The other responses will come later, if their error are also unauthorized, save these to list retry
  • After refresh flow success, save new token to local storage, and loop the list to retry all requests
  • If refresh fails (usually means refreshToken also is expired) -> logout user

Notice:

  • Set isRefreshing to false & clear list retry after refreshing success or fail
  • Exclude your endpoint refresh token (and whatever endpoints don’t need accessToken but may respond unauthorized) to prevent infinite loop when refreshToken is also unauthorized
  • If a response matches the condition need to wait for refresh flow, don’t let it fall down to handler.next

You can refer example below

class AuthInterceptor extends InterceptorsWrapper {
  final Dio dio;

  AuthInterceptor(this.dio);

  // when accessToken is expired & having multiple requests call
  // this variable to lock others request to make sure only trigger call refresh token 01 times
  // to prevent duplicate refresh call
  bool _isRefreshing = false;

  // when having multiple requests call at the same time, you need to store them in a list
  // then loop this list to retry every request later, after call refresh token success
  final _requestsNeedRetry = <({RequestOptions options, ErrorInterceptorHandler handler})>[];

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final accessToken = getAccessTokenFromLocalStorage();
    options.headers['authorization'] = 'Bearer $accessToken';
    return handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final response = err.response;
    if (response != null &&
        // status code for unauthorized usually 401
        response.statusCode == 401 &&
        // refresh token call maybe fail by it self
        // eg: when refreshToken also is expired -> can't get new accessToken
        // usually server also return 401 unauthorized for this case
        // need to exlude it to prevent loop infinite call
        response.requestOptions.path != "path/your/endpoint/refresh") {
      // if hasn't not refreshing yet, let's start it
      if (!_isRefreshing) {
        _isRefreshing = true;

        // add request (requestOptions and handler) to queue and wait to retry later
        _requestsNeedRetry.add((options: response.requestOptions, handler: handler));

        // call api refresh token
        final isRefreshSuccess = await _refreshToken();

        _isRefreshing = false;

        if (isRefreshSuccess) {
          // refresh success, loop requests need retry
          for (var requestNeedRetry in _requestsNeedRetry) {
            // don't need set new accessToken to header here, because these retry
            // will go through onRequest callback above (where new accessToken will be set to header)
            final retry = await dio.fetch(requestNeedRetry.options);
            requestNeedRetry.handler.resolve(retry);
          }
          _requestsNeedRetry.clear();
        } else {
          _requestsNeedRetry.clear();
          // if refresh fail, force logout user here
        }
      } else {
        // if refresh flow is processing, add this request to queue and wait to retry later
        _requestsNeedRetry.add((options: response.requestOptions, handler: handler));
      }
    } else {
      // ignore other error is not unauthorized
      return handler.next(err);
    }
  }

  Future<bool> _refreshToken() async {
    try {
      final refreshToken = getRefreshTokenFromLocalStorage();
      final res = await callApiRefreshToken(refreshToken);
      if (res.response.statusCode == 200) {
        print("refresh token success");
        final refreshResponse = RefreshResponse.fromJson(res.data);
        // save new access + refresh token to your local storage for using later
        setAccessTokenToLocalStorage(refreshResponse.accessToken);
        setRefreshTokenToLocalStorage(refreshResponse.refreshToken);
        return true;
      } else {
        print("refresh token fail ${res.response.statusMessage ?? res.response.toString()}");
        return false;
      }
    } catch (error) {
      print("refresh token fail $error");
      return false;
    }
  }
}