kean / Get

Web API client built using async/await
MIT License
943 stars 75 forks source link

Proposed implementation of RequestDelegate for authentication #18

Closed simorgh3196 closed 2 years ago

simorgh3196 commented 2 years ago

Hello :)

I've created a proposal for an additional feature and would appreciate your feedback.

Introduction

Provides an implementation of APIClientDelegate specifically for authenticating requests.

Motivation

Alamofire provides an implementation called AuthenticationInterceptor for authentication. (docs) AuthenticationInterceptor takes care of state management when sending concurrent requests. Therefore, users only need to implement each type that conforms to the AuthenticationCredential protocol and the Authenticator protocol.

I would like to see an implementation of AuthenticationInterceptor that manages authentication in Get as well. (With an implementation based on async/await and actor, of course.)

Implementation

Authenticator

Types adopting the Authenticator protocol will load or update Credential and apply the Credential to URLRequest.

AuthenticationInterceptor

The AuthenticationInterceptor class provides authentication for requests using exclusive control. It relies on an Authenticator type to handle the actual URLRequest authentication and Credential refresh.

AuthenticationInterceptor uses actor State, for exclusion control, and can apply and refresh authentication in order even for parallel requests.

Sample Usage

  1. Implement a class that adapt to the Authenticator protocol.
public class SampleAuthenticator: Authenticator {
    public typealias Credential = Token

    public var tokenStore: TokenStore
    public let client: APIClient

    public init(tokenStore: TokenStore, clientToRefreshCredential: APIClient) {
        self.tokenStore = tokenStore
        self.client = clientToRefreshCredential
    }

    public func credential() async throws -> Token {
        if let token = tokenStore.token, token.expiresDate < Date() {
            return token
        }

        // If there is no token, generate a token.
        let token: Token = try await client.send(.post("/token")).value
        tokenStore.save(token)
        return token
    }

    public func apply(_ credential: Token, to request: inout URLRequest) async throws {
        request.setValue(authorizationHeaderValue(for: credential), forHTTPHeaderField: "Authorization")
    }

    public func refresh(_ credential: Credential) async throws -> Credential {
        let token: Token = try await client.send(.put("/token", body: ["refresh_token": credential.refreshToken])).value
        tokenStore.save(token)
        return token
    }

    public func didRequest(_: URLRequest, failDueToAuthenticationError error: Error) -> Bool {
        if case .unacceptableStatusCode(let status) = (error as? APIError), status == 401 {
            return true
        }
        return false
    }

    public func isRequest(_ request: URLRequest, authenticatedWith credential: Token) -> Bool {
        request.value(forHTTPHeaderField: "Authorization") == authorizationHeaderValue(for: credential)
    }

    private func authorizationHeaderValue(for token: Token) -> String {
        "token \(token.accessToken)"
    }
}
  1. Set AuthenticationInterceptor with SampleAuthenticator as APIClientDelegate
let authenticator = SampleAuthenticator(tokenStore: TokenStore(),
                                        clientToRefreshCredential: APIClient(host: "example.com"))

let apiClient = APIClient(host: "example.com") {
  $0.delegate = AuthenticationInterceptor(authenticator: authenticator)
}

Impact on existing packages

Breaking Changes

Changed method shouldClientRetry(_ client: APIClient, withError error: Error) async throws -> Bool to shouldClientRetry(_ client: APIClient, for request: URLRequest, with error: Error) async throws -> Bool in APIClientDelegate because URLRequest was needed to manage retries for parallel requests.

Other Changes

In order to pass the URLRequest actually sent to APIClientDelegate.shouldClientRetry(_:for:with:), APIClientDelegate.client(_:willSendRequest:) is now called from APIClient.send(_:) to call it from APIClient.send(_:) instead of APIClient.actuallySend(_:).

thanosbellos commented 2 years ago

I am trying to implement your pr and i have a question. How can we still implement our own version of

public func shouldClientRetry(_ client: APIClient, for request: URLRequest, with error: Error) async throws -> Bool

of the APIClientDelegate?

You have already implemented 2 of the 3 APIClientDelegate methods and the last one has a default implementation.

kean commented 2 years ago

Changed method shouldClientRetry( client: APIClient, withError error: Error) async throws -> Bool to shouldClientRetry( client: APIClient, for request: URLRequest, with error: Error) async throws -> Bool in APIClientDelegate because URLRequest was needed to manage retries for parallel requests.

This change was released separately in 0.6.0.

I'm not sure if Authenticator should be shipped as part of the main framework. Unlike Alamofire, the goal of Get is to allow the users to build these things based on their requirements, while keeping the main framework small.

kean commented 2 years ago

I suggested creating a separate authentication package and I'll happily mention it in the repo. What do you think @simorgh3196?

kean commented 2 years ago

I'm going to close this PR for now because there are no plans to bake any authentication mechanisms into the framework. The goal is to provide just enough APIs to build on top of the framework.