Refreshing Auth Token with Moya

Moya rxswift : Refresh token and restart request

Finally i was able to solve this by doing the following :

First create a protocol like so ( Those functions are mandatory and not optional ).

import RxSwift

public protocol SessionProtocol {
func getTokenRefreshService() -> Single<Response>
func didFailedToRefreshToken()
func tokenDidRefresh (response : String)
}

It is very very important to conform to the protocol SessionProtocol in the class that you write your network request(s) in like so :

import RxSwift

class API_Connector : SessionProtocol {
//
private final var apiProvider : APIsProvider<APIs>!

required override init() {
super.init()
apiProvider = APIsProvider<APIs>()
}
// Very very important
func getTokenRefreshService() -> Single<Response> {
return apiProvider.rx.request(.doRefreshToken())
}

// Parse and save your token locally or do any thing with the new token here
func tokenDidRefresh(response: String) {}

// Log the user out or do anything related here
public func didFailedToRefreshToken() {}

func getUsers (page : Int, completion: @escaping completionHandler<Page>) {
let _ = apiProvider.rx
.request(.getUsers(page: String(page)))
.filterSuccessfulStatusAndRedirectCodes()
.refreshAuthenticationTokenIfNeeded(sessionServiceDelegate: self)
.map(Page.self)
.subscribe { event in
switch event {
case .success(let page) :
completion(.success(page))
case .error(let error):
completion(.failure(error.localizedDescription))
}
}
}

}

Then, I created a function that returns a Single<Response>.

import RxSwift

extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response {

// Tries to refresh auth token on 401 error and retry the request.
// If the refresh fails it returns an error .
public func refreshAuthenticationTokenIfNeeded(sessionServiceDelegate : SessionProtocol) -> Single<Response> {
return
// Retry and process the request if any error occurred
self.retryWhen { responseFromFirstRequest in
responseFromFirstRequest.flatMap { originalRequestResponseError -> PrimitiveSequence<SingleTrait, ElementType> in
if let lucidErrorOfOriginalRequest : LucidMoyaError = originalRequestResponseError as? LucidMoyaError {
let statusCode = lucidErrorOfOriginalRequest.statusCode!
if statusCode == 401 {
// Token expired >> Call refresh token request
return sessionServiceDelegate
.getTokenRefreshService()
.filterSuccessfulStatusCodesAndProcessErrors()
.catchError { tokeRefreshRequestError -> Single<Response> in
// Failed to refresh token
if let lucidErrorOfTokenRefreshRequest : LucidMoyaError = tokeRefreshRequestError as? LucidMoyaError {
//
// Logout or do any thing related
sessionServiceDelegate.didFailedToRefreshToken()
//
return Single.error(lucidErrorOfTokenRefreshRequest)
}
return Single.error(tokeRefreshRequestError)
}
.flatMap { tokenRefreshResponseString -> Single<Response> in
// Refresh token response string
// Save new token locally to use with any request from now on
sessionServiceDelegate.tokenDidRefresh(response: try! tokenRefreshResponseString.mapString())
// Retry the original request one more time
return self.retry(1)
}
}
else {
// Retuen errors other than 401 & 403 of the original request
return Single.error(lucidErrorOfOriginalRequest)
}
}
// Return any other error
return Single.error(originalRequestResponseError)
}
}
}
}

What this function do is that it catches the error from the response then check for the status code, If it is any thing other than 401 then it will return that error to the original request's onError block but if it is 401 (You can change it to fulfill your needs but this is the standard) then it is going to do the refresh token request.

After doing the refresh token request, it checks for the response.

=> If the status code is in bigger than or equal 400 then this means that the refresh token request failed too so return the result of that request to the original request OnError block.
=> If the status code in the 200..300 range then this means that refresh token request succeeded hence it will retry the original request one more time, if the original request fails again then the failure will go to OnError block as normal.

Notes:

=> It is very important to parse & save the new token after the refresh token request is successful and a new token is returned, so when repeating the original request it will do it with the new token & not with the old one.

The token response is returned at this callback right before repeating the original request.
func tokenDidRefresh (response : String)

=> In case the refresh token request fails then it may that the token is expired so in addition that the failure is redirected to the original request's onError, you also get this failure callback
func didFailedToRefreshToken(), you can use it to notify the user that his session is lost or log him out or anything.

=> It is very important to return the function that do the token request because it is the only way the refreshAuthenticationTokenIfNeeded function knows which request to call in order to do the refresh token.

func getTokenRefreshService() -> Single<Response> {
return apiProvider.rx.request(.doRefreshToken())
}

How can I change the bearer token in Moya

The implementation details of authentication/authorization can be quite different for each API out there. This is the reason why Moya will not handle the auth for you.

That said, implementing your own authentication/authorization can be done in many ways. It will depend on your constraints and/or preferences. As of today, you can find a few solutions sparsely outlined in Moya documentation:

  • Use the PluginType to add your auth to the requests. But think that this can potentially be used to refresh the token if needed. You may also need to intercept the completion of the request to detect authorization errors and apply your preferred recovery scenario (eg. refresh the token and retry the call).
  • Same can be implemented using the endpointClosure and/or requestClosure.
  • You can also consider implementing Alamofire's RequestAdapter and RequestRetrier. Depending on your needs, this can make retries easier. However, on them you will not have straightforward access to your TargetType, so you may need to find a way to recognize the different auth methods needed (ie. your bearer or none).

A few direct references to their documentation:

  • Plugins
  • Endpoints
  • Authentication
  • Alamofire Automatic Validation

Also, I highly encourage anybody to learn/get inspiration from Eilodon's Networking source code.

How to synchronously refresh an access token using Alamofire + RxSwift

I found a solution to my problem using DispatchWorkItem and controlling the entrance on my function with a boolean: isTokenRefreshing. Maybe that's not the most elegant solution, but it works.

So, in my NetworkManager class I added this two new properties:

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

Now in my SingleTrait extension, whenever I enter in the token refresh method I set the boolean isTokenRefreshing to true. So, if it's true, instead of starting another request, I simply throw a RefreshTokenProcessInProgressException and save the current request in my savedRequests array.

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

(Of course, that, if the token refresh succeeds you have to remember to continue all the savedRequests that are saved inside the array, it's not described inside the code down below yet).

Well, my SingleTrait extension is now something like this:

import Foundation
import Moya
import RxSwift
import Domain

public extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response {

private var refreshTokenParameters: TokenParameters {
TokenParameters(clientId: "pdappclient",
grantType: "refresh_token",
refreshToken: KeychainManager.shared.refreshToken)
}

func retryWithToken(target: MultiTarget) -> Single<E> {
return self.catchError { error -> Single<Response> in
if case Moya.MoyaError.statusCode(let response) = error {
if self.isTokenExpiredError(error) {
return Single.error(error)
} else {
return self.parseError(response: response)
}
}
return Single.error(error)
}
.retryToken(target: target)
.catchError { error -> Single<Response> in
if case Moya.MoyaError.statusCode(let response) = error {
return self.parseError(response: response)
}
return Single.error(error)
}
}

private func retryToken(target: MultiTarget) -> Single<E> {
let maxRetries = 1

return self.retryWhen({ error in
error
.enumerated()
.flatMap { (attempt, error) -> Observable<Int> in
if attempt >= maxRetries {
return Observable.error(error)
}
if self.isTokenExpiredError(error) {
return Observable<Int>.just(attempt + 1)
}
return Observable.error(error)
}
.flatMapFirst { _ -> Single<TokenResponse> in
if NetworkManager.shared.isTokenRefreshing {
self.saveRequest {
self.retryToken(target: target)
}
return Single.error(RefreshTokenProcessInProgressException())
} else {
return self.refreshTokenRequest()
}
}
.share()
.asObservable()
})
}

private func refreshTokenRequest() -> Single<TokenResponse> {
NetworkManager.shared.isTokenRefreshing = true

return NetworkManager.shared.fetchData(fromApi: IdentityServerAPI
.token(parameters: self.refreshTokenParameters))
.do(onSuccess: { tokenResponse in
KeychainManager.shared.accessToken = tokenResponse.accessToken
KeychainManager.shared.refreshToken = tokenResponse.refreshToken
}).catchError { error -> Single<TokenResponse> in
return Single.error(InvalidGrantException())
}
}

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

func parseError<E>(response: Response) -> Single<E> {
if response.statusCode == 401 {
// TODO
}

let decoder = JSONDecoder()
if let errors = try? response.map([BaseResponseError].self, atKeyPath: "errors", using: decoder,
failsOnEmptyData: true) {
return Single.error(BaseAPIErrorResponse(errors: errors))
}

return Single.error(APIError2.unknown)
}

func isTokenExpiredError(_ error: Error) -> Bool {
if let moyaError = error as? MoyaError {
switch moyaError {
case .statusCode(let response):
if response.statusCode != 401 {
return false
} else if response.data.count == 0 {
return true
}
default:
break
}
}
return false
}

func filterUnauthorized() -> Single<E> {
flatMap { (response) -> Single<E> in
if 200...299 ~= response.statusCode {
return Single.just(response)
} else if response.statusCode == 404 {
return Single.just(response)
} else {
return Single.error(MoyaError.statusCode(response))
}
}
}

func asRetriableAuthenticated(target: MultiTarget) -> Single<Element> {
filterUnauthorized()
.retryWithToken(target: target)
.filterStatusCode()
}

func filterStatusCode() -> Single<E> {
flatMap { (response) -> Single<E> in
if 200...299 ~= response.statusCode {
return Single.just(response)
} else {
return self.parseError(response: response)
}
}
}
}

In my case, if the token refresh fails, after a N number of retries, I restart the app. And so, whenever a restart the application I'm setting the isTokenRefreshing to false again.

This is the way I found to solve this problem. If you have another approach, please let me know.



Related Topics



Leave a reply



Submit