RxSwift renew Authentication Token - swift

Hy I'm trying to come up with a solution using Moya and RxSwift that renews an Authentication token and retries the requests.
The problem is I have multiple requests going on at the same time, so lets say 10 requests fire while the Authentication token has expired, I will try to renew the token on all of them, and as soon as the first one renews the other ones will fail because they use a wrong token to renew.
What I would like to do is just build a queue (maybe) of requests and then retry those. Not sure if this is the best scenario for this.
This is what I have so far:
final class NetworkOnlineProvider {
fileprivate let database = DatabaseClient(database: DatabaseRealm()).database
fileprivate let provider: MoyaProvider<NetworkAPI>
init(endpointClosure: #escaping MoyaProvider<NetworkAPI>.EndpointClosure = MoyaProvider<NetworkAPI>.defaultEndpointMapping,
requestClosure: #escaping MoyaProvider<NetworkAPI>.RequestClosure = MoyaProvider<NetworkAPI>.defaultRequestMapping,
stubClosure: #escaping MoyaProvider<NetworkAPI>.StubClosure = MoyaProvider.neverStub,
manager: Manager = MoyaProvider<NetworkAPI>.defaultAlamofireManager(),
plugins: [PluginType] = [],
trackInflights: Bool = false) {
self.provider = MoyaProvider(endpointClosure: endpointClosure, requestClosure: requestClosure, stubClosure: stubClosure, manager: manager, plugins: plugins, trackInflights: trackInflights)
}
fileprivate func getJWTRenewRequest() -> Single<Response>? {
if let token = JWTManager.sharedInstance.token {
return provider.rx.request(.renew(token: token))
}
return nil
}
func tokenRequest() -> Single<String> {
let errorSingle = Single<String>.create { single in
single(.error(APIError.failure))
return Disposables.create()
}
let emptyJWTSingle = Single<String>.create { single in
single(.success(""))
return Disposables.create()
}
// Return if no token found
guard let appToken = JWTManager.sharedInstance.getJWT() else {
return refreshToken() ?? emptyJWTSingle
}
// If we have a valid token, just return it
if !appToken.hasTokenExpired {
return Single<String>.create { single in
single(.success(appToken.token))
return Disposables.create()
}
}
// Token has expired
let newTokenRequest = refreshToken()
return newTokenRequest ?? errorSingle
}
func refreshToken() -> Single<String>? {
return getJWTRenewRequest()?
.debug("Renewing JWT")
.filterSuccessfulStatusCodes()
.map { (response: Response) -> (token: String, expiration: Double) in
guard let json = try? JSON(data: response.data) else { throw RxError.unknown }
let success = json["success"]
guard
let jwt = success["jwt"].string,
let jwt_expiration = success["jwt_expiration"].double,
let valid_login = success["valid_login"].bool, valid_login
else { throw RxError.unknown }
return (token: jwt, expiration: jwt_expiration)
}
.do(onSuccess: { (token: String, expiration: Double) in
JWTManager.sharedInstance.save(token: JWT(token: token, expiration: String(expiration)))
})
.map { (token: String, expiration: Double) in
return token
}
.catchError { e -> Single<String> in
print("Failed to Renew JWT")
JWTManager.sharedInstance.delete()
UIApplication.shared.appDelegate.cleanPreviousContext(jwt: true)
let loginVC = UIStoryboard(storyboard: .login).instantiateViewController(vc: LoginViewController.self)
UIApplication.shared.appDelegate.window?.setRootViewController(UINavigationController(rootViewController: loginVC))
throw e
}
}
func request(_ target: NetworkAPI) -> Single<Response> {
let actualRequest = provider.rx.request(target)
if target.isAuthenticatedCall {
return tokenRequest().flatMap { _ in
actualRequest
}
}
return actualRequest
}
}

The solution is here: RxSwift and Retrying a Network Request Despite Having an Invalid Token
The key is to use flatMapFirst so you only make one request for the first 401 and ignore other 401s while that request is in flight.
The gist associated with the article includes unit tests proving it works.

Related

Apollo Grapql Refresh Token swift

I'm trying to build network layer using Apollo, and got one problem. I'm using tutorial from Apollo docs to refresh tokens. And they said that I should provide interceptor to handle token expired date. I did it. But I should create network request to refresh token, and this request catch by interceptor too. How I can resolve it?
class NetworkInterceptorProvider: DefaultInterceptorProvider {
override func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [ApolloInterceptor] {
var interceptors = super.interceptors(for: operation)
interceptors.insert(UserManagementInterceptor(), at: 0)
return interceptors
}
My UserManagementInterceptor:
func interceptAsync<Operation: GraphQLOperation>(
chain: RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?,
completion: #escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
) {
guard let token = AuthenticationService.shared.getAccessToken() else {
chain.handleErrorAsync(
UserError.noUserLoggedIn,
request: request,
response: response,
completion: completion
)
AuthenticationService.shared.status = .unauthenticated
print("No access token")
return
}
if AuthenticationService.shared.checkTokenExpired() {
print("Token expired")
guard let refreshToken = AuthenticationService.shared.getRefreshToken() else {
AuthenticationService.shared.status = .unauthenticated
print("No refresh token")
return
}
NetworkManager.shared.apollo.perform(mutation: Network.RefreshTokenMutation(token: refreshToken)) { [weak self] tokenRenewResult in
guard let self else { return }
switch tokenRenewResult {
case .failure:
AuthenticationService.shared.status = .unauthenticated
print("Filed request")
break
case let .success(token):
As result I've got recursion with "Token expired", because my network request RefreshTokenMutation passed through this interceptor too... How I can create exception for token refresh network method?

How to synchronously refresh an access token using Alamofire + RxSwift

I have this generic fetchData() function in my NetworkManager class that is able to request make a authorised request to the network and if it fail (after a number of retries) emits an error that will restart my app (requesting a new login). I need that this retry token be called synchronously, I mean, if multiple requests failed, only one should be requesting the refresh token at once. And if that one fail, and the other one requests must be discarded. I already tried some approached using DispatchGroup / NSRecursiveLock / and also with calling the function cancelRequests describing bellow (in this case, the tasks count is always 0). How can I make this behaviour works in this scenario?
My NetworkManager class:
public func fetchData<Type: Decodable>(fromApi api: TargetType,
decodeFromKeyPath keyPath: String? = nil) -> Single<Response> {
let request = MultiTarget(api)
return provider.rx.request(request)
.asRetriableAuthenticated(target: request)
}
func cancelAllRequests(){
if #available(iOS 9.0, *) {
DefaultAlamofireManager
.sharedManager
.session
.getAllTasks { (tasks) in
tasks.forEach{ $0.cancel() }
}
} else {
DefaultAlamofireManager
.sharedManager
.session
.getTasksWithCompletionHandler { (sessionDataTask, uploadData, downloadData) in
sessionDataTask.forEach { $0.cancel() }
uploadData.forEach { $0.cancel() }
downloadData.forEach { $0.cancel() }
}
}
}
The Single extension that make the retry works:
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> {
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(InvalidGrantException())
}
}
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)
}
.flatMap { _ -> Single<TokenResponse> in
self.refreshTokenRequest()
}
.share()
.asObservable()
})
}
private func refreshTokenRequest() -> Single<TokenResponse> {
return NetworkManager.shared.fetchData(fromApi: IdentityServerAPI
.token(parameters: self.refreshTokenParameters)).do(onSuccess: { tokenResponse in
KeychainManager.shared.accessToken = tokenResponse.accessToken
KeychainManager.shared.refreshToken = tokenResponse.refreshToken
}, onError: { error in
NetworkManager.shared.cancelAllRequests()
})
}
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)
}
}
}
}
Here is an RxSwift solution: RxSwift and Handling Invalid Tokens
Just posting the link isn't the best, so I will post the core of the solution as well:
The key is to make a class that is much like the ActivityMonitor class but handles token refreshing...
public final class TokenAcquisitionService<T> {
/// responds with the current token immediatly and emits a new token whenver a new one is aquired. You can, for example, subscribe to it in order to save the token as it's updated.
public var token: Observable<T> {
return _token.asObservable()
}
public typealias GetToken = (T) -> Observable<(response: HTTPURLResponse, data: Data)>
/// Creates a `TokenAcquisitionService` object that will store the most recent authorization token acquired and will acquire new ones as needed.
///
/// - Parameters:
/// - initialToken: The token the service should start with. Provide a token from storage or an empty string (object represting a missing token) if one has not been aquired yet.
/// - getToken: A function responsable for aquiring new tokens when needed.
/// - extractToken: A function that can extract a token from the data returned by `getToken`.
public init(initialToken: T, getToken: #escaping GetToken, extractToken: #escaping (Data) throws -> T) {
relay
.flatMapFirst { getToken($0) }
.map { (urlResponse) -> T in
guard urlResponse.response.statusCode / 100 == 2 else { throw TokenAcquisitionError.refusedToken(response: urlResponse.response, data: urlResponse.data) }
return try extractToken(urlResponse.data)
}
.startWith(initialToken)
.subscribe(_token)
.disposed(by: disposeBag)
}
/// Allows the token to be set imperativly if necessary.
/// - Parameter token: The new token the service should use. It will immediatly be emitted to any subscribers to the service.
func setToken(_ token: T) {
lock.lock()
_token.onNext(token)
lock.unlock()
}
/// Monitors the source for `.unauthorized` error events and passes all other errors on. When an `.unauthorized` error is seen, `self` will get a new token and emit a signal that it's safe to retry the request.
///
/// - Parameter source: An `Observable` (or like type) that emits errors.
/// - Returns: A trigger that will emit when it's safe to retry the request.
func trackErrors<O: ObservableConvertibleType>(for source: O) -> Observable<Void> where O.Element == Error {
let lock = self.lock
let relay = self.relay
let error = source
.asObservable()
.map { error in
guard (error as? TokenAcquisitionError) == .unauthorized else { throw error }
}
.flatMap { [unowned self] in self.token }
.do(onNext: {
lock.lock()
relay.onNext($0)
lock.unlock()
})
.filter { _ in false }
.map { _ in }
return Observable.merge(token.skip(1).map { _ in }, error)
}
private let _token = ReplaySubject<T>.create(bufferSize: 1)
private let relay = PublishSubject<T>()
private let lock = NSRecursiveLock()
private let disposeBag = DisposeBag()
}
extension ObservableConvertibleType where Element == Error {
/// Monitors self for `.unauthorized` error events and passes all other errors on. When an `.unauthorized` error is seen, the `service` will get a new token and emit a signal that it's safe to retry the request.
///
/// - Parameter service: A `TokenAcquisitionService` object that is being used to store the auth token for the request.
/// - Returns: A trigger that will emit when it's safe to retry the request.
public func renewToken<T>(with service: TokenAcquisitionService<T>) -> Observable<Void> {
return service.trackErrors(for: self)
}
}
Once you put the above in your app, you can just add a .retryWhen { $0.renewToken(with: tokenAcquisitionService) } to the end of your request. Make sure your request emits a ResponseError.unauthorized if the token is unauthorized and the service will handle the retry.
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.

Swift Siesta - How to include asynchronous code into a request chain?

I try to use Siesta decorators to enable a flow where my authToken gets refreshed automatically when a logged in user gets a 401. For authentication I use Firebase.
In the Siesta documentation there is a straight forward example on how to chain Siesta requests, but I couldn't find a way how to get the asynchronous Firebase getIDTokenForcingRefresh:completion: working here. The problem is that Siesta always expects a Request or a RequestChainAction to be returned, which is not possible with the Firebase auth token refresh api.
I understand that the request chaining is primarily done for Siesta-only use cases. But is there a way to use asynchronous third party APIs like FirebaseAuth which don't perfectly fit in the picture?
Here is the code:
init() {
configure("**") {
$0.headers["jwt"] = self.authToken
$0.decorateRequests {
self.refreshTokenOnAuthFailure(request: $1)
}
}
func refreshTokenOnAuthFailure(request: Request) -> Request {
return request.chained {
guard case .failure(let error) = $0.response, // Did request fail…
error.httpStatusCode == 401 else { // …because of expired token?
return .useThisResponse // If not, use the response we got.
}
return .passTo(
self.createAuthToken().chained { // If so, first request a new token, then:
if case .failure = $0.response { // If token request failed…
return .useThisResponse // …report that error.
} else {
return .passTo(request.repeated()) // We have a new token! Repeat the original request.
}
}
)
}
}
//What to do here? This should actually return a Siesta request
func createAuthToken() -> Void {
let currentUser = Auth.auth().currentUser
currentUser?.getIDTokenForcingRefresh(true) { idToken, error in
if let error = error {
// Error
return;
}
self.authToken = idToken
self.invalidateConfiguration()
}
}
Edit:
Based on the suggested answer of Adrian I've tried the solution below. It still does not work as expected:
I use request() .post to send a request
With the solution I get a failure "Request Cancelled" in the callback
After the callback of createUser was called, the original request is sent with the updated jwt token
This new request with the correct jwt token is lost as the callback of createUser is not called for the response -> So onSuccess is never reached in that case.
How do I make sure that the callback of createUser is only called after the original request was sent with the updated jwt token?
Here is my not working solution - happy for any suggestions:
// This ends up with a requestError "Request Cancelled" before the original request is triggered a second time with the refreshed jwt token.
func createUser(user: UserModel, completion: #escaping CompletionHandler) {
do {
let userAsDict = try user.asDictionary()
Api.sharedInstance.users.request(.post, json: userAsDict)
.onSuccess {
data in
if let user: UserModel = data.content as? UserModel {
completion(user, nil)
} else {
completion(nil, "Deserialization Error")
}
}.onFailure {
requestError in
completion(nil, requestError)
}
} catch let error {
completion(nil, nil, "Serialization Error")
}
}
The Api class:
class Api: Service {
static let sharedInstance = Api()
var jsonDecoder = JSONDecoder()
var authToken: String? {
didSet {
// Rerun existing configuration closure using new value
invalidateConfiguration()
// Wipe any cached state if auth token changes
wipeResources()
}
}
init() {
configureJSONDecoder(decoder: jsonDecoder)
super.init(baseURL: Urls.baseUrl.rawValue, standardTransformers:[.text, .image])
SiestaLog.Category.enabled = SiestaLog.Category.all
configure("**") {
$0.expirationTime = 1
$0.headers["bearer-token"] = self.authToken
$0.decorateRequests {
self.refreshTokenOnAuthFailure(request: $1)
}
}
self.configureTransformer("/users") {
try self.jsonDecoder.decode(UserModel.self, from: $0.content)
}
}
var users: Resource { return resource("/users") }
func refreshTokenOnAuthFailure(request: Request) -> Request {
return request.chained {
guard case .failure(let error) = $0.response, // Did request fail…
error.httpStatusCode == 401 else { // …because of expired token?
return .useThisResponse // If not, use the response we got.
}
return .passTo(
self.refreshAuthToken(request: request).chained { // If so, first request a new token, then:
if case .failure = $0.response {
return .useThisResponse // …report that error.
} else {
return .passTo(request.repeated()) // We have a new token! Repeat the original request.
}
}
)
}
}
func refreshAuthToken(request: Request) -> Request {
return Resource.prepareRequest(using: RefreshJwtRequest())
.onSuccess {
self.authToken = $0.text // …make future requests use it
}
}
}
The RequestDelegate:
class RefreshJwtRequest: RequestDelegate {
func startUnderlyingOperation(passingResponseTo completionHandler: RequestCompletionHandler) {
if let currentUser = Auth.auth().currentUser {
currentUser.getIDTokenForcingRefresh(true) { idToken, error in
if let error = error {
let reqError = RequestError(response: nil, content: nil, cause: error, userMessage: nil)
completionHandler.broadcastResponse(ResponseInfo(response: .failure(reqError)))
return;
}
let entity = Entity<Any>(content: idToken ?? "no token", contentType: "text/plain")
completionHandler.broadcastResponse(ResponseInfo(response: .success(entity))) }
} else {
let authError = RequestError(response: nil, content: nil, cause: AuthError.NOT_LOGGED_IN_ERROR, userMessage: "You are not logged in. Please login and try again.".localized())
completionHandler.broadcastResponse(ResponseInfo(response: .failure(authError)))
}
}
func cancelUnderlyingOperation() {}
func repeated() -> RequestDelegate { RefreshJwtRequest() }
private(set) var requestDescription: String = "CustomSiestaRequest"
}
First off, you should rephrase the main thrust of your question so it's not Firebase-specific, along the lines of "How do I do request chaining with some arbitrary asynchronous code instead of a request?". It will be much more useful to the community that way. Then you can mention that Firebase auth is your specific use case. I'm going to answer your question accordingly.
(Edit: Having answered this question, I now see that Paul had already answered it here: How to decorate Siesta request with an asynchronous task)
Siesta's RequestDelegate does what you're looking for. To quote the docs: "This is useful for taking things that are not standard network requests, and wrapping them so they look to Siesta as if they are. To create a custom request, pass your delegate to Resource.prepareRequest(using:)."
You might use something like this as a rough starting point - it runs a closure (the auth call in your case) that either succeeds with no output or returns an error. Depending on use, you might adapt it to populate the entity with actual content.
// todo better name
class SiestaPseudoRequest: RequestDelegate {
private let op: (#escaping (Error?) -> Void) -> Void
init(op: #escaping (#escaping (Error?) -> Void) -> Void) {
self.op = op
}
func startUnderlyingOperation(passingResponseTo completionHandler: RequestCompletionHandler) {
op {
if let error = $0 {
// todo better
let reqError = RequestError(response: nil, content: nil, cause: error, userMessage: nil)
completionHandler.broadcastResponse(ResponseInfo(response: .failure(reqError)))
}
else {
// todo you might well produce output at this point
let ent = Entity<Any>(content: "", contentType: "text/plain")
completionHandler.broadcastResponse(ResponseInfo(response: .success(ent)))
}
}
}
func cancelUnderlyingOperation() {}
func repeated() -> RequestDelegate { SiestaPseudoRequest(op: op) }
// todo better
private(set) var requestDescription: String = "SiestaPseudoRequest"
}
One catch I found with this is that response transformers aren't run for such "requests" - the transformer pipeline is specific to Siesta's NetworkRequest. (This took me by surprise and I'm not sure that I like it, but Siesta seems to be generally full of good decisions, so I'm mostly taking it on faith that there's a good reason for it.)
It might be worth watching out for other non request-like behaviour.

Handling 401 status w/ RxSwift & URLSession

I currently have a network client that looks like the below:
class Client<R: ResourceType> {
let engine: ClientEngineType
var session: URLSession
init(engine: ClientEngineType = ClientEngine()) {
self.engine = engine
self.session = URLSession.shared
}
func request<T: Codable>(_ resource: R) -> Single<T> {
let request = URLRequest(resource: resource)
return Single<T>.create { [weak self] single in
guard let self = self else { return Disposables.create() }
let response = self.session.rx.response(request: request)
return response.subscribe(
onNext: { response, data in
if let error = self.error(from: response) {
single(.error(error))
return
}
do {
let decoder = JSONDecoder()
let value = try decoder.decode(T.self, from: data)
single(.success(value))
} catch let error {
single(.error(error))
}
},
onError: { error in
single(.error(error))
})
}
}
struct StatusCodeError: LocalizedError {
let code: Int
var errorDescription: String? {
return "An error occurred communicating with the server. Please try again."
}
}
private func error(from response: URLResponse?) -> Error? {
guard let response = response as? HTTPURLResponse else { return nil }
let statusCode = response.statusCode
if 200..<300 ~= statusCode {
return nil
} else {
return StatusCodeError(code: statusCode)
}
}
}
Which I can then invoke something like
let client = Client<MyRoutes>()
client.request(.companyProps(params: ["collections": "settings"]))
.map { props -> CompanyModel in return props }
.subscribe(onSuccess: { props in
// do something with props
}) { error in
print(error.localizedDescription)
}.disposed(by: disposeBag)
I'd like to start handling 401 responses and refreshing my token and retrying the request.
I'm struggling to find a nice way to do this.
I found this excellent gist that outlines a way to achieve this, however I am struggling to implement this in my current client.
Any tips or pointers would be very much appreciated.
That's my gist! (Thanks for calling it excellent.) Did you see the article that went with it? https://medium.com/#danielt1263/retrying-a-network-request-despite-having-an-invalid-token-b8b89340d29
There are two key elements in handling 401 retries. First is that you need a way to insert tokens into your requests and start your request pipeline with Observable.deferred { tokenAcquisitionService.token.take(1) }. In your case, that means you need a URLRequest.init that will accept a Resource and a token, not just a resource.
The second is to throw a TokenAcquisitionError.unauthorized error when you get a 401 and end your request pipeline with .retryWhen { $0.renewToken(with: tokenAcquisitionService) }
So, given what you have above, in order to handle token retries all you need to do is bring my TokenAcquisitionService into your project and use this:
func getToken(_ oldToken: Token) -> Observable<(response: HTTPURLResponse, data: Data)> {
fatalError("this function needs to be able to request a new token from the server. It has access to the old token if it needs that to request the new one.")
}
func extractToken(_ data: Data) -> Token {
fatalError("This function needs to be able to extract the new token using the data returned from the previous function.")
}
let tokenAcquisitionService = TokenAcquisitionService<Token>(initialToken: Token(), getToken: getToken, extractToken: extractToken)
final class Client<R> where R: ResourceType {
let session: URLSession
init(session: URLSession = URLSession.shared) {
self.session = session
}
func request<T>(_ resource: R) -> Single<T> where T: Decodable {
return Observable.deferred { tokenAcquisitionService.token.take(1) }
.map { token in URLRequest(resource: resource, token: token) }
.flatMapLatest { [session] request in session.rx.response(request: request) }
.do(onNext: { response, _ in
if response.statusCode == 401 {
throw TokenAcquisitionError.unauthorized
}
})
.map { (_, data) -> T in
return try JSONDecoder().decode(T.self, from: data)
}
.retryWhen { $0.renewToken(with: tokenAcquisitionService) }
.asSingle()
}
}
Note, it could be the case that the getToken function has to, for example, present a view controller that asks for the user's credentials. That means you need to present your login view controller (or a UIAlertController) to gather the data. Or maybe you get both an authorization token and a refresh token from your server when you login. In that case the TokenAcquisitionService should hold on to both of them (i.e., its T should be a (token: String, refresh: String). Either is fine.
The only problem with the service is that if acquiring the new token fails, the entire service shuts down. I haven't fixed that yet.

Refresh token and retry the request using RxSwift

I receive from my backend a accessToken and a refreshToken. When I receive a respons with statusCode 401 I need to replace the accessToken with the refreshToken (accessToken = refreshToken) and try the request again. If the request fail for the third time I need to request the user to log in again. I'm trying to apply this logic using the code described bellow, but it isn't working. The request is not being called again. How can I do this?
// example of usage:
public func login(with parameters: LoginParameters) -> Single<User> {
return provider.rx.request(MultiTarget(UserTarget.login(parameters))).handleResponse(User.self, using: jsonDecoder)
}
func handleResponse<D: Decodable>(_ type: D.Type, atKeyPath keyPath: String? = nil, using decoder: JSONDecoder = JSONDecoder(), failsOnEmptyData: Bool = true) -> Single<D> {
return self.flatMap({ (response) -> Single<D> in
switch response.statusCode {
case 200...299:
RequestsFailedCount.count = 0
return Single.just(try response.map(type, atKeyPath: keyPath, using: decoder, failsOnEmptyData: failsOnEmptyData))
case 500:
return Single.error(try response.map(ResponseError.self))
default:
if response.statusCode == 401, let urlResponse = response.response {
guard RequestsFailedCount.count <= 3 else {
RequestsFailedCount.count = 0
return Single.error(InvalidChallengeError())
}
RequestsFailedCount.count += 1
if let challenge = urlResponse.allHeaderFields[HeaderType.authenticate.rawValue] as? String, challenge.contains(self.challenge) {
var keychain = KeychainManager()
keychain.accessToken = keychain.refreshToken
return self.retry(1).map(type, atKeyPath: keyPath, using: decoder, failsOnEmptyData: failsOnEmptyData)
} else {
return Single.error(try response.map(ResponseError.self))
}
} else {
return Single.error(try response.map(ResponseError.self))
}
}
})
}
Here's an article with a link to code that should help you out: https://medium.com/#danielt1263/retrying-a-network-request-despite-having-an-invalid-token-b8b89340d29
The upshot is that you need to create a token acquisition service that holds onto the current token and is interposed into the network call chain to update the token when necessary...
It would be used like this:
func getData<T>(response: #escaping Response, tokenAcquisitionService: TokenAcquisitionService<T>, request: #escaping (T) throws -> URLRequest) -> Observable<(response: HTTPURLResponse, data: Data)> {
return Observable
.deferred { tokenAcquisitionService.token.take(1) } // gets the current token
.map { try request($0) } // creates the request using the current token
.flatMap { response($0) } // gets response from server
.map { response in
guard response.response.statusCode != 401 else { throw TokenAcquisitionError.unauthorized }
return response
}
.retryWhen { $0.renewToken(with: tokenAcquisitionService) }
}
The tokenAcquisitionService picks up .unauthorized errors and attempts to get a new token from the server. If it's successful, it will retry the chain.
Note that this service is designed to work properly even if you have multiple requests in flight at the same time, and it includes a test harness proving that it works.