iOS/Sources/Shared/API/Authentication/TokenManager.swift

237 lines
8.6 KiB
Swift

import Alamofire
import Foundation
import PromiseKit
public class TokenManager {
public enum TokenError: Error {
case tokenUnavailable
case expired
case connectionFailed
}
public let server: Server
private var authenticationAPI: AuthenticationAPI
private class RefreshPromiseCache {
// we can be asked to refresh from any queue - alamofire's utility queue, webview's main queue, so guard
// accessing the underlying promise here without being on the queue is programmer error
let queue: DispatchQueue
private let queueSpecific = DispatchSpecificKey<Bool>()
init() {
self.queue = DispatchQueue(label: "refresh-promise-cache-mutex", qos: .userInitiated)
queue.setSpecific(key: queueSpecific, value: true)
}
private var underlyingPromise: Promise<TokenInfo>?
var promise: Promise<TokenInfo>? {
get {
assert(DispatchQueue.getSpecific(key: queueSpecific) == true)
return underlyingPromise
}
set {
assert(DispatchQueue.getSpecific(key: queueSpecific) == true)
underlyingPromise = newValue
}
}
}
private let refreshPromiseCache = RefreshPromiseCache()
public init(server: Server) {
self.authenticationAPI = AuthenticationAPI(server: server)
self.server = server
}
/// After authenticating with the server and getting a code, call this method to exchange the code for
/// an auth token.
/// - Parameter code: Code acquired by authenticating with an authenticaiton provider.
public static func initialToken(
code: String,
connectionInfo: inout ConnectionInfo
) -> Promise<TokenInfo> {
guard let url = connectionInfo.activeURL() else {
return Promise { seal in
seal.reject(ServerConnectionError.noActiveURL)
}
}
return AuthenticationAPI.fetchToken(
authorizationCode: code,
baseURL: url,
exceptions: connectionInfo.securityExceptions
)
}
// Request the server revokes the current token.
public func revokeToken() -> Promise<Bool> {
authenticationAPI.revokeToken(tokenInfo: server.info.token)
}
public var bearerToken: Promise<(String, Date)> {
firstly {
self.currentToken
}.recover { [self] error -> Promise<(String, Date)> in
guard let tokenError = error as? TokenError, tokenError == TokenError.expired else {
Current.Log.verbose("Unable to recover from token error! \(error)")
throw error
}
return refreshToken().map {
Current.Log.info("providing token \($0.accessToken.hash)")
return ($0.accessToken, $0.expiration)
}
}
}
public func authDictionaryForWebView(forceRefresh: Bool) -> Promise<[String: Any]> {
firstly { () -> Promise<(String, Date)> in
if forceRefresh {
Current.Log.info("forcing a refresh of token")
return refreshToken().map { ($0.accessToken, $0.expiration) }
} else {
Current.Log.info("using existing token")
return bearerToken
}
}.map { token, expiration -> [String: Any] in
Current.Log.info("creating webview token with \(token.hash)")
var dictionary: [String: Any] = [:]
dictionary["access_token"] = token
dictionary["expires_in"] = Int(expiration.timeIntervalSince(Current.date()))
return dictionary
}
}
// MARK: - Private helpers
private var currentToken: Promise<(String, Date)> {
Promise<(String, Date)> { seal in
let tokenInfo = server.info.token
// Add a margin to -10 seconds so that we never get into a state where we return a token
// that immediately fails.
if tokenInfo.expiration.addingTimeInterval(-10) > Current.date() {
seal.fulfill((tokenInfo.accessToken, tokenInfo.expiration))
} else {
if let expirationAmount = Calendar.current.dateComponents(
[.second],
from: tokenInfo.expiration,
to: Current.date()
).second {
Current.Log.error("Token \(tokenInfo.accessToken.hash) is expired by \(expirationAmount) seconds")
} else {
Current.Log.error("Token \(tokenInfo.accessToken.hash) is expired by unknown")
}
seal.reject(TokenError.expired)
}
}
}
private func refreshToken() -> Promise<TokenInfo> {
refreshPromiseCache.queue.sync { [self, server] in
let tokenInfo = server.info.token
if let refreshPromise = refreshPromiseCache.promise {
Current.Log.info("using cached refreshToken promise")
return refreshPromise
}
let promise: Promise<TokenInfo> = firstly {
authenticationAPI.refreshTokenWith(tokenInfo: tokenInfo)
}.get { [server] tokenInfo in
Current.Log.info("storing refresh token")
server.info.token = tokenInfo
}.ensure(on: refreshPromiseCache.queue) { [self] in
Current.Log.info("reset cached refreshToken promise")
refreshPromiseCache.promise = nil
}.tap { [server] result in
switch result {
case let .rejected(error):
Current.Log.error("refresh token got error: \(error)")
if let underlying = (error as? AFError)?.underlyingError as? AuthenticationAPI.AuthenticationError,
case .serverError(400 ... 403, _, _) = underlying {
/// Server rejected the refresh token. All is lost.
let event = ClientEvent(
text: "Refresh token is invalid, notifying user",
type: .networkRequest,
payload: [
"error": String(describing: underlying),
]
)
Current.clientEventStore.addEvent(event).cauterize()
Current.modelManager.unsubscribe()
Current.onboardingObservation.needed(.unauthenticated(
server.identifier.rawValue,
underlying.asAFError?.responseCode ?? -1
))
}
case .fulfilled:
Current.Log.info("refresh token got success")
}
}
Current.Log.info("starting refreshToken cache")
refreshPromiseCache.promise = promise
return promise
}
}
}
extension TokenManager.TokenError: LocalizedError {
public var errorDescription: String? {
switch self {
case .tokenUnavailable:
return L10n.TokenError.tokenUnavailable
case .expired:
return L10n.TokenError.expired
case .connectionFailed:
return L10n.TokenError.connectionFailed
}
}
}
extension TokenManager: Authenticator {
public var authenticationInterceptor: AuthenticationInterceptor<TokenManager> {
AuthenticationInterceptor(authenticator: self, credential: server.info.token, refreshWindow: nil)
}
public func apply(_ credential: TokenInfo, to urlRequest: inout URLRequest) {
urlRequest.headers.add(.authorization(bearerToken: credential.accessToken))
}
public func refresh(
_ credential: TokenInfo,
for session: Session,
completion: @escaping (Swift.Result<TokenInfo, Error>) -> Void
) {
firstly {
refreshToken()
}.done { token in
completion(.success(token))
}.catch { error in
completion(.failure(error))
}
}
public func didRequest(
_ urlRequest: URLRequest,
with response: HTTPURLResponse,
failDueToAuthenticationError error: Error
) -> Bool {
switch response.statusCode {
case 401:
return true
default:
return false
}
}
public func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: TokenInfo) -> Bool {
let bearerToken = HTTPHeader.authorization(bearerToken: credential.accessToken).value
return urlRequest.headers["Authorization"] == bearerToken
}
}