16

I am using https://github.com/p2/OAuth2 for connecting to the backend of my app via OAuth2 which works quite well.

The problem I have is when the access token expires and multiple requests happen at the same time some of them fail.

Parallel requests can be triggered from different parts of the app. For example when the app is launched the current location is sent to the server and a list of events is downloaded.

What would be the best way to make sure that no second refresh token request is made while the first is still running?

Thomas Einwaller
  • 8,873
  • 4
  • 40
  • 55
  • I'm having the same problem for the moment : I need to refresh the token in multiple parallel requests but when I start refreshing for one request the token I use for the other requests is now invalid. I'm looking at this direction for the moment : 1. if the token has expired refresh it only one class such as AppDelegate 2. get the new token and pass it to the other class It's harder than I thought to access the value : http://stackoverflow.com/questions/27390656/how-to-return-value-from-alamofire Do you have any news ? – Matthew Usdin Jan 24 '16 at 19:28

3 Answers3

14

Find your token lifetime and set buffer e.g 1-2 min and If your token needs refresh, save all requests when token is refreshing. After that execute all saved all requests. You can do this with DispatchQueue and DispatchWorkItem.

Example code below.

final class Network: NSObject {

    static let shared = Network()

    private enum Constants {
        static let tokenRefreshDiffrenceMinute = 1
        static let tokenExpireDateKey = "tokenExpireDate"
    }

    private(set) var tokenExpireDate: Date! {
        didSet {
            UserDefaults.standard.set(tokenExpireDate, forKey: Constants.tokenExpireDateKey)
        }
    }

    public override init() {
        super.init()

        if let date = UserDefaults.standard.object(forKey: Constants.tokenExpireDateKey) as? Date {
            tokenExpireDate = date
            print("Token found!")
        }
        else {
            print("Token not found!")
            isTokenRefreshing = true
            getToken {
                self.isTokenRefreshing = false
                self.executeAllSavedRequests()
            }
        }
    }


    private var isTokenRefreshing = false
    private var savedRequests: [DispatchWorkItem] = []

    func request(url: String, params: [String: Any], result: @escaping (String?, Error?) -> Void) {

        // isTokenRefreshing save all requests
        if isTokenRefreshing {

            saveRequest {
                self.request(url: url, params: params, result: result)
            }

            return
        }

        // if token expire
        if getMinutesFrom2Dates(Date(), tokenExpireDate) < Constants.tokenRefreshDiffrenceMinute {

            // open this flag for we need wait refresh token
            isTokenRefreshing = true

            // save current request too
            saveRequest {
                self.request(url: url, params: params, result: result)
            }


            // get token
            self.getToken { [unowned self] in
                self.isTokenRefreshing = false
                self.executeAllSavedRequests()
            }
        } else {
            //Alamofire.request ...

            DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
                DispatchQueue.main.async(execute: {
                    result(url, nil)
                })
            }
        }
    }

    private func saveRequest(_ block: @escaping () -> Void) {
        // Save request to DispatchWorkItem array
        savedRequests.append( DispatchWorkItem {
            block()
        })
    }

    private func executeAllSavedRequests() {
        savedRequests.forEach({ DispatchQueue.global().async(execute: $0) })
        savedRequests.removeAll()
    }

    private func getToken(completion: @escaping () -> Void) {
        print("Token needs a be refresh")
        // Goto server and update token
        DispatchQueue.global().asyncAfter(deadline: .now() + 1) { [unowned self] in
            DispatchQueue.main.async(execute: { [unowned self] in
                self.tokenExpireDate = Date().addingTimeInterval(120)
                print("Token refreshed!")
                completion()
            })
        }
    }

    private func getMinutesFrom2Dates(_ date1: Date, _ date2: Date) -> Int {
        return Calendar.current.dateComponents([.minute], from: date1, to: date2).minute!
    }
}
Vicaren
  • 654
  • 4
  • 12
4

You should queue your failure requests 401. Since you don't provide the code you use to refresh token I explain to you how you should do it and let you implement it yourself:

  • Create a shared retrier for auth requests
  • It should has a shared queue of retrying requests. An array [Request] for example
  • When a request failure because of the accessToken, it will appended to the requestQueue and wait for the access token to be refreshed.
  • So observe the queue and if it was empty and now it has a new item, means that the token recently expired and should perform refresh logic
  • Meanwhile another request failed because of the 401 error.
  • Add it to requestQueue
  • Observer will notice about that BUT it won't try to refresh the token! because it was NOT empty before this request added to it. so it just appended and waits there.
  • So after some time, the new access token arrived
  • Then you can retry all requests in the queue with the new access token. (From 0 to keep the order or all at once async for more rapid response)

- What if there is a request that already performed but not hit the server unless the queue is empty?

Well, That's very rare condition but it could happen (Already happened for me). if you implement it correctly like I said, and don't mess whit the something like is retrying flag, It will just refresh twice! Not really desired but it's fine and it will work like a charm.

- What if we don't want to refresh it twice or more at that rare condition?

Although it is completely OK with the OAuth 2 rules, but you can do this to prevent the error: - As soon as you get the 401 error (or any you marked as auth error), remove the access token immediately. - Any further request will notice there is no access token to request with and they could automatically and directly sent to the requestQueue. - So there is no more request race condition at all.

- Any note at last?

Don't forget to clear the queue if refresh logic failed. Also you can keep them and retry them if user logged in again, but you have to check the identity of the new logged in user with the previous one who filled the queue.

Hope it helps

3

we fixed similar issue using serial queue and semaphore

import Alamofire
    
class AccessTokenInterceptor: RequestInterceptor {

    let auth = OAuthService()

    func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
            auth.refreshToken { [weak self] result in
                switch result {
                case .success(_):
                    urlRequest.setValue(accessTokenType + (self?.auth.oauth2?.accessToken ?? ""), forHTTPHeaderField: "Authorization")
                    urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
                    urlRequest.setValue(token, forHTTPHeaderField: "Token")
                    completion(.success(urlRequest))
                case .failure(let error):
                    switch error {
                    case .noRefreshToken:
                        self?.forceLogoutUser(error: error)
                    case .invalidGrant(_):
                        self?.forceLogoutUser(error: error)
                    default: ()
                    }
                }
            }
        }
        
        func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
            
        }
    }

Here whenever getToken is called we add this in serial queue and using semaphore to ensure only one call is sent at a time.

import Foundation
import OAuth2
    
class OAuthService: NSObject {
        
    private let serialQueue : DispatchQueue = DispatchQueue(label: "refreshToken.serial.queue")
    private let semaphore : DispatchSemaphore = DispatchSemaphore(value: 0)
        
    func getToken(completion: @escaping (Result<Bool, OAuth2Error>) -> ()) {
            if let validAccessToken = oauth2?.hasUnexpiredAccessToken(), validAccessToken {
                completion(.success(true))
                semaphore.signal()
            }else {
                serialQueue.async {
                    self.refreshOAuth2Token(completion)
                }
                refreshOAuth2Token(completion)
            }
            semaphore.wait()
        }
        
        
        func refreshOAuth2Token(_ completion: @escaping(Result<Bool, OAuth2Error>) -> ()) {
            oauth2?.clientConfig.secretInBody = true
            oauth2?.doRefreshToken(callback: { [weak self] authParamters, error in
                guard let self = self else { return }
                self.semaphore.signal()
                if let error = error {
                    completion(.failure(error))
                } else {
                    completion(.success(true))
                }
            })
        }
        
    }