Moya: Creating a PluginType to handle request reauthentication

Created on 27 Oct 2016  ยท  27Comments  ยท  Source: Moya/Moya

I'm using Moya to talk to an API that may return HTTP 401 if a requests needs to be authenticated. Unfortunately, there's no way to know at the client if the session has expired or not, and the only way to know that is to make a request to an endpoint which requires authentication and checking the status code.

What I want to do is to make all requests to the authenticated endpoints automatically check the status code of the response, reauthenticate if needed and retry the request with the refreshed session. This way, my client doesn't need to know the specifics of the API and can just make simple requests without worrying about authentication. From what I understand, creating a PluginType would be the way to go. However, I'm not sure on how to replace the whole request on didReceiveResponse.

Is there a way to achieve this with the PluginType or am I completely on the wrong track here?

chore documentation question

Most helpful comment

@rlam3 Yep!
The code looks like that:

// (Endpoint<Target>, NSURLRequest -> Void) -> Void
static func endpointResolver<T>() -> MoyaProvider<T>.RequestClosure where T: TargetType {
    return { (endpoint, closure) in
        let request = endpoint.urlRequest!
        request.httpShouldHandleCookies = false

        if (tokenIsOK) {
            // Token is valid, so just resume the request and let AccessTokenPlugin set the Authentication header
            closure(.success(request))
            return
        }
        // authenticationProvider is a MoyaProvider<Authentication> for example
        authenticationProvider.request(.refreshToken(params)) { result in
            switch result {
                case .success(let response):
                    self.token = response.mapJSON()["token"]
                    closure(.success(request)) // This line will "resume" the actual request, and then you can use AccessTokenPlugin to set the Authentication header
                case .failure(let error):
                    closure(.failure(error)) //something went terrible wrong! Request will not be performed
            }
        }
    }
}

All 27 comments

Plugins probably aren't the best way to approach this, I'd suggest subclassing either the RxMoyaProvider or ReactiveMoyaProvider and use RxSwift or ReactiveCocoa to retry on failures. There is no straightforward approach, I'm afraid. Even if you don't use RxSwift or ReactiveCocoa, I would still recommend subclassing and putting the necessary logic in the request function.

I'm trying a naive implementation for this:

public typealias AuthenticationBlock = (_ done: () -> Void) -> Void

public enum Error: Swift.Error {
    case missingAuthenticationBlock
    case invalidCredentials
}

public class RxAuthenticatedMoyaProvider<Target>: RxMoyaProvider<Target> where Target: Moya.TargetType {

    private let disposeBag = DisposeBag()

    public var authenticationBlock: AuthenticationBlock?

    public init(endpointClosure: @escaping MoyaProvider<Target>.EndpointClosure = MoyaProvider.DefaultEndpointMapping,
                requestClosure: @escaping MoyaProvider<Target>.RequestClosure = MoyaProvider.DefaultRequestMapping,
                stubClosure: @escaping MoyaProvider<Target>.StubClosure = MoyaProvider.NeverStub,
                manager: Manager = Alamofire.SessionManager.default,
                plugins: [PluginType] = []) {

        super.init(endpointClosure: endpointClosure, requestClosure: requestClosure, stubClosure: stubClosure, manager: manager, plugins: plugins)
    }

    public override func request(_ token: Target) -> Observable<Response> {
        return _request(token)
    }

    private func _request(_ token: Target, isSecondTryAfterAuth: Bool = false) -> Observable<Response> {
        return super.request(token)
            .flatMap { [unowned self] response -> Observable<Response> in
                if response.statusCode == 401 || response.statusCode == 403 { // We need to authenticate

                    if isSecondTryAfterAuth { // Server is still asking for authentication. Give up
                        return Observable<Response>.error(Error.invalidCredentials)
                    }

                    guard let authenticationBlock = self.authenticationBlock else {
                        throw Error.missingAuthenticationBlock
                    }

                    return Observable.create { observer in
                        authenticationBlock {
                            self._request(token, isSecondTryAfterAuth: true)
                                .subscribe { event in
                                    observer.on(event)
                                }
                                .addDisposableTo(self.disposeBag)
                        }

                        return Disposables.create()
                    }


                } else {
                    return Observable.just(response)
                }
        }
    }

}

I'm gonna document here the results for anyone who might stumble upon this in the future.
PS: @ashfurrow Am I on the right track here?

_Edited your comment to enable code highlighting_ :wink:

This is great, thanks @raphaelcruzeiro! Except for basic HTTP auth, Moya has remained largely agnostic about authentication. Do we think this should go in the library itself or in the documentation?

@ashfurrow I think it would be nice to add a FAQ (or maybe a cookbook). This would definitely make it easier for people that are considering using Moya to see what it can do and how they might make it fit their requirements.

Cool do you think this could go into our existing Examples section? Or do you think it should be separate?

Hello
I begin with Moya and RxSwift, i have a same problem like you, i want to know where i can add /make my request for refresh token? And i don't understand the callback AuthenticationBlock.
And i want to know something, how i can change de header autorisation for the second try after auth
Thanks guy's

So guys, I made a simple wrapper around the TVDB API using Moya. The TVDB api uses JWT token, and the token expires after 24 hours.

I implemented the authentication/refresh logic using the requestClosure from MoyaProvider and AccessTokenPlugin, already provided by Moya.

I don't know if it's the best way to do this. I only did it in the requestClosure because it's asynchronous, if Alamofire allowed us to do synchronous requests, then I think that it's possible to implement this using Moya Plugins, very likely to OkHTTP interceptors

You can see the code here

@pietbrauer if it is async, what happens when the request is fired before the token is refreshed? Shouldn't this be a synchronous process where the token refresh must take place first? Thanks!

@pietrocaselani interesting approach โ€“ Moya now supports authentication plugins, including sync/async methods. The docs are here: https://github.com/Moya/Moya/blob/master/docs/Authentication.md Let us know if there's anything we can clarify!

@rlam3 The link that @ashfurrow just posted it is exactly how I implemented. https://github.com/Moya/Moya/blob/master/docs/Authentication.md#oauth

If the token is invalid/absent, I request the token, and then, on the completion closure from the token request I call the requestClosure. This way, the requests keep awaiting until there is a valid token.

@ashfurrow thanks for that link! I was aware of AccessTokenPlugin, it's a great plugin ๐Ÿ˜
But I had never seen the part about OAuth

"Signing" a network request with OAuth can itself sometimes require network requests be performed first, so signing a request for Moya is an asynchronous process.

It's exactly what I did ๐Ÿ™Œ

@pietrocaselani

You mean to handle the token in this requestClosure?
Reference: https://github.com/artsy/eidolon/blob/master/Kiosk/App/Networking/Networking.swift

// (Endpoint<Target>, NSURLRequest -> Void) -> Void
    static func endpointResolver<T>() -> MoyaProvider<T>.RequestClosure where T: TargetType {
        return { (endpoint, closure) in
            var request = endpoint.urlRequest!
            request.httpShouldHandleCookies = false
            closure(.success(request))
        }
    }

@rlam3 Yep!
The code looks like that:

// (Endpoint<Target>, NSURLRequest -> Void) -> Void
static func endpointResolver<T>() -> MoyaProvider<T>.RequestClosure where T: TargetType {
    return { (endpoint, closure) in
        let request = endpoint.urlRequest!
        request.httpShouldHandleCookies = false

        if (tokenIsOK) {
            // Token is valid, so just resume the request and let AccessTokenPlugin set the Authentication header
            closure(.success(request))
            return
        }
        // authenticationProvider is a MoyaProvider<Authentication> for example
        authenticationProvider.request(.refreshToken(params)) { result in
            switch result {
                case .success(let response):
                    self.token = response.mapJSON()["token"]
                    closure(.success(request)) // This line will "resume" the actual request, and then you can use AccessTokenPlugin to set the Authentication header
                case .failure(let error):
                    closure(.failure(error)) //something went terrible wrong! Request will not be performed
            }
        }
    }
}

@pietrocaselani Thanks! Which version of Moya are you using? It seems like I'm getting a bunch of errors when handling the request.

screen shot 2017-11-16 at 12 25 59 pm

@rlam3 I am using Moya 10.
Sorry, I actually didn't run the previously code. But I think that the endpoint.urlRequest returns an Optional or throws some error, so you will need to use guard or try?, something like that.

You can look at my real implementation here

Hmm... I'm definitely doing something wrong here. Refesh seems to be looping back on itself... Any ideas on how to get out of my loop? Thanks!

//
//  Networking.swift
//  moyaJWTLogin
//
//

import Foundation
import Moya
import Result
import RxSwift
import JWTDecode

class OnlineProvider<Target>: MoyaProvider<Target> where Target: TargetType {

    fileprivate let online: Observable<Bool>
    fileprivate let provider: MoyaProvider<Target>

    init(
        endpointClosure: @escaping MoyaProvider<Target>.EndpointClosure = MoyaProvider.defaultEndpointMapping,
        requestClosure: @escaping MoyaProvider<Target>.RequestClosure = MoyaProvider.defaultRequestMapping,
        stubClosure: @escaping MoyaProvider<Target>.StubClosure = MoyaProvider.neverStub,
        manager: Manager = MoyaProvider<Target>.defaultAlamofireManager(),
        plugins: [PluginType] = [],
        online: Observable<Bool> = connectedToInternetOrStubbing()) {

        self.online = online
        self.provider = MoyaProvider(endpointClosure: endpointClosure, requestClosure: requestClosure, stubClosure: stubClosure, manager: manager, plugins: plugins)

        super.init(endpointClosure: endpointClosure, requestClosure: requestClosure, stubClosure: stubClosure, manager: manager, plugins: plugins)
    }

    func request(_ token: Target) -> Observable<Moya.Response> {
        let actualRequest = provider.rx.request(token)
//        let actualRequest = self.request(token)
        return online
            //            .ignore(value: false)  // Wait until we're online
            .take(1)        // Take 1 to make sure we only invoke the API once.
            .flatMap { _ in // Turn the online state into a network request
                return actualRequest
        }
    }

}



protocol NetworkingType {
    associatedtype T: TargetType
    var provider: OnlineProvider<T> { get }
}

struct Networking: NetworkingType {

    typealias T = JWTAPI
    var provider: OnlineProvider<JWTAPI>

}


extension NetworkingType {

    func smartTokenClosure(_ token: JWTAPI) -> String {
        switch token {
        case .authenticateUser:
            return ""
        default:
            return AuthUser.get(.access_token) as! String
        }
    }

}

// Static methods
extension NetworkingType {

    static var plugins: [PluginType] {

//        let authPlugin = AccessTokenPlugin(tokenClosure: smartTokenClosure(self.T) )
        let authPlugin = AccessTokenPlugin(tokenClosure: AuthUser.get(.access_token) as! String)

        return [
            NetworkLoggerPlugin(verbose:true),
            authPlugin
        ]
    }

    static var refreshTokenPlugins: [PluginType] {
        let authPlugin = AccessTokenPlugin(tokenClosure: AuthUser.get(.refresh_token) as! String)

        return [
            NetworkLoggerPlugin(verbose:true),
            authPlugin
        ]

    }

    // (Endpoint<Target>, NSURLRequest -> Void) -> Void
    static func endpointResolver<T>() -> MoyaProvider<T>.RequestClosure where T: TargetType {
        return { (endpoint, closure) in

            var request = try! endpoint.urlRequest()
            request.httpShouldHandleCookies = false
            let disposeBag = DisposeBag()

            let s_jwt = SmartAccessToken()
            if s_jwt.isExpiredOrExpiringSoon{
                let authProvider = Networking.refreshTokenDefaultNetworking()
                authProvider.request(.refreshAccessToken())
                    .filterSuccessfulStatusCodes()
                    .map(to: UserAuthenticationTokens.self)
                    .subscribe{ event in
                        switch event{
                        case .next(let object):
                            AuthUser.save([
                                .access_token : object.access_token,
                                .refresh_token: object.refresh_token
                            ])
                            closure(.success(request))
                        case .error(let error):
                            print("\(error.localizedDescription)")
//                            closure(.failure(error))
                        default: break
                        }

                    }.disposed(by: disposeBag)
            }else{
                closure(.success(request))
                return
            }
        }
    }

    static func unauthenticatedDefaultNetworking() -> Networking {

        print("Entering.... Unauth Default Networking")
        return Networking(provider: OnlineProvider<JWTAPI>())
    }

    static func refreshTokenDefaultNetworking() -> Networking {

        print("Entering.... refreshTokenDefaultNetworking")
        return Networking(provider: OnlineProvider<JWTAPI>(
//            plugins: refreshTokenPlugins
            requestClosure: self.endpointResolver()
        ))
    }

    // FIXME: During production... Network Logger should be turned off?

    static func newDefaultNetworking() -> Networking {
        print("Entering.... New Default Networking")
        return Networking(provider: OnlineProvider<JWTAPI>(
            requestClosure: self.endpointResolver(),
            plugins: self.plugins
        ))
    }

//    static func newStubbingNetworking() -> Networking {
//        return Networking(provider: OnlineProvider<JWTAPI>(
//            endpointClosure: MoyaProvider.defaultEndpointMapping,
//            stubClosure: MoyaProvider<JWTAPI>.immediatelyStub,
//            plugins:[NetworkLoggerPlugin(verbose:true)],
//            online: .just(false)))
//    }


    static func APIKeysBasedStubBehaviour<T>(_: T) -> Moya.StubBehavior {
        return .immediate
    }
}


fileprivate extension Networking{

    func RequiresAuthenticationRequest() -> Observable<String> {

        let njwt_string = AuthUser.get(.access_token) as! String

//        guard let jwt: JWT = try! decode(jwt: njwt_string) else {
//
//        }

        return .just(njwt_string)

//        return .just(AuthUser.get(.access_token))

//
//        // If access token is valid
//        if AuthManager.shared.expiredAccessToken == false{
//            return .just(jwt)
//        }else{
//
//            // Refresh access token
//            return request(.refreshAccessToken())
//                .filterSuccessfulStatusCodes()
//                .map(RefrehedAccessToken.self)
//                .do(onNext: {
//                    print("Saved new access token")
//                    $0.save()
//                }).map{ (token) -> String in
//                    // Get new access token that was just saved
//                    return AuthManager.shared.accessToken!
//            }
//        }

    }
}



// "Public" interfaces
extension Networking {

    func request(_ token: JWTAPI) -> Observable<Moya.Response> {
        let actualRequest = provider.rx.request(token)
        return actualRequest.asObservable()
//        return flatMap{ _ in actualRequest }
//        return self.RequiresAuthenticationRequest().flatMap{ _ in actualRequest}

    }

}



//
//private func newProvider<T>(plugins: [PluginType]) -> OnlineProvider<T> {
//    return OnlineProvider(plugins: plugins)
//}


https://github.com/rlam3/moyajwtlogin

I have also included a repo for this. Please have a look and let me know how I can make authentication and refresh work better.

I know there are literally so many ways to approach this and it seems like I have been trying to tackle this at so many different angles. Would really appreciate if you could give me some insight as to how to make this better. Thanks!

Authentication works fine. It is only the refreshing token which loops back on itself indefinitely...

@rlam3 probably the the requestClosure is been executed for the refresh token request too.
You need to skip the authentication/refresh logic when you are making the request to refresh the token, otherwise will enter in a loop.

Moya has changed much, I can't now apply those code above, anyone can help pls ^^

This issue seems to have been forgotten... sorry about that!

@ducbm051291 do you still need some help?
This problem appear again here and we talked about it here too

Maybe should we close this in favor of other issues? If we search for refresh on Moya's issues, there are a lot of related issues, about refreshing token, retrying request if auth fails and some like that.

I think that Alamofire's SessionManager has the elegant way to implement refreshing token and retrying request. And since MoyaProvider get SessionManager's instance on init we can use that approach with Moya
https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#adapting-and-retrying-requests

@pietrocaselani

I'm trying to follow your solution, but I'm confused as to where to implement it.

Here is my Moya provider class:

import Foundation
import Moya

enum ApiService {
    case signIn(email: String, password: String)
    case like(id: Int, type: String)
}

extension ApiService: TargetType, AccessTokenAuthorizable {
    var authorizationType: AuthorizationType {
        switch self {
        case .signIn(_, _):
            return .basic
        case .like(_, _):
            return .bearer
        }
    }

    var baseURL: URL {
        return URL(string: Constants.apiUrl)!
    }

    var path: String {
        switch self {
            case .signIn(_, _):
                return "user/signin"
            case .like(_, _):
                return "message/like"
        }
    }

    var method: Moya.Method {
        switch self {
            case .signIn, .like:
                return .post
        }
    }

    var task: Task {
        switch self {
            case let .signIn(email, password):
                return .requestParameters(parameters: ["email": email, "password": password], encoding: JSONEncoding.default)
            case let .like(id, type):
                return .requestParameters(parameters: ["messageId": id, "type": type], encoding: JSONEncoding.default)
        }
    }

    var sampleData: Data {
        return Data()
    }

    var headers: [String: String]? {
        return ["Content-type": "application/json"]
    }
}

private extension String {
    var urlEscaped: String {
        return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
    }

    var utf8Encoded: Data {
        return data(using: .utf8)!
    }
}

Where does your code, below, fit into my code?

// (Endpoint<Target>, NSURLRequest -> Void) -> Void
static func endpointResolver<T>() -> MoyaProvider<T>.RequestClosure where T: TargetType {
    return { (endpoint, closure) in
        let request = endpoint.urlRequest!
        request.httpShouldHandleCookies = false

        if (tokenIsOK) {
            // Token is valid, so just resume the request and let AccessTokenPlugin set the Authentication header
            closure(.success(request))
            return
        }
        // authenticationProvider is a MoyaProvider<Authentication> for example
        authenticationProvider.request(.refreshToken(params)) { result in
            switch result {
                case .success(let response):
                    self.token = response.mapJSON()["token"]
                    closure(.success(request)) // This line will "resume" the actual request, and then you can use AccessTokenPlugin to set the Authentication header
                case .failure(let error):
                    closure(.failure(error)) //something went terrible wrong! Request will not be performed
            }
        }
    }
}

Link to my StackOverflow question:

https://stackoverflow.com/questions/54936080/refreshing-auth-token-with-moya

@user6724161 Hey!

The function endpointResolver() returns a RequestClosure that you can then pass on to your provider:

let provider = MoyaProvider<YourTarget>(requestClosure: endpointPointResolver())

More details in our docs

@pedrovereza

Thanks for the quick reply! Two questions.

(1) I call let provider = MoyaProvider<ApiService>() many times, at the top of each controller I use it in. Is this how I should be using it?

And (2), if I call the above line multiple times in my code, as I am, how can I use @pietrocaselani code without having to write the func endpointResolver function in each of the controllers I call the provider?

I'm all a bit confused as I'm new to all of this, so please bear with me.

@user6724161

You could encapsulate all providers and token logic in your API Client class, like this:

public final class MoviesAPIClient {
    public lazy var movies: MoyaProvider<Movies> = createProvider(forTarget: Movies.self)
    public lazy var authentication: MoyaProvider<Authentication> = createProvider(forTarget: Authentication.self)

    private var token: String = ""

    private func isTokenValid() -> Bool {
        // check expiration date
        return true
    }

    func createProvider<T: TargetType>(forTarget target: T.Type) -> MoyaProvider<T> {
        let endpointClosure = createEndpointClosure(forTarget: target)
        let requestClosure = createRequestClosure(forTarget: target)

        return MoyaProvider<T>(requestClosure: requestClosure)
    }

    private func createRequestClosure<T: TargetType>(forTarget target: T.Type) -> MoyaProvider<T>.RequestClosure {
        let requestClosure = { [unowned self] (endpoint: Endpoint, done: @escaping MoyaProvider.RequestResultClosure) in
            guard let request = try? endpoint.urlRequest() else {
                done(.failure(MoyaError.requestMapping(endpoint.url)))
                return
            }

            if (self.isTokenValid) {
                done(.success(request))
                return
            }

            let target = Authentication.accessToken(code: self.oauthCode,
                                   clientId: self.clientId,
                                   clientSecret: self.clientSecret,
                                   redirectURL: self.redirectURL,
                                    grantType: "authorization_code")

            self.authentication.request(target) { result in
                switch result {
                    case .success(let response):
                        self.token = response.mapJSON()["token"]
                        done(.success(request))
                    case .failure(let error):
                        done(.failure(error)
                    }
                }
            }
        }

        return requestClosure
    }
}

This is a pseudo version from this code

@pedrovereza

There is more issues related to this, and this one it's getting preety old... should we maybe close this, and redirect the conversation to a central place?

@pietrocaselani good call out, the conversation is indeed moving further from the original issue ๐Ÿ‘

I'll close this issue and people that still have questions (related or not to authentication), feel free to open a new issue ๐Ÿ˜‰

Was this page helpful?
0 / 5 - 0 ratings