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"
}