From bddf46076f57671d5e938d347176116f74673277 Mon Sep 17 00:00:00 2001 From: Martin Dufort Date: Mon, 2 Oct 2023 12:52:21 -0700 Subject: [PATCH] Add ability to obtain result of Authentication process (#12) * Add scopes to Login object that will store authorized scopes. Define new closure callback to get result of authentication operation * Add new test to ensure we get a proper authenticationResult * Update readme to refer to new AuthenticationResult capability Update test case for AuthenticationResult * Rename authentication result callback to use new name: AuthenticationStatusHandler Update README.md to showcase new usage Add new test to validate that error in AuthenticationStatusHandler is properly propagated --- README.md | 29 +++++ Sources/OAuthenticator/Authenticator.swift | 30 ++++- Sources/OAuthenticator/Models.swift | 10 +- .../OAuthenticator/Services/GoogleAPI.swift | 19 ++- .../AuthenticatorTests.swift | 116 ++++++++++++++++++ 5 files changed, 191 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e5b4473..fe40aa1 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,35 @@ let myRequest = URLRequest(...) let (data, response) = try await authenticator.response(for: myRequest) ``` +If you want to receive the result of the authentication process without issuing a URLRequest first, you can specify +an optional `Authenticator.AuthenticationStatusHandler` callback function within the `Authenticator.Configuration` initializer. + +This allows you to support special cases where you need to capture the `Login` object before executing your first +authenticated `URLRequest` and manage that separately. + +``` swift +let authenticationStatusHandler: Authenticator.AuthenticationStatusHandler = { result in + switch result { + case .success (let login): + authenticatedLogin = login + case .failure(let error): + print("Authentication failed: \(error)") + } +} + +// Configure Authenticator with result callback +let config = Authenticator.Configuration(appCredentials: appCreds, + tokenHandling: tokenHandling, + mode: .manualOnly, + userAuthenticator: userAuthenticator, + authenticationStatusHandler: authenticationStatusHandler) +let auth = Authenticator(config: config, urlLoader: mockLoader) +try await auth.authenticate() +if let authenticatedLogin = authenticatedLogin { + // Process special case + ... +} +``` ### GitHub diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 223b3a6..8d870bb 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -20,7 +20,8 @@ public enum AuthenticatorError: Error { /// Manage state required to executed authenticated URLRequests. public final class Authenticator { public typealias UserAuthenticator = (URL, String) async throws -> URL - + public typealias AuthenticationStatusHandler = (Result) -> Void + /// A `UserAuthenticator` that always fails. Useful as a placeholder /// for testing and for doing manual authentication with an external /// instance not available at configuration-creation time. @@ -45,29 +46,36 @@ public final class Authenticator { public let tokenHandling: TokenHandling public let userAuthenticator: UserAuthenticator public let mode: UserAuthenticationMode + + // Specify an authenticationResult closure to obtain result and grantedScope + public let authenticationStatusHandler: AuthenticationStatusHandler? @available(tvOS 16.0, macCatalyst 13.0, *) public init(appCredentials: AppCredentials, loginStorage: LoginStorage? = nil, tokenHandling: TokenHandling, - mode: UserAuthenticationMode = .automatic) { + mode: UserAuthenticationMode = .automatic, + authenticationStatusHandler: AuthenticationStatusHandler? = nil) { self.appCredentials = appCredentials self.loginStorage = loginStorage self.tokenHandling = tokenHandling self.mode = mode self.userAuthenticator = ASWebAuthenticationSession.userAuthenticator + self.authenticationStatusHandler = authenticationStatusHandler } public init(appCredentials: AppCredentials, loginStorage: LoginStorage? = nil, tokenHandling: TokenHandling, mode: UserAuthenticationMode = .automatic, - userAuthenticator: @escaping UserAuthenticator) { + userAuthenticator: @escaping UserAuthenticator, + authenticationStatusHandler: AuthenticationStatusHandler? = nil) { self.appCredentials = appCredentials self.loginStorage = loginStorage self.tokenHandling = tokenHandling self.mode = mode self.userAuthenticator = userAuthenticator + self.authenticationStatusHandler = authenticationStatusHandler } } @@ -189,7 +197,21 @@ extension Authenticator { private func loginTaskResult(manual: Bool, userAuthenticator: @escaping UserAuthenticator) async throws -> Login { let task = activeTokenTask ?? makeLoginTask(manual: manual, userAuthenticator: userAuthenticator) - return try await loginFromTask(task: task) + var login: Login + do { + login = try await loginFromTask(task: task) + + // Inform authenticationResult closure of new login information + self.config.authenticationStatusHandler?(.success(login)) + } + catch let authenticatorError as AuthenticatorError { + self.config.authenticationStatusHandler?(.failure(authenticatorError)) + + // Rethrow error + throw authenticatorError + } + + return login } private func loginFromTask(task: Task) async throws -> Login { diff --git a/Sources/OAuthenticator/Models.swift b/Sources/OAuthenticator/Models.swift index 45e1beb..bb8b0e4 100644 --- a/Sources/OAuthenticator/Models.swift +++ b/Sources/OAuthenticator/Models.swift @@ -3,7 +3,7 @@ import Foundation /// Function that can execute a `URLRequest`. /// /// This is used to abstract the actual networking system from the underlying authentication -/// mechanism. Take a look at +/// mechanism. public typealias URLResponseProvider = (URLRequest) async throws -> (Data, URLResponse) public struct Token: Codable, Hashable, Sendable { @@ -30,10 +30,14 @@ public struct Token: Codable, Hashable, Sendable { public struct Login: Codable, Hashable, Sendable { public var accessToken: Token public var refreshToken: Token? - - public init(accessToken: Token, refreshToken: Token? = nil) { + + // User authorized scopes + public var scopes: String? + + public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken + self.scopes = scopes } public init(token: String, validUntilDate: Date? = nil) { diff --git a/Sources/OAuthenticator/Services/GoogleAPI.swift b/Sources/OAuthenticator/Services/GoogleAPI.swift index e97d51f..3a29c2e 100644 --- a/Sources/OAuthenticator/Services/GoogleAPI.swift +++ b/Sources/OAuthenticator/Services/GoogleAPI.swift @@ -49,6 +49,11 @@ public struct GoogleAPI { login.refreshToken = .init(value: refreshToken) } + // Set the authorized scopes from the OAuthResponse if present + if !self.scope.isEmpty { + login.scopes = self.scope + } + return login } } @@ -90,19 +95,22 @@ public struct GoogleAPI { /// The `code` is exchanged for an access / refresh token pair using the granted scope in part 1 static func authenticationRequest(url: URL, appCredentials: AppCredentials) throws -> URLRequest { let code = try url.authorizationCode - let grantedScope = try url.grantedScope // It's possible the user will decide to grant less scopes than requested by the app. - // We should have a mechanism to tell us which scopes were authorized and decide if we - // can continue forward or not. + // The actual granted scopes will be recorded in the Login object upon code exchange... + let grantedScope = try url.grantedScope + + /* -- This is no longer necessary but kept as a reference -- let grantedScopeItems = grantedScope.components(separatedBy: " ") if appCredentials.scopes.count > grantedScopeItems.count { - // For now, just log that less scope was authorized + // Here we just os_log(.info, "[Authentication] Granted scopes less than requested scopes") } - + */ + // Regardless if we want to move forward, we need to supply the granted scopes. // If we don't, the tokens will not be issued and an error will occur + // The application can then later inspect the Login object and decide how to handle a reduce OAuth scope var urlBuilder = URLComponents() urlBuilder.scheme = GoogleAPI.scheme urlBuilder.host = GoogleAPI.tokenHost @@ -138,7 +146,6 @@ public struct GoogleAPI { let (data, _) = try await urlLoader(request) do { - let jsonString = String(data: data, encoding: .utf8) ?? "" os_log(.debug, "%s", jsonString) diff --git a/Tests/OAuthenticatorTests/AuthenticatorTests.swift b/Tests/OAuthenticatorTests/AuthenticatorTests.swift index 33f5ada..5c0fec9 100644 --- a/Tests/OAuthenticatorTests/AuthenticatorTests.swift +++ b/Tests/OAuthenticatorTests/AuthenticatorTests.swift @@ -256,6 +256,122 @@ final class AuthenticatorTests: XCTestCase { await compatFulfillment(of: [userAuthExp, loadExp], timeout: 1.0, enforceOrder: true) } + @MainActor + func testManualAuthenticationWithSuccessResult() async throws { + let urlProvider: TokenHandling.AuthorizationURLProvider = { creds in + return URL(string: "my://auth?client_id=\(creds.clientId)")! + } + + let loginProvider: TokenHandling.LoginProvider = { url, creds, tokenUrl, _ in + XCTAssertEqual(url, URL(string: "my://login")!) + + return Login(token: "TOKEN") + } + + let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid) + + let userAuthExp = expectation(description: "user auth") + let mockUserAuthenticator: Authenticator.UserAuthenticator = { url, scheme in + userAuthExp.fulfill() + + return URL(string: "my://login")! + } + + // This is the callback to obtain authentication results + var authenticatedLogin: Login? + let authenticationCallback: Authenticator.AuthenticationStatusHandler = { result in + switch result { + case .failure(_): + XCTFail() + case .success(let login): + authenticatedLogin = login + } + } + + // Configure Authenticator with result callback + let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, + tokenHandling: tokenHandling, + mode: .manualOnly, + userAuthenticator: mockUserAuthenticator, + authenticationStatusHandler: authenticationCallback) + + let loadExp = expectation(description: "load url") + let mockLoader: URLResponseProvider = { request in + loadExp.fulfill() + + return ("hello".data(using: .utf8)!, URLResponse()) + } + + let auth = Authenticator(config: config, urlLoader: mockLoader) + // Explicitly authenticate and grab Login information after + try await auth.authenticate() + + // Ensure our authenticatedLogin objet is available and contains the proper Token + XCTAssertNotNil(authenticatedLogin) + XCTAssertEqual(authenticatedLogin!, Login(token:"TOKEN")) + + let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) + + await compatFulfillment(of: [userAuthExp, loadExp], timeout: 1.0, enforceOrder: true) + } + + // Test AuthenticationResultHandler with a failed UserAuthenticator + @MainActor + func testManualAuthenticationWithFailedResult() async throws { + let urlProvider: TokenHandling.AuthorizationURLProvider = { creds in + return URL(string: "my://auth?client_id=\(creds.clientId)")! + } + + let loginProvider: TokenHandling.LoginProvider = { url, creds, tokenUrl, _ in + XCTAssertEqual(url, URL(string: "my://login")!) + + return Login(token: "TOKEN") + } + + let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid) + + // This is the callback to obtain authentication results + var authenticatedLogin: Login? + let failureAuth = expectation(description: "auth failure") + let authenticationCallback: Authenticator.AuthenticationStatusHandler = { result in + switch result { + case .failure(_): + failureAuth.fulfill() + authenticatedLogin = nil + case .success(_): + XCTFail() + } + } + + // Configure Authenticator with result callback + let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, + tokenHandling: tokenHandling, + mode: .manualOnly, + userAuthenticator: Authenticator.failingUserAuthenticator, + authenticationStatusHandler: authenticationCallback) + + let auth = Authenticator(config: config, urlLoader: nil) + do { + // Explicitly authenticate and grab Login information after + try await auth.authenticate() + + // Ensure our authenticatedLogin objet is *not* available + XCTAssertNil(authenticatedLogin) + } + catch let error as AuthenticatorError { + XCTAssertEqual(error, AuthenticatorError.failingAuthenticatorUsed) + } + catch { + throw error + } + + await compatFulfillment(of: [failureAuth], timeout: 1.0, enforceOrder: true) + } + @MainActor func testUnauthorizedRequestRefreshes() async throws { let requestedURL = URL(string: "https://example.com")!