Last active
November 12, 2020 15:07
-
-
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/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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