Browse Source

Added AuthenticationInterceptor and Authenticator protocol (#3164)

* Added AuthenticationInterceptor to handle adapt and retry queue during refresh

* Addressed PR feedback in AuthenticationInterceptor public APIs and test suite

* Added docstrings to all public APIs in AuthenticationInterceptor file

* Added AuthenticationInterceptor section to AdvancedUsage doc

* Addressed PR feedback by updating docstrings, fixing typos, and adding more info

* Removed unnecessary Alamofire declaration on Session property

* Replaced refresh date timestamps with system uptime to improve reliability

* Ran swiftformat over the project to fix any formatter issues
Christian Noon 5 years ago
parent
commit
4f72b95b49

+ 18 - 0
Alamofire.xcodeproj/project.pbxproj

@@ -194,6 +194,10 @@
 		4C4466EC21F8F5D800AC9703 /* CachedResponseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4466EA21F8F5D800AC9703 /* CachedResponseHandler.swift */; };
 		4C4466ED21F8F5D800AC9703 /* CachedResponseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4466EA21F8F5D800AC9703 /* CachedResponseHandler.swift */; };
 		4C4466EE21F8F5D800AC9703 /* CachedResponseHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4466EA21F8F5D800AC9703 /* CachedResponseHandler.swift */; };
+		4C67D1362454B12A00CBA725 /* AuthenticationInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C67D1352454B12A00CBA725 /* AuthenticationInterceptor.swift */; };
+		4C67D1372454B12A00CBA725 /* AuthenticationInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C67D1352454B12A00CBA725 /* AuthenticationInterceptor.swift */; };
+		4C67D1382454B12A00CBA725 /* AuthenticationInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C67D1352454B12A00CBA725 /* AuthenticationInterceptor.swift */; };
+		4C67D1392454B12A00CBA725 /* AuthenticationInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C67D1352454B12A00CBA725 /* AuthenticationInterceptor.swift */; };
 		4C743CF61C22772D00BCB23E /* certDER.cer in Resources */ = {isa = PBXBuildFile; fileRef = B39E2F831C1A72F8002DA1A9 /* certDER.cer */; };
 		4C743CF71C22772D00BCB23E /* certDER.crt in Resources */ = {isa = PBXBuildFile; fileRef = B39E2F841C1A72F8002DA1A9 /* certDER.crt */; };
 		4C743CF81C22772D00BCB23E /* certDER.der in Resources */ = {isa = PBXBuildFile; fileRef = B39E2F851C1A72F8002DA1A9 /* certDER.der */; };
@@ -256,6 +260,9 @@
 		4C7DD7ED224C627300249836 /* Result+Alamofire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7DD7EA224C627300249836 /* Result+Alamofire.swift */; };
 		4C811F8D1B51856D00E0F59A /* ServerTrustEvaluation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C811F8C1B51856D00E0F59A /* ServerTrustEvaluation.swift */; };
 		4C811F8E1B51856D00E0F59A /* ServerTrustEvaluation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C811F8C1B51856D00E0F59A /* ServerTrustEvaluation.swift */; };
+		4CB0080D2455FE9700C38783 /* AuthenticationInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB0080C2455FE9700C38783 /* AuthenticationInterceptorTests.swift */; };
+		4CB0080E2455FE9700C38783 /* AuthenticationInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB0080C2455FE9700C38783 /* AuthenticationInterceptorTests.swift */; };
+		4CB0080F2455FE9700C38783 /* AuthenticationInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB0080C2455FE9700C38783 /* AuthenticationInterceptorTests.swift */; };
 		4CB928291C66BFBC00CE5F08 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB928281C66BFBC00CE5F08 /* Notifications.swift */; };
 		4CB9282A1C66BFBC00CE5F08 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB928281C66BFBC00CE5F08 /* Notifications.swift */; };
 		4CB9282B1C66BFBC00CE5F08 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB928281C66BFBC00CE5F08 /* Notifications.swift */; };
@@ -414,6 +421,7 @@
 		4C3D00571C66A8B900D1F709 /* NetworkReachabilityManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkReachabilityManagerTests.swift; sourceTree = "<group>"; };
 		4C43669A1D7BB93D00C38AAD /* DispatchQueue+Alamofire.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Alamofire.swift"; sourceTree = "<group>"; };
 		4C4466EA21F8F5D800AC9703 /* CachedResponseHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedResponseHandler.swift; sourceTree = "<group>"; };
+		4C67D1352454B12A00CBA725 /* AuthenticationInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationInterceptor.swift; sourceTree = "<group>"; };
 		4C7DD7EA224C627300249836 /* Result+Alamofire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Alamofire.swift"; sourceTree = "<group>"; };
 		4C811F8C1B51856D00E0F59A /* ServerTrustEvaluation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerTrustEvaluation.swift; sourceTree = "<group>"; };
 		4C812C3A1B535F220017E0BF /* alamofire-root-ca.cer */ = {isa = PBXFileReference; lastKnownFileType = file; name = "alamofire-root-ca.cer"; path = "alamofire.org/alamofire-root-ca.cer"; sourceTree = "<group>"; };
@@ -432,6 +440,7 @@
 		4C9E88371F5FB3B0000BEC61 /* Alamofire 2.0 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = "Alamofire 2.0 Migration Guide.md"; path = "Documentation/Alamofire 2.0 Migration Guide.md"; sourceTree = "<group>"; };
 		4C9E88381F5FB3B0000BEC61 /* Alamofire 3.0 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = "Alamofire 3.0 Migration Guide.md"; path = "Documentation/Alamofire 3.0 Migration Guide.md"; sourceTree = "<group>"; };
 		4C9E88391F5FB3B0000BEC61 /* Alamofire 4.0 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = "Alamofire 4.0 Migration Guide.md"; path = "Documentation/Alamofire 4.0 Migration Guide.md"; sourceTree = "<group>"; };
+		4CB0080C2455FE9700C38783 /* AuthenticationInterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationInterceptorTests.swift; sourceTree = "<group>"; };
 		4CB928281C66BFBC00CE5F08 /* Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = "<group>"; };
 		4CBD217F220B48AE008F1C59 /* RetryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryPolicyTests.swift; sourceTree = "<group>"; };
 		4CCB20681D4549E000C64D5B /* expired.badssl.com-leaf.cer */ = {isa = PBXFileReference; lastKnownFileType = file; path = "expired.badssl.com-leaf.cer"; sourceTree = "<group>"; };
@@ -562,6 +571,7 @@
 		4C256A4F1B09656E0065714F /* Features */ = {
 			isa = PBXGroup;
 			children = (
+				4CB0080C2455FE9700C38783 /* AuthenticationInterceptorTests.swift */,
 				4CFD6B132201338E00FFB5E3 /* CachedResponseHandlerTests.swift */,
 				4C341BB91B1A865A00C1B34D /* CacheTests.swift */,
 				3113D46A21878227001CCD21 /* HTTPHeadersTests.swift */,
@@ -746,6 +756,7 @@
 			isa = PBXGroup;
 			children = (
 				31DADDFA224811ED0051390F /* AlamofireExtended.swift */,
+				4C67D1352454B12A00CBA725 /* AuthenticationInterceptor.swift */,
 				4C4466EA21F8F5D800AC9703 /* CachedResponseHandler.swift */,
 				3111CE8720A77843008315E2 /* EventMonitor.swift */,
 				4C23EB421B327C5B0090E0BC /* MultipartFormData.swift */,
@@ -1292,6 +1303,7 @@
 				31F5085F20B50DC400FE2A0C /* URLSessionConfiguration+Alamofire.swift in Sources */,
 				4196936422FA1E05001EA5D5 /* Result+Alamofire.swift in Sources */,
 				3172741F218BB1790039FFCC /* ParameterEncoder.swift in Sources */,
+				4C67D1382454B12A00CBA725 /* AuthenticationInterceptor.swift in Sources */,
 				319917BB209CE53A00103A19 /* OperationQueue+Alamofire.swift in Sources */,
 				319917AC209CDCB000103A19 /* HTTPHeaders.swift in Sources */,
 				3172741A218BAEC90039FFCC /* HTTPMethod.swift in Sources */,
@@ -1338,6 +1350,7 @@
 				4CF627141BA7CC240011A099 /* BaseTestCase.swift in Sources */,
 				4C7DD7ED224C627300249836 /* Result+Alamofire.swift in Sources */,
 				3106FB6323F8C53A007FAB43 /* ProtectedTests.swift in Sources */,
+				4CB0080F2455FE9700C38783 /* AuthenticationInterceptorTests.swift in Sources */,
 				31727424218BB9A50039FFCC /* HTTPBin.swift in Sources */,
 				311A89C123185BC0003BB714 /* CachedResponseHandlerTests.swift in Sources */,
 				31EBD9C320D1D89D00D1FF34 /* ValidationTests.swift in Sources */,
@@ -1371,6 +1384,7 @@
 				4C1DC8551B68908E00476DE3 /* AFError.swift in Sources */,
 				31F5085E20B50DC400FE2A0C /* URLSessionConfiguration+Alamofire.swift in Sources */,
 				4196936322FA1E05001EA5D5 /* Result+Alamofire.swift in Sources */,
+				4C67D1372454B12A00CBA725 /* AuthenticationInterceptor.swift in Sources */,
 				3172741E218BB1790039FFCC /* ParameterEncoder.swift in Sources */,
 				319917BA209CE53A00103A19 /* OperationQueue+Alamofire.swift in Sources */,
 				319917AB209CDCB000103A19 /* HTTPHeaders.swift in Sources */,
@@ -1411,6 +1425,7 @@
 				4CEC605A1B745C9100E684F4 /* AFError.swift in Sources */,
 				31F5086020B50DC400FE2A0C /* URLSessionConfiguration+Alamofire.swift in Sources */,
 				4196936522FA1EAD001EA5D5 /* Result+Alamofire.swift in Sources */,
+				4C67D1392454B12A00CBA725 /* AuthenticationInterceptor.swift in Sources */,
 				31727420218BB1790039FFCC /* ParameterEncoder.swift in Sources */,
 				319917BC209CE53A00103A19 /* OperationQueue+Alamofire.swift in Sources */,
 				319917AD209CDCB000103A19 /* HTTPHeaders.swift in Sources */,
@@ -1451,6 +1466,7 @@
 				4C1DC8541B68908E00476DE3 /* AFError.swift in Sources */,
 				31F5085D20B50DC400FE2A0C /* URLSessionConfiguration+Alamofire.swift in Sources */,
 				4196936222FA1E05001EA5D5 /* Result+Alamofire.swift in Sources */,
+				4C67D1362454B12A00CBA725 /* AuthenticationInterceptor.swift in Sources */,
 				3172741D218BB1790039FFCC /* ParameterEncoder.swift in Sources */,
 				319917B9209CE53A00103A19 /* OperationQueue+Alamofire.swift in Sources */,
 				319917AA209CDCB000103A19 /* HTTPHeaders.swift in Sources */,
@@ -1497,6 +1513,7 @@
 				4C256A531B096C770065714F /* BaseTestCase.swift in Sources */,
 				4C7DD7EB224C627300249836 /* Result+Alamofire.swift in Sources */,
 				3106FB6123F8C53A007FAB43 /* ProtectedTests.swift in Sources */,
+				4CB0080D2455FE9700C38783 /* AuthenticationInterceptorTests.swift in Sources */,
 				31727422218BB9A50039FFCC /* HTTPBin.swift in Sources */,
 				311A89BF23185BBF003BB714 /* CachedResponseHandlerTests.swift in Sources */,
 				31EBD9C120D1D89C00D1FF34 /* ValidationTests.swift in Sources */,
@@ -1536,6 +1553,7 @@
 				F829C6BF1A7A950600A2CD59 /* RequestTests.swift in Sources */,
 				4C7DD7EC224C627300249836 /* Result+Alamofire.swift in Sources */,
 				3106FB6223F8C53A007FAB43 /* ProtectedTests.swift in Sources */,
+				4CB0080E2455FE9700C38783 /* AuthenticationInterceptorTests.swift in Sources */,
 				31727423218BB9A50039FFCC /* HTTPBin.swift in Sources */,
 				311A89C023185BBF003BB714 /* CachedResponseHandlerTests.swift in Sources */,
 				31EBD9C220D1D89C00D1FF34 /* ValidationTests.swift in Sources */,

+ 81 - 0
Documentation/AdvancedUsage.md

@@ -38,6 +38,7 @@
   + [`RequestAdapter`](#requestadapter)
   + [`RequestRetrier`](#requestretrier)
   + [Using Multiple `RequestInterceptor`s](#using-multiple-requestinterceptors)
+  + [`AuthenticationInterceptor`](#authenticationinterceptor)
 * [Security](#security)
   + [Evaluating Server Trusts with `ServerTrustManager` and `ServerTrustEvaluating`](#evaluating-server-trusts-with-servertrustmanager-and-servertrustevaluating)
     - [`ServerTrustEvaluting`](#servertrustevaluting)
@@ -536,6 +537,86 @@ let composite = Interceptor(interceptors: [adapterAndRetrier, interceptor])
 
 When composed of multiple `RequestAdapter`s, `Interceptor` will call each `RequestAdapter` in succession. If they all succeed, the final `URLRequest` out of the chain of `RequestAdapter`s will be used to perform the request. If one fails, adaptation stops and the `Request` fails with the error returned. Similarly, when composed of multiple `RequestRetrier`s, retries are executed in the same order as the retriers were added to the instance, until either all of them complete or one of them fails with an error.
 
+### `AuthenticationInterceptor`
+Alamofire's `AuthenticationInterceptor` class is a `RequestInterceptor` designed to handle the queueing and threading complexity involved with authenticating requests.
+It leverages an injected `Authenticator` protocol that manages the lifecycle of the matching `AuthenticationCredential`.
+Here is a simple example of how an `OAuthAuthenticator` class could be implemented along with an `OAuthCredential`.
+
+**`OAuthCredential`**
+
+```swift
+struct OAuthCredential: AuthenticationCredential {
+    let accessToken: String
+    let refreshToken: String
+    let userID: String
+    let expiration: Date
+
+    // Require refresh if within 5 minutes of expiration
+    var requiresRefresh: Bool { Date(timeIntervalSinceNow: 60 * 5) > expiration }
+}
+```
+
+**`OAuthAuthenticator`**
+
+```swift
+class OAuthAuthenticator: Authenticator {
+    func apply(_ credential: OAuthCredential, to urlRequest: inout URLRequest) {
+        urlRequest.headers.add(.authorization(bearerToken: credential.accessToken))
+    }
+
+    func refresh(_ credential: OAuthCredential,
+                 for session: Session,
+                 completion: @escaping (Result<OAuthCredential, Error>) -> Void) {
+        // Refresh the credential using the refresh token...then call completion with the new credential.
+        //
+        // The new credential will automatically be stored within the `AuthenticationInterceptor`. Future requests will
+        // be authenticated using the `apply(_:to:)` method using the new credential.
+    }
+
+    func didRequest(_ urlRequest: URLRequest,
+                    with response: HTTPURLResponse,
+                    failDueToAuthenticationError error: Error) -> Bool {
+        // If authentication server CANNOT invalidate credentials, return `false`
+        return false
+
+        // If authentication server CAN invalidate credentials, then inspect the response matching against what the
+        // authentication server returns as an authentication failure. This is generally a 401 along with a custom
+        // header value.
+        // return response.statusCode == 401
+    }
+
+    func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: OAuthCredential) -> Bool {
+        // If authentication server CANNOT invalidate credentials, return `true`
+        return true
+
+        // If authentication server CAN invalidate credentials, then compare the "Authorization" header value in the
+        // `URLRequest` against the Bearer token generated with the access token of the `Credential`.
+        // let bearerToken = HTTPHeader.authorization(bearerToken: credential.accessToken).value
+        // return urlRequest.headers["Authorization"] == bearerToken
+    }
+}
+```
+
+**Usage**
+
+```swift
+// Generally load from keychain if it exists
+let credential = OAuthCredential(accessToken: "a0",
+                                 refreshToken: "r0",
+                                 userID: "u0",
+                                 expiration: Date(timeIntervalSinceNow: 60 * 60))
+
+// Create the interceptor
+let authenticator = OAuthAuthenticator()
+let interceptor = AuthenticationInterceptor(authenticator: authenticator,
+                                            credential: credential)
+
+// Execute requests with the interceptor
+let session = Session()
+let urlRequest = URLRequest(url: URL(string: "https://api.example.com/example/user")!)
+session.request(urlRequest, interceptor: interceptor)
+```
+
 ## Security
 Using a secure HTTPS connection when communicating with servers and web services is an important step in securing sensitive data. By default, Alamofire receives the same automatic TLS certificate and certificate chain validation as `URLSession`. While this guarantees the certificate chain is valid, it does not prevent man-in-the-middle (MITM) attacks or other potential vulnerabilities. In order to mitigate MITM attacks, applications dealing with sensitive customer data or financial information should use certificate or public key pinning provided by Alamofire’s `ServerTrustEvaluating` protocol.
 

+ 1 - 1
README.md

@@ -23,7 +23,7 @@ Alamofire is an HTTP networking library written in Swift.
 	- **Tools -** [Statistical Metrics](https://github.com/Alamofire/Alamofire/blob/master/Documentation/Usage.md#statistical-metrics), [cURL Command Output](https://github.com/Alamofire/Alamofire/blob/master/Documentation/Usage.md#curl-command-output)
 - [Advanced Usage](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md)
 	- **URL Session -** [Session Manager](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#session), [Session Delegate](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#sessiondelegate), [Request](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#request)
-	- **Routing -** [Routing Requests](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#routing-requests), [Adapting and Retrying Requests](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#adapting-and-retrying-requests)
+	- **Routing -** [Routing Requests](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#routing-requests), [Adapting and Retrying Requests](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#adapting-and-retrying-requests-with-requestinterceptor)
 	- **Model Objects -** [Custom Response Serialization](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#custom-response-serialization)
 	- **Connection -** [Security](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#security), [Network Reachability](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#network-reachability)
 - [Open Radars](#open-radars)

+ 402 - 0
Source/AuthenticationInterceptor.swift

@@ -0,0 +1,402 @@
+//
+//  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: [(Alamofire.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.credential = credential
+        mutableState.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
+
+        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)) }
+        }
+    }
+}

+ 2 - 2
Source/Request.swift

@@ -1508,7 +1508,7 @@ public class DownloadRequest: Request {
     ///
     /// - Returns: The instance.
     @discardableResult
-    public override func cancel() -> Self {
+    override public func cancel() -> Self {
         cancel(producingResumeData: false)
     }
 
@@ -1725,7 +1725,7 @@ public class UploadRequest: DataRequest {
         return stream
     }
 
-    public override func cleanup() {
+    override public func cleanup() {
         defer { super.cleanup() }
 
         guard

+ 657 - 0
Tests/AuthenticationInterceptorTests.swift

@@ -0,0 +1,657 @@
+//
+//  AuthenticationInterceptorTests.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.
+//
+
+@testable import Alamofire
+import Foundation
+import XCTest
+
+class AuthenticationInterceptorTestCase: BaseTestCase {
+    // MARK: - Helper Types
+
+    struct OAuthCredential: AuthenticationCredential {
+        let accessToken: String
+        let refreshToken: String
+        let userID: String
+        let expiration: Date
+
+        let requiresRefresh: Bool
+
+        init(accessToken: String = "a0",
+             refreshToken: String = "r0",
+             userID: String = "u0",
+             expiration: Date = Date(),
+             requiresRefresh: Bool = false) {
+            self.accessToken = accessToken
+            self.refreshToken = refreshToken
+            self.userID = userID
+            self.expiration = expiration
+            self.requiresRefresh = requiresRefresh
+        }
+    }
+
+    enum OAuthError: Error {
+        case refreshNetworkFailure
+    }
+
+    class OAuthAuthenticator: Authenticator {
+        private(set) var applyCount = 0
+        private(set) var refreshCount = 0
+        private(set) var didRequestFailDueToAuthErrorCount = 0
+        private(set) var isRequestAuthenticatedWithCredentialCount = 0
+
+        let refreshResult: Result<OAuthCredential, Error>?
+        let lock = NSLock()
+
+        init(refreshResult: Result<OAuthCredential, Error>? = nil) {
+            self.refreshResult = refreshResult
+        }
+
+        func apply(_ credential: OAuthCredential, to urlRequest: inout URLRequest) {
+            lock.lock(); defer { lock.unlock() }
+
+            applyCount += 1
+
+            urlRequest.headers.add(.authorization(bearerToken: credential.accessToken))
+        }
+
+        func refresh(_ credential: OAuthCredential,
+                     for session: Session,
+                     completion: @escaping (Result<OAuthCredential, Error>) -> Void) {
+            lock.lock(); defer { lock.unlock() }
+
+            refreshCount += 1
+
+            let refreshResult = self.refreshResult ?? .success(
+                OAuthCredential(accessToken: "a\(refreshCount)",
+                                refreshToken: "a\(refreshCount)",
+                                userID: "u1",
+                                expiration: Date())
+            )
+
+            // The 100 ms delay here is important to allow multiple requests to queue up while refreshing
+            DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.1) { completion(refreshResult) }
+        }
+
+        func didRequest(_ urlRequest: URLRequest,
+                        with response: HTTPURLResponse,
+                        failDueToAuthenticationError error: Error)
+            -> Bool {
+            lock.lock(); defer { lock.unlock() }
+
+            didRequestFailDueToAuthErrorCount += 1
+
+            return response.statusCode == 401
+        }
+
+        func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: OAuthCredential) -> Bool {
+            lock.lock(); defer { lock.unlock() }
+
+            isRequestAuthenticatedWithCredentialCount += 1
+
+            let bearerToken = HTTPHeader.authorization(bearerToken: credential.accessToken).value
+
+            return urlRequest.headers["Authorization"] == bearerToken
+        }
+    }
+
+    class PathAdapter: RequestAdapter {
+        var paths: [String]
+
+        init(paths: [String]) {
+            self.paths = paths
+        }
+
+        func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
+            var request = urlRequest
+
+            var urlComponents = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
+            urlComponents.path = paths.removeFirst()
+
+            request.url = urlComponents.url
+
+            completion(.success(request))
+        }
+    }
+
+    // MARK: - Tests - Adapt
+
+    func testThatInterceptorCanAdaptURLRequest() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let urlRequest = URLRequest.makeHTTPBinRequest()
+        let session = Session()
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: interceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a0")
+        XCTAssertEqual(response?.result.isSuccess, true)
+
+        XCTAssertEqual(authenticator.applyCount, 1)
+        XCTAssertEqual(authenticator.refreshCount, 0)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 0)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 0)
+
+        XCTAssertEqual(request.retryCount, 0)
+    }
+
+    func testThatInterceptorQueuesAdaptOperationWhenRefreshing() {
+        // Given
+        let credential = OAuthCredential(requiresRefresh: true)
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let urlRequest1 = URLRequest.makeHTTPBinRequest(path: "/status/200")
+        let urlRequest2 = URLRequest.makeHTTPBinRequest(path: "/status/202")
+        let session = Session()
+
+        let expect = expectation(description: "both requests should complete")
+        expect.expectedFulfillmentCount = 2
+
+        var response1: AFDataResponse<Data?>?
+        var response2: AFDataResponse<Data?>?
+
+        // When
+        let request1 = session.request(urlRequest1, interceptor: interceptor).validate().response {
+            response1 = $0
+            expect.fulfill()
+        }
+
+        let request2 = session.request(urlRequest2, interceptor: interceptor).validate().response {
+            response2 = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response1?.request?.headers["Authorization"], "Bearer a1")
+        XCTAssertEqual(response2?.request?.headers["Authorization"], "Bearer a1")
+        XCTAssertEqual(response1?.result.isSuccess, true)
+        XCTAssertEqual(response2?.result.isSuccess, true)
+
+        XCTAssertEqual(authenticator.applyCount, 2)
+        XCTAssertEqual(authenticator.refreshCount, 1)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 0)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 0)
+
+        XCTAssertEqual(request1.retryCount, 0)
+        XCTAssertEqual(request2.retryCount, 0)
+    }
+
+    func testThatInterceptorThrowsMissingCredentialErrorWhenCredentialIsNil() {
+        // Given
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator)
+
+        let urlRequest = URLRequest.makeHTTPBinRequest()
+        let session = Session()
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: interceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers.count, 0)
+
+        XCTAssertEqual(response?.result.isFailure, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.isRequestAdaptationError, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.underlyingError as? AuthenticationError, .missingCredential)
+
+        XCTAssertEqual(authenticator.applyCount, 0)
+        XCTAssertEqual(authenticator.refreshCount, 0)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 0)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 0)
+
+        XCTAssertEqual(request.retryCount, 0)
+    }
+
+    func testThatInterceptorRethrowsRefreshErrorFromAdapt() {
+        // Given
+        let credential = OAuthCredential(requiresRefresh: true)
+        let authenticator = OAuthAuthenticator(refreshResult: .failure(OAuthError.refreshNetworkFailure))
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let session = Session()
+        let urlRequest = URLRequest.makeHTTPBinRequest()
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: interceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers.count, 0)
+
+        XCTAssertEqual(response?.result.isFailure, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.isRequestAdaptationError, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.underlyingError as? OAuthError, .refreshNetworkFailure)
+
+        if case let .requestRetryFailed(_, originalError) = response?.result.failure {
+            XCTAssertEqual(originalError.asAFError?.isResponseValidationError, true)
+            XCTAssertEqual(originalError.asAFError?.responseCode, 401)
+        }
+
+        XCTAssertEqual(authenticator.applyCount, 0)
+        XCTAssertEqual(authenticator.refreshCount, 1)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 0)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 0)
+
+        XCTAssertEqual(request.retryCount, 0)
+    }
+
+    // MARK: - Tests - Retry
+
+    func testThatInterceptorDoesNotRetryWithoutResponse() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let urlRequest = URLRequest(url: URL(string: "/invalid/path")!)
+        let session = Session()
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: interceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a0")
+
+        XCTAssertEqual(response?.result.isFailure, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.isSessionTaskError, true)
+
+        XCTAssertEqual(authenticator.applyCount, 1)
+        XCTAssertEqual(authenticator.refreshCount, 0)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 0)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 0)
+
+        XCTAssertEqual(request.retryCount, 0)
+    }
+
+    func testThatInterceptorDoesNotRetryWhenRequestDoesNotFailDueToAuthError() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let urlRequest = URLRequest.makeHTTPBinRequest(path: "status/500")
+        let session = Session()
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: interceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a0")
+
+        XCTAssertEqual(response?.result.isFailure, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.isResponseValidationError, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.responseCode, 500)
+
+        XCTAssertEqual(authenticator.applyCount, 1)
+        XCTAssertEqual(authenticator.refreshCount, 0)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 1)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 0)
+
+        XCTAssertEqual(request.retryCount, 0)
+    }
+
+    func testThatInterceptorThrowsMissingCredentialErrorWhenCredentialIsNilAndRequestShouldBeRetried() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let eventMonitor = ClosureEventMonitor()
+        eventMonitor.requestDidCreateTask = { _, _ in interceptor.credential = nil }
+
+        let session = Session(eventMonitors: [eventMonitor])
+
+        let urlRequest = URLRequest.makeHTTPBinRequest(path: "status/401")
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: interceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a0")
+
+        XCTAssertEqual(response?.result.isFailure, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.isRequestRetryError, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.underlyingError as? AuthenticationError, .missingCredential)
+
+        if case let .requestRetryFailed(_, originalError) = response?.result.failure {
+            XCTAssertEqual(originalError.asAFError?.isResponseValidationError, true)
+            XCTAssertEqual(originalError.asAFError?.responseCode, 401)
+        }
+
+        XCTAssertEqual(authenticator.applyCount, 1)
+        XCTAssertEqual(authenticator.refreshCount, 0)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 1)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 0)
+
+        XCTAssertEqual(request.retryCount, 0)
+    }
+
+    func testThatInterceptorRetriesRequestThatFailedWithOutdatedCredential() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let eventMonitor = ClosureEventMonitor()
+
+        eventMonitor.requestDidCreateTask = { _, _ in
+            interceptor.credential = OAuthCredential(accessToken: "a1",
+                                                     refreshToken: "r1",
+                                                     userID: "u0",
+                                                     expiration: Date(),
+                                                     requiresRefresh: false)
+        }
+
+        let session = Session(eventMonitors: [eventMonitor])
+
+        let pathAdapter = PathAdapter(paths: ["/status/401", "/status/200"])
+        let compositeInterceptor = Interceptor(adapters: [pathAdapter, interceptor], retriers: [interceptor])
+
+        let urlRequest = URLRequest.makeHTTPBinRequest()
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: compositeInterceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a1")
+        XCTAssertEqual(response?.result.isSuccess, true)
+
+        XCTAssertEqual(authenticator.applyCount, 2)
+        XCTAssertEqual(authenticator.refreshCount, 0)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 1)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 1)
+
+        XCTAssertEqual(request.retryCount, 1)
+    }
+
+    func testThatInterceptorRetriesRequestAfterRefresh() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let pathAdapter = PathAdapter(paths: ["/status/401", "/status/200"])
+
+        let compositeInterceptor = Interceptor(adapters: [pathAdapter, interceptor], retriers: [interceptor])
+
+        let session = Session()
+        let urlRequest = URLRequest.makeHTTPBinRequest()
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: compositeInterceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a1")
+        XCTAssertEqual(response?.result.isSuccess, true)
+
+        XCTAssertEqual(authenticator.applyCount, 2)
+        XCTAssertEqual(authenticator.refreshCount, 1)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 1)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 1)
+
+        XCTAssertEqual(request.retryCount, 1)
+    }
+
+    func testThatInterceptorRethrowsRefreshErrorFromRetry() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator(refreshResult: .failure(OAuthError.refreshNetworkFailure))
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let session = Session()
+        let urlRequest = URLRequest.makeHTTPBinRequest(path: "/status/401")
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: interceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a0")
+
+        XCTAssertEqual(response?.result.isFailure, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.isRequestRetryError, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.underlyingError as? OAuthError, .refreshNetworkFailure)
+
+        if case let .requestRetryFailed(_, originalError) = response?.result.failure {
+            XCTAssertEqual(originalError.asAFError?.isResponseValidationError, true)
+            XCTAssertEqual(originalError.asAFError?.responseCode, 401)
+        }
+
+        XCTAssertEqual(authenticator.applyCount, 1)
+        XCTAssertEqual(authenticator.refreshCount, 1)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 1)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 1)
+
+        XCTAssertEqual(request.retryCount, 0)
+    }
+
+    func testThatInterceptorTriggersRefreshWithMultipleParallelRequestsReturning401Responses() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let requestCount = 6
+        let urlRequest = URLRequest.makeHTTPBinRequest()
+        let session = Session()
+
+        let expect = expectation(description: "both requests should complete")
+        expect.expectedFulfillmentCount = requestCount
+
+        var requests: [Int: Request] = [:]
+        var responses: [Int: AFDataResponse<Data?>] = [:]
+
+        for index in 0..<requestCount {
+            let pathAdapter = PathAdapter(paths: ["/status/401", "/status/20\(index)"])
+            let compositeInterceptor = Interceptor(adapters: [pathAdapter, interceptor], retriers: [interceptor])
+
+            // When
+            let request = session.request(urlRequest, interceptor: compositeInterceptor).validate().response {
+                responses[index] = $0
+                expect.fulfill()
+            }
+
+            requests[index] = request
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        for index in 0..<requestCount {
+            let response = responses[index]
+            XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a1")
+            XCTAssertEqual(response?.result.isSuccess, true)
+
+            let request = requests[index]
+            XCTAssertEqual(request?.retryCount, 1)
+        }
+
+        XCTAssertEqual(authenticator.applyCount, 12)
+        XCTAssertEqual(authenticator.refreshCount, 1)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 6)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 6)
+    }
+
+    // MARK: - Tests - Excessive Refresh
+
+    func testThatInterceptorIgnoresExcessiveRefreshWhenRefreshWindowIsNil() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator, credential: credential)
+
+        let pathAdapter = PathAdapter(paths: ["/status/401",
+                                              "/status/401",
+                                              "/status/401",
+                                              "/status/401",
+                                              "/status/401",
+                                              "/status/200"])
+
+        let compositeInterceptor = Interceptor(adapters: [pathAdapter, interceptor], retriers: [interceptor])
+
+        let session = Session()
+        let urlRequest = URLRequest.makeHTTPBinRequest()
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: compositeInterceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a5")
+        XCTAssertEqual(response?.result.isSuccess, true)
+
+        XCTAssertEqual(authenticator.applyCount, 6)
+        XCTAssertEqual(authenticator.refreshCount, 5)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 5)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 5)
+
+        XCTAssertEqual(request.retryCount, 5)
+    }
+
+    func testThatInterceptorThrowsExcessiveRefreshErrorWhenExcessiveRefreshOccurs() {
+        // Given
+        let credential = OAuthCredential()
+        let authenticator = OAuthAuthenticator()
+        let interceptor = AuthenticationInterceptor(authenticator: authenticator,
+                                                    credential: credential,
+                                                    refreshWindow: .init(interval: 30, maximumAttempts: 2))
+
+        let session = Session()
+        let urlRequest = URLRequest.makeHTTPBinRequest(path: "/status/401")
+
+        let expect = expectation(description: "request should complete")
+        var response: AFDataResponse<Data?>?
+
+        // When
+        let request = session.request(urlRequest, interceptor: interceptor).validate().response {
+            response = $0
+            expect.fulfill()
+        }
+
+        waitForExpectations(timeout: timeout)
+
+        // Then
+        XCTAssertEqual(response?.request?.headers["Authorization"], "Bearer a2")
+
+        XCTAssertEqual(response?.result.isFailure, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.isRequestRetryError, true)
+        XCTAssertEqual(response?.result.failure?.asAFError?.underlyingError as? AuthenticationError, .excessiveRefresh)
+
+        if case let .requestRetryFailed(_, originalError) = response?.result.failure {
+            XCTAssertEqual(originalError.asAFError?.isResponseValidationError, true)
+            XCTAssertEqual(originalError.asAFError?.responseCode, 401)
+        }
+
+        XCTAssertEqual(authenticator.applyCount, 3)
+        XCTAssertEqual(authenticator.refreshCount, 2)
+        XCTAssertEqual(authenticator.didRequestFailDueToAuthErrorCount, 3)
+        XCTAssertEqual(authenticator.isRequestAuthenticatedWithCredentialCount, 3)
+
+        XCTAssertEqual(request.retryCount, 2)
+    }
+}