AuthenticationInterceptor.swift 19.8 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403
//
//  AuthenticationInterceptor.swift
//
//  Copyright (c) 2020 Alamofire Software Foundation (http://alamofire.org/)
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the "Software"), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in
//  all copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//  THE SOFTWARE.
//

import Foundation

/// Types adopting the `AuthenticationCredential` protocol can be used to authenticate `URLRequest`s.
///
/// One common example of an `AuthenticationCredential` is an OAuth2 credential containing an access token used to
/// authenticate all requests on behalf of a user. The access token generally has an expiration window of 60 minutes
/// which will then require a refresh of the credential using the refresh token to generate a new access token.
public protocol AuthenticationCredential {
    /// Whether the credential requires a refresh. This property should always return `true` when the credential is
    /// expired. It is also wise to consider returning `true` when the credential will expire in several seconds or
    /// minutes depending on the expiration window of the credential.
    ///
    /// For example, if the credential is valid for 60 minutes, then it would be wise to return `true` when the
    /// credential is only valid for 5 minutes or less. That ensures the credential will not expire as it is passed
    /// around backend services.
    var requiresRefresh: Bool { get }
}

// MARK: -

/// Types adopting the `Authenticator` protocol can be used to authenticate `URLRequest`s with an
/// `AuthenticationCredential` as well as refresh the `AuthenticationCredential` when required.
public protocol Authenticator: AnyObject {
    /// The type of credential associated with the `Authenticator` instance.
    associatedtype Credential: AuthenticationCredential

    /// Applies the `Credential` to the `URLRequest`.
    ///
    /// In the case of OAuth2, the access token of the `Credential` would be added to the `URLRequest` as a Bearer
    /// token to the `Authorization` header.
    ///
    /// - Parameters:
    ///   - credential: The `Credential`.
    ///   - urlRequest: The `URLRequest`.
    func apply(_ credential: Credential, to urlRequest: inout URLRequest)

    /// Refreshes the `Credential` and executes the `completion` closure with the `Result` once complete.
    ///
    /// Refresh can be called in one of two ways. It can be called before the `Request` is actually executed due to
    /// a `requiresRefresh` returning `true` during the adapt portion of the `Request` creation process. It can also
    /// be triggered by a failed `Request` where the authentication server denied access due to an expired or
    /// invalidated access token.
    ///
    /// In the case of OAuth2, this method would use the refresh token of the `Credential` to generate a new
    /// `Credential` using the authentication service. Once complete, the `completion` closure should be called with
    /// the new `Credential`, or the error that occurred.
    ///
    /// In general, if the refresh call fails with certain status codes from the authentication server (commonly a 401),
    /// the refresh token in the `Credential` can no longer be used to generate a valid `Credential`. In these cases,
    /// you will need to reauthenticate the user with their username / password.
    ///
    /// Please note, these are just general examples of common use cases. They are not meant to solve your specific
    /// authentication server challenges. Please work with your authentication server team to ensure your
    /// `Authenticator` logic matches their expectations.
    ///
    /// - Parameters:
    ///   - credential: The `Credential` to refresh.
    ///   - session:    The `Session` requiring the refresh.
    ///   - completion: The closure to be executed once the refresh is complete.
    func refresh(_ credential: Credential, for session: Session, completion: @escaping (Result<Credential, Error>) -> Void)

    /// Determines whether the `URLRequest` failed due to an authentication error based on the `HTTPURLResponse`.
    ///
    /// If the authentication server **CANNOT** invalidate credentials after they are issued, then simply return `false`
    /// for this method. If the authentication server **CAN** invalidate credentials due to security breaches, then you
    /// will need to work with your authentication server team to understand how to identify when this occurs.
    ///
    /// In the case of OAuth2, where an authentication server can invalidate credentials, you will need to inspect the
    /// `HTTPURLResponse` or possibly the `Error` for when this occurs. This is commonly handled by the authentication
    /// server returning a 401 status code and some additional header to indicate an OAuth2 failure occurred.
    ///
    /// It is very important to understand how your authentication server works to be able to implement this correctly.
    /// For example, if your authentication server returns a 401 when an OAuth2 error occurs, and your downstream
    /// service also returns a 401 when you are not authorized to perform that operation, how do you know which layer
    /// of the backend returned you a 401? You do not want to trigger a refresh unless you know your authentication
    /// server is actually the layer rejecting the request. Again, work with your authentication server team to understand
    /// how to identify an OAuth2 401 error vs. a downstream 401 error to avoid endless refresh loops.
    ///
    /// - Parameters:
    ///   - urlRequest: The `URLRequest`.
    ///   - response:   The `HTTPURLResponse`.
    ///   - error:      The `Error`.
    ///
    /// - Returns: `true` if the `URLRequest` failed due to an authentication error, `false` otherwise.
    func didRequest(_ urlRequest: URLRequest, with response: HTTPURLResponse, failDueToAuthenticationError error: Error) -> Bool

    /// Determines whether the `URLRequest` is authenticated with the `Credential`.
    ///
    /// If the authentication server **CANNOT** invalidate credentials after they are issued, then simply return `true`
    /// for this method. If the authentication server **CAN** invalidate credentials due to security breaches, then
    /// read on.
    ///
    /// When an authentication server can invalidate credentials, it means that you may have a non-expired credential
    /// that appears to be valid, but will be rejected by the authentication server when used. Generally when this
    /// happens, a number of requests are all sent when the application is foregrounded, and all of them will be
    /// rejected by the authentication server in the order they are received. The first failed request will trigger a
    /// refresh internally, which will update the credential, and then retry all the queued requests with the new
    /// credential. However, it is possible that some of the original requests will not return from the authentication
    /// server until the refresh has completed. This is where this method comes in.
    ///
    /// When the authentication server rejects a credential, we need to check to make sure we haven't refreshed the
    /// credential while the request was in flight. If it has already refreshed, then we don't need to trigger an
    /// additional refresh. If it hasn't refreshed, then we need to refresh.
    ///
    /// Now that it is understood how the result of this method is used in the refresh lifecyle, let's walk through how
    /// to implement it. You should return `true` in this method if the `URLRequest` is authenticated in a way that
    /// matches the values in the `Credential`. In the case of OAuth2, this would mean that the Bearer token in the
    /// `Authorization` header of the `URLRequest` matches the access token in the `Credential`. If it matches, then we
    /// know the `Credential` was used to authenticate the `URLRequest` and should return `true`. If the Bearer token
    /// did not match the access token, then you should return `false`.
    ///
    /// - Parameters:
    ///   - urlRequest: The `URLRequest`.
    ///   - credential: The `Credential`.
    ///
    /// - Returns: `true` if the `URLRequest` is authenticated with the `Credential`, `false` otherwise.
    func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: Credential) -> Bool
}

// MARK: -

/// Represents various authentication failures that occur when using the `AuthenticationInterceptor`. All errors are
/// still vended from Alamofire as `AFError` types. The `AuthenticationError` instances will be embedded within
/// `AFError` `.requestAdaptationFailed` or `.requestRetryFailed` cases.
public enum AuthenticationError: Error {
    /// The credential was missing so the request could not be authenticated.
    case missingCredential
    /// The credential was refreshed too many times within the `RefreshWindow`.
    case excessiveRefresh
}

// MARK: -

/// The `AuthenticationInterceptor` class manages the queuing and threading complexity of authenticating requests.
/// It relies on an `Authenticator` type to handle the actual `URLRequest` authentication and `Credential` refresh.
public class AuthenticationInterceptor<AuthenticatorType>: RequestInterceptor where AuthenticatorType: Authenticator {
    // MARK: Typealiases

    /// Type of credential used to authenticate requests.
    public typealias Credential = AuthenticatorType.Credential

    // MARK: Helper Types

    /// Type that defines a time window used to identify excessive refresh calls. When enabled, prior to executing a
    /// refresh, the `AuthenticationInterceptor` compares the timestamp history of previous refresh calls against the
    /// `RefreshWindow`. If more refreshes have occurred within the refresh window than allowed, the refresh is
    /// cancelled and an `AuthorizationError.excessiveRefresh` error is thrown.
    public struct RefreshWindow {
        /// `TimeInterval` defining the duration of the time window before the current time in which the number of
        /// refresh attempts is compared against `maximumAttempts`. For example, if `interval` is 30 seconds, then the
        /// `RefreshWindow` represents the past 30 seconds. If more attempts occurred in the past 30 seconds than
        /// `maximumAttempts`, an `.excessiveRefresh` error will be thrown.
        public let interval: TimeInterval

        /// Total refresh attempts allowed within `interval` before throwing an `.excessiveRefresh` error.
        public let maximumAttempts: Int

        /// Creates a `RefreshWindow` instance from the specified `interval` and `maximumAttempts`.
        ///
        /// - Parameters:
        ///   - interval:        `TimeInterval` defining the duration of the time window before the current time.
        ///   - maximumAttempts: The maximum attempts allowed within the `TimeInterval`.
        public init(interval: TimeInterval = 30.0, maximumAttempts: Int = 5) {
            self.interval = interval
            self.maximumAttempts = maximumAttempts
        }
    }

    private struct AdaptOperation {
        let urlRequest: URLRequest
        let session: Session
        let completion: (Result<URLRequest, Error>) -> Void
    }

    private enum AdaptResult {
        case adapt(Credential)
        case doNotAdapt(AuthenticationError)
        case adaptDeferred
    }

    private struct MutableState {
        var credential: Credential?

        var isRefreshing = false
        var refreshTimestamps: [TimeInterval] = []
        var refreshWindow: RefreshWindow?

        var adaptOperations: [AdaptOperation] = []
        var requestsToRetry: [(RetryResult) -> Void] = []
    }

    // MARK: Properties

    /// The `Credential` used to authenticate requests.
    public var credential: Credential? {
        get { $mutableState.credential }
        set { $mutableState.credential = newValue }
    }

    let authenticator: AuthenticatorType
    let queue = DispatchQueue(label: "org.alamofire.authentication.inspector")

    @Protected
    private var mutableState: MutableState

    // MARK: Initialization

    /// Creates an `AuthenticationInterceptor` instance from the specified parameters.
    ///
    /// A `nil` `RefreshWindow` will result in the `AuthenticationInterceptor` not checking for excessive refresh calls.
    /// It is recommended to always use a `RefreshWindow` to avoid endless refresh cycles.
    ///
    /// - Parameters:
    ///   - authenticator: The `Authenticator` type.
    ///   - credential:    The `Credential` if it exists. `nil` by default.
    ///   - refreshWindow: The `RefreshWindow` used to identify excessive refresh calls. `RefreshWindow()` by default.
    public init(authenticator: AuthenticatorType,
                credential: Credential? = nil,
                refreshWindow: RefreshWindow? = RefreshWindow()) {
        self.authenticator = authenticator
        mutableState = MutableState(credential: credential, refreshWindow: refreshWindow)
    }

    // MARK: Adapt

    public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
        let adaptResult: AdaptResult = $mutableState.write { mutableState in
            // Queue the adapt operation if a refresh is already in place.
            guard !mutableState.isRefreshing else {
                let operation = AdaptOperation(urlRequest: urlRequest, session: session, completion: completion)
                mutableState.adaptOperations.append(operation)
                return .adaptDeferred
            }

            // Throw missing credential error is the credential is missing.
            guard let credential = mutableState.credential else {
                let error = AuthenticationError.missingCredential
                return .doNotAdapt(error)
            }

            // Queue the adapt operation and trigger refresh operation if credential requires refresh.
            guard !credential.requiresRefresh else {
                let operation = AdaptOperation(urlRequest: urlRequest, session: session, completion: completion)
                mutableState.adaptOperations.append(operation)
                refresh(credential, for: session, insideLock: &mutableState)
                return .adaptDeferred
            }

            return .adapt(credential)
        }

        switch adaptResult {
        case let .adapt(credential):
            var authenticatedRequest = urlRequest
            authenticator.apply(credential, to: &authenticatedRequest)
            completion(.success(authenticatedRequest))

        case let .doNotAdapt(adaptError):
            completion(.failure(adaptError))

        case .adaptDeferred:
            // No-op: adapt operation captured during refresh.
            break
        }
    }

    // MARK: Retry

    public func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
        // Do not attempt retry if there was not an original request and response from the server.
        guard let urlRequest = request.request, let response = request.response else {
            completion(.doNotRetry)
            return
        }

        // Do not attempt retry unless the `Authenticator` verifies failure was due to authentication error (i.e. 401 status code).
        guard authenticator.didRequest(urlRequest, with: response, failDueToAuthenticationError: error) else {
            completion(.doNotRetry)
            return
        }

        // Do not attempt retry if there is no credential.
        guard let credential = credential else {
            let error = AuthenticationError.missingCredential
            completion(.doNotRetryWithError(error))
            return
        }

        // Retry the request if the `Authenticator` verifies it was authenticated with a previous credential.
        guard authenticator.isRequest(urlRequest, authenticatedWith: credential) else {
            completion(.retry)
            return
        }

        $mutableState.write { mutableState in
            mutableState.requestsToRetry.append(completion)

            guard !mutableState.isRefreshing else { return }

            refresh(credential, for: session, insideLock: &mutableState)
        }
    }

    // MARK: Refresh

    private func refresh(_ credential: Credential, for session: Session, insideLock mutableState: inout MutableState) {
        guard !isRefreshExcessive(insideLock: &mutableState) else {
            let error = AuthenticationError.excessiveRefresh
            handleRefreshFailure(error, insideLock: &mutableState)
            return
        }

        mutableState.refreshTimestamps.append(ProcessInfo.processInfo.systemUptime)
        mutableState.isRefreshing = true

        // Dispatch to queue to hop out of the lock in case authenticator.refresh is implemented synchronously.
        queue.async {
            self.authenticator.refresh(credential, for: session) { result in
                self.$mutableState.write { mutableState in
                    switch result {
                    case let .success(credential):
                        self.handleRefreshSuccess(credential, insideLock: &mutableState)
                    case let .failure(error):
                        self.handleRefreshFailure(error, insideLock: &mutableState)
                    }
                }
            }
        }
    }

    private func isRefreshExcessive(insideLock mutableState: inout MutableState) -> Bool {
        guard let refreshWindow = mutableState.refreshWindow else { return false }

        let refreshWindowMin = ProcessInfo.processInfo.systemUptime - refreshWindow.interval

        let refreshAttemptsWithinWindow = mutableState.refreshTimestamps.reduce(into: 0) { attempts, refreshTimestamp in
            guard refreshWindowMin <= refreshTimestamp else { return }
            attempts += 1
        }

        let isRefreshExcessive = refreshAttemptsWithinWindow >= refreshWindow.maximumAttempts

        return isRefreshExcessive
    }

    private func handleRefreshSuccess(_ credential: Credential, insideLock mutableState: inout MutableState) {
        mutableState.credential = credential

        let adaptOperations = mutableState.adaptOperations
        let requestsToRetry = mutableState.requestsToRetry

        mutableState.adaptOperations.removeAll()
        mutableState.requestsToRetry.removeAll()

        mutableState.isRefreshing = false

        // Dispatch to queue to hop out of the mutable state lock
        queue.async {
            adaptOperations.forEach { self.adapt($0.urlRequest, for: $0.session, completion: $0.completion) }
            requestsToRetry.forEach { $0(.retry) }
        }
    }

    private func handleRefreshFailure(_ error: Error, insideLock mutableState: inout MutableState) {
        let adaptOperations = mutableState.adaptOperations
        let requestsToRetry = mutableState.requestsToRetry

        mutableState.adaptOperations.removeAll()
        mutableState.requestsToRetry.removeAll()

        mutableState.isRefreshing = false

        // Dispatch to queue to hop out of the mutable state lock
        queue.async {
            adaptOperations.forEach { $0.completion(.failure(error)) }
            requestsToRetry.forEach { $0(.doNotRetryWithError(error)) }
        }
    }
}