Skip to content

Commit

Permalink
Add ability to obtain result of Authentication process (#12)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
martindufort authored Oct 2, 2023
1 parent af91d95 commit bddf460
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 13 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 26 additions & 4 deletions Sources/OAuthenticator/Authenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Login, AuthenticatorError>) -> 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.
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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<Login, Error>) async throws -> Login {
Expand Down
10 changes: 7 additions & 3 deletions Sources/OAuthenticator/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
19 changes: 13 additions & 6 deletions Sources/OAuthenticator/Services/GoogleAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
116 changes: 116 additions & 0 deletions Tests/OAuthenticatorTests/AuthenticatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Expand Down

0 comments on commit bddf460

Please sign in to comment.