Skip to content

Instantly share code, notes, and snippets.

@Rspoon3
Last active November 12, 2020 15:07
Show Gist options
  • Select an option

  • Save Rspoon3/65a61a2f31e1ab442ac490427019fbd1 to your computer and use it in GitHub Desktop.

Select an option

Save Rspoon3/65a61a2f31e1ab442ac490427019fbd1 to your computer and use it in GitHub Desktop.
Concurrency Proof Token Refresh Flow In Combine based on https://www.donnywals.com/building-a-concurrency-proof-token-refresh-flow-in-combine/
struct OAuthToken: Codable {
let idToken: String
let refreshToken: String
let expirationTimestamp: Double
var expirationDate: Date{
Date(timeIntervalSince1970: expirationTimestamp / 1000)
}
var isValid: Bool{
return Date() < expirationDate
}
var formattedDate: String{
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy h:mm:ss a"
return formatter.string(from: expirationDate)
}
}
class AuthenticatorManager {
var currentToken: OAuthToken? = PersistenceManager.shared.oAuthToken
private let queue = DispatchQueue(label: "Authenticator.\(UUID().uuidString)")
// this publisher is shared amongst all calls that request a token refresh
private var refreshPublisher: AnyPublisher<OAuthToken, Error>?
func validateAuthorization(for request: URLRequest) -> AnyPublisher<Data, Error> {
validToken()
.flatMap({ [self] token in
// we can now use this token to authenticate the request
authPublisher(for: request)
})
.tryCatch({ [self] error -> AnyPublisher<Data, Error> in
guard let networkError = error as? NetworkError, networkError == .unAuthorized else {
throw error
}
return validToken(forceRefresh: true)
.flatMap({ token in
// we can now use this new token to authenticate the second attempt at making this request
authPublisher(for: request)
})
.eraseToAnyPublisher()
})
.eraseToAnyPublisher()
}
//MARK: - Helpers
private func validToken(forceRefresh: Bool = false) -> AnyPublisher<OAuthToken, Error> {
return queue.sync { [weak self] in
if let publisher = self?.refreshPublisher {
print("scenario 1: we're already loading a new token")
return publisher
}
guard let token = self?.currentToken else {
print("scenario 2: we don't have a token at all, the user should probably log in")
return Fail(error: NetworkError.unAuthorized)
.eraseToAnyPublisher()
}
if token.isValid, !forceRefresh {
print("scenario 3: we already have a valid token and don't want to force a refresh")
return Just(token)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
print("scenario 4: We need a new token")
guard
let components = URLComponents(
string: NetworkManager.shared.baseUrl,
path: "/token/refresh"),
let url = components.url
else {
return Fail(error: NetworkError.invalidURL)
.eraseToAnyPublisher()
}
guard let refreshToken = PersistenceManager.shared.oAuthToken?.refreshToken.data(using: .utf8) else {
fatalError("Should always be a refresh token")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = refreshToken
let publisher = authPublisher(for: request)
.share()
.decode(type: OAuthToken.self, decoder: JSONDecoder())
.handleEvents(receiveOutput: { token in
DispatchQueue.main.async{
PersistenceManager.shared.oAuthToken = token
}
self?.currentToken = token
}, receiveCompletion: { _ in
self?.queue.sync {
self?.refreshPublisher = nil
}
})
.eraseToAnyPublisher()
self?.refreshPublisher = publisher
return publisher
}
}
private func authPublisher(for incomingRequest: URLRequest) -> AnyPublisher<Data, Error> {
var request = incomingRequest
//This was my fix
if let id = currentToken?.idToken{
request.setValue("Bearer \(id)", forHTTPHeaderField: "Authorization")
}
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap() { element -> Data in
if let httpResponse = element.response as? HTTPURLResponse,
httpResponse.statusCode == 401 {
throw NetworkError.unAuthorized
}
return element.data
}
.eraseToAnyPublisher()
}
}
class NetworkManager{
static let shared = NetworkManager()
let staticUrl = "https://ezmaxrequest.interprosoft.com"
let authenticator = AuthenticatorManager()
var baseUrl = PersistenceManager.shared.organizationURL
//MARK: - Helpers
private func tokenRequest(from url: URL) -> URLRequest{
var request = URLRequest(url: url)
request.httpMethod = "GET"
if let idToken = PersistenceManager.shared.oAuthToken?.idToken{
request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization")
}
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
return request
}
func getLocations() -> AnyPublisher<[String], Error>{
guard
let components = URLComponents(string: "\(baseUrl)/mapLocations"),
let url = components.url
else{
return Fail(error: NetworkError.invalidURL)
.eraseToAnyPublisher()
}
let request = tokenRequest(from: url)
return authenticator.validateAuthorization(for: request)
.decode(type: [String].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment