diff --git a/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift b/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift index 2a55472..b1e37fe 100644 --- a/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift +++ b/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift @@ -42,7 +42,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { public init(presentationAnchor: ASPresentationAnchor = .init(), scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes, - shouldExchangeAuthCode: Bool = true) { + shouldExchangeAuthCode: Bool = false) { self.configurationProvider = DefaultConfigurationProvider() guard let clientID: String = configurationProvider.clientID else { @@ -64,7 +64,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { init(presentationAnchor: ASPresentationAnchor = .init(), scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes, - shouldExchangeAuthCode: Bool = true, + shouldExchangeAuthCode: Bool = false, configurationProvider: ConfigurationProviding = DefaultConfigurationProvider(), applicationLauncher: ApplicationLaunching = UIApplication.shared, responseParser: AuthorizationCodeResponseParsing = AuthorizationCodeResponseParser(), @@ -93,25 +93,51 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { public func execute(authDestination: AuthDestination, prefill: Prefill? = nil, completion: @escaping Completion) { - self.completion = completion + // Completion is stored for native handle callback + // Upon completion, intercept result and exchange for token if enabled + let authCompletion: Completion = { [weak self] result in + guard let self else { return } + + switch result { + case .success(let client): + // Exchange auth code for token if needed + if shouldExchangeAuthCode, + let code = client.authorizationCode { + exchange(code: code, completion: completion) + self.completion = nil + return + } + case .failure: + break + } + + completion(result) + self.completion = nil + } + executePar( prefill: prefill, completion: { [weak self] requestURI in self?.executeLogin( authDestination: authDestination, requestURI: requestURI, - completion: completion + completion: authCompletion ) } ) + + self.completion = authCompletion } public func handle(response url: URL) -> Bool { guard responseParser.isValidResponse(url: url, matching: redirectURI) else { return false } - completion?(responseParser(url: url)) + + let result = responseParser(url: url) + completion?(result) + return true } @@ -170,16 +196,10 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { anchor: presentationAnchor, callbackURLScheme: callbackURLScheme, url: url, - completion: { result in - if self.shouldExchangeAuthCode, - case .success(let client) = result, - let code = client.authorizationCode { - // TODO: Exchange auth code here - self.currentSession = nil - return - } + completion: { [weak self] result in + guard let self else { return } completion(result) - self.currentSession = nil + currentSession = nil } ) @@ -237,6 +257,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { ) } + /// Attempts to launch a native app with an SSO universal link. + /// Calls a closure with a boolean indicating if the application was successfully opened. + /// + /// - Parameters: + /// - context: A tuple of the destination app and an optional requestURI + /// - completion: An optional closure indicating whether or not the app was launched private func launch(context: (app: UberApp, requestURI: String?), completion: ((Bool) -> Void)?) { let (app, requestURI) = context @@ -271,7 +297,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { } private func executePar(prefill: Prefill?, - completion: @escaping (_ requestURI: String?) -> Void) { + completion: @escaping (_ requestURI: String?) -> Void) { guard let prefill else { completion(nil) return @@ -295,6 +321,33 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { ) } + // MARK: Token Exchange + + /// Makes a request to the /token endpoing to exchange the authorization code + /// for an access token. + /// - Parameter code: The authorization code to exchange + private func exchange(code: String, completion: @escaping Completion) { + let request = TokenRequest( + clientID: clientID, + authorizationCode: code, + redirectURI: redirectURI, + codeVerifier: pkce.codeVerifier + ) + + networkProvider.execute( + request: request, + completion: { [weak self] result in + switch result { + case .success(let response): + let client = Client(tokenResponse: response) + completion(.success(client)) + case .failure(let error): + completion(.failure(error)) + } + } + ) + } + // MARK: Constants private enum Constants { @@ -303,3 +356,17 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { static let baseUrl = "https://auth.uber.com/v2" } } + + +fileprivate extension Client { + + init(tokenResponse: TokenRequest.Response) { + self = .init( + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + tokenType: tokenResponse.tokenType, + expiresIn: tokenResponse.expiresIn, + scope: tokenResponse.scope + ) + } +} diff --git a/Sources/UberAuth/Authorize/AuthorizeRequest.swift b/Sources/UberAuth/Authorize/AuthorizeRequest.swift index 2e79df6..4c95688 100644 --- a/Sources/UberAuth/Authorize/AuthorizeRequest.swift +++ b/Sources/UberAuth/Authorize/AuthorizeRequest.swift @@ -5,7 +5,12 @@ import Foundation -struct AuthorizeRequest: Request { +/// +/// Defines a network request conforming to the OAuth 2.0 standard authorization request +/// for the authorization code grant flow. +/// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2 +/// +struct AuthorizeRequest: NetworkRequest { // MARK: Private Properties diff --git a/Sources/UberAuth/Networking/NetworkProvider.swift b/Sources/UberAuth/Networking/NetworkProvider.swift index b87ee41..dd1496c 100644 --- a/Sources/UberAuth/Networking/NetworkProvider.swift +++ b/Sources/UberAuth/Networking/NetworkProvider.swift @@ -5,8 +5,9 @@ import Foundation +/// @mockable protocol NetworkProviding { - func execute(request: R, completion: @escaping (Result) -> ()) + func execute(request: R, completion: @escaping (Result) -> ()) } final class NetworkProvider: NetworkProviding { @@ -20,7 +21,7 @@ final class NetworkProvider: NetworkProviding { self.session = URLSession(configuration: .default) } - func execute(request: R, completion: @escaping (Result) -> ()) { + func execute(request: R, completion: @escaping (Result) -> ()) { guard let urlRequest = request.urlRequest(baseUrl: baseUrl) else { completion(.failure(UberAuthError.invalidRequest(""))) return diff --git a/Sources/UberAuth/Networking/Request.swift b/Sources/UberAuth/Networking/NetworkRequest.swift similarity index 96% rename from Sources/UberAuth/Networking/Request.swift rename to Sources/UberAuth/Networking/NetworkRequest.swift index cbad5ba..259f63b 100644 --- a/Sources/UberAuth/Networking/Request.swift +++ b/Sources/UberAuth/Networking/NetworkRequest.swift @@ -5,8 +5,8 @@ import Foundation -protocol Request { - +protocol NetworkRequest { + associatedtype Response: Codable var body: [String: String]? { get } @@ -19,7 +19,7 @@ protocol Request { var scheme: String? { get } } -extension Request { +extension NetworkRequest { var contentType: String? { nil } var body: [String: String]? { nil } @@ -30,7 +30,7 @@ extension Request { var scheme: String? { nil } } -extension Request { +extension NetworkRequest { func url(baseUrl: String) -> URL? { urlRequest(baseUrl: baseUrl)?.url diff --git a/Sources/UberAuth/PAR/ParRequest.swift b/Sources/UberAuth/PAR/ParRequest.swift index 20a93b0..d75f810 100644 --- a/Sources/UberAuth/PAR/ParRequest.swift +++ b/Sources/UberAuth/PAR/ParRequest.swift @@ -5,7 +5,7 @@ import Foundation -struct ParRequest: Request { +struct ParRequest: NetworkRequest { // MARK: Private Properties diff --git a/Sources/UberAuth/Token/TokenRequest.swift b/Sources/UberAuth/Token/TokenRequest.swift new file mode 100644 index 0000000..f1a1eb6 --- /dev/null +++ b/Sources/UberAuth/Token/TokenRequest.swift @@ -0,0 +1,101 @@ +// +// Copyright © Uber Technologies, Inc. All rights reserved. +// + + +import Foundation + + +/// +/// Defines a network request conforming to the OAuth 2.0 standard access token request +/// for the authorization code grant flow. +/// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 +/// +struct TokenRequest: NetworkRequest { + + let path: String + let clientID: String + let authorizationCode: String + let grantType: String + let redirectURI: String + let codeVerifier: String + + init(path: String = "/oauth/v2/token", + clientID: String, + authorizationCode: String, + grantType: String = "authorization_code", + redirectURI: String, + codeVerifier: String) { + self.path = path + self.clientID = clientID + self.authorizationCode = authorizationCode + self.grantType = grantType + self.redirectURI = redirectURI + self.codeVerifier = codeVerifier + } + + // MARK: Request + + var method: HTTPMethod { + .post + } + + typealias Response = Token + + var parameters: [String : String]? { + [ + "code": authorizationCode, + "client_id": clientID, + "redirect_uri": redirectURI, + "grant_type": grantType, + "code_verifier": codeVerifier + ] + } +} + +/// +/// The Access Token response for the authorization code grant flow as +/// defined by the OAuth 2.0 standard. +/// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 +/// +struct Token: Codable { + + let accessToken: String + let tokenType: String + let expiresIn: Int? + let refreshToken: String? + let scope: [String] + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + case refreshToken = "refresh_token" + case scope + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.accessToken = try container.decode(String.self, forKey: .accessToken) + self.tokenType = try container.decode(String.self, forKey: .tokenType) + self.expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn) + self.refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) + + let scopeString = try container.decodeIfPresent(String.self, forKey: .scope) + self.scope = (scopeString ?? "") + .split(separator: " ") + .map(String.init) + } + + init(accessToken: String, + tokenType: String, + expiresIn: Int? = nil, + refreshToken: String? = nil, + scope: [String] = []) { + self.accessToken = accessToken + self.tokenType = tokenType + self.expiresIn = expiresIn + self.refreshToken = refreshToken + self.scope = scope + } +} diff --git a/examples/UberSDK/UberSDK/ContentView.swift b/examples/UberSDK/UberSDK/ContentView.swift index 0825100..a299d5f 100644 --- a/examples/UberSDK/UberSDK/ContentView.swift +++ b/examples/UberSDK/UberSDK/ContentView.swift @@ -28,6 +28,7 @@ final class Content { var selection: Item? var type: LoginType? = .authorizationCode var destination: LoginDestination? = .inApp + var isTokenExchangeEnabled: Bool = true var isPrefillExpanded: Bool = false var response: String? var prefillBuilder = PrefillBuilder() @@ -35,7 +36,7 @@ final class Content { func login() { let authProvider: AuthProviding = .authorizationCode( - shouldExchangeAuthCode: false + shouldExchangeAuthCode: isTokenExchangeEnabled ) let authDestination: AuthDestination = { @@ -70,6 +71,7 @@ final class Content { enum Item: String, Hashable, Identifiable { case type = "Auth Type" case destination = "Destination" + case tokenExchange = "Exchange Auth Code for Token" case prefill = "Prefill Values" case firstName = "First Name" case lastName = "Last Name" @@ -174,6 +176,15 @@ struct ContentView: View { tapHandler: { content.selection = .destination } ) + row( + item: .tokenExchange, + content: { + Toggle(isOn: $content.isTokenExchangeEnabled, label: { EmptyView() }) + }, + showDisclosureIndicator: false, + tapHandler: nil + ) + row( item: .prefill, content: { diff --git a/examples/UberSDK/UberSDKTests/Mocks/UberAuthMocks.swift b/examples/UberSDK/UberSDKTests/Mocks/UberAuthMocks.swift index d3cdd0b..4eef559 100644 --- a/examples/UberSDK/UberSDKTests/Mocks/UberAuthMocks.swift +++ b/examples/UberSDK/UberSDKTests/Mocks/UberAuthMocks.swift @@ -36,6 +36,21 @@ class AuthorizationCodeResponseParsingMock: AuthorizationCodeResponseParsing { } } +class NetworkProvidingMock: NetworkProviding { + init() { } + + + private(set) var executeCallCount = 0 + var executeHandler: ((Any, Any) -> ())? + func execute(request: R, completion: @escaping (Result) -> ()) { + executeCallCount += 1 + if let executeHandler = executeHandler { + executeHandler(request, completion) + } + + } +} + class ApplicationLaunchingMock: ApplicationLaunching { init() { } diff --git a/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift b/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift index 9fabf56..86fe9cb 100644 --- a/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift +++ b/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift @@ -3,6 +3,7 @@ // +@testable import UberCore @testable import UberAuth import XCTest @@ -10,7 +11,7 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { private let configurationProvider = ConfigurationProvidingMock( clientID: "test_client_id", - redirectURI: "test://" + redirectURI: "test://app" ) func test_executeInAppLogin_createsAuthenticationSession() { @@ -215,6 +216,33 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { XCTAssertNotNil(provider.currentSession) } + func test_executeNativeLogin_noOpens_triggersInAppLogin() { + + let applicationLauncher = ApplicationLaunchingMock() + applicationLauncher.openHandler = { _, _, completion in + completion?(false) + } + + configurationProvider.isInstalledHandler = { _, _ in + true + } + + let provider = AuthorizationCodeAuthProvider( + configurationProvider: configurationProvider, + applicationLauncher: applicationLauncher + ) + + XCTAssertNil(provider.currentSession) + + provider.execute( + authDestination: .native(appPriority: UberApp.allCases), + prefill: nil, + completion: { _ in } + ) + + XCTAssertNotNil(provider.currentSession) + } + func test_handleResponse_true_callsResponseParser() { let responseParser = AuthorizationCodeResponseParsingMock() @@ -276,4 +304,192 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { XCTAssertEqual(responseParser.callAsFunctionCallCount, 0) XCTAssertFalse(handled) } + + func test_prefill_executesParRequest() { + + var hasCalledParRequest = false + + let networkProvider = NetworkProvidingMock() + networkProvider.executeHandler = { request, _ in + if request is ParRequest { + hasCalledParRequest = true + } + } + + let provider = AuthorizationCodeAuthProvider( + configurationProvider: configurationProvider, + networkProvider: networkProvider + ) + + provider.execute( + authDestination: .native(appPriority: [.rides]), + prefill: Prefill(), + completion: { _ in } + ) + + XCTAssertTrue(hasCalledParRequest) + } + + func test_noPrefill_doesNotExecuteParRequest() { + + var hasCalledParRequest = false + + let networkProvider = NetworkProvidingMock() + networkProvider.executeHandler = { request, _ in + if request is ParRequest { + hasCalledParRequest = true + } + } + + let provider = AuthorizationCodeAuthProvider( + configurationProvider: configurationProvider, + networkProvider: networkProvider + ) + + provider.execute( + authDestination: .native(appPriority: [.rides]), + completion: { _ in } + ) + + XCTAssertFalse(hasCalledParRequest) + } + + func test_nativeAuth_tokenExchange_triggersTokenRequest() { + + var hasCalledTokenRequest = false + + let networkProvider = NetworkProvidingMock() + networkProvider.executeHandler = { request, _ in + if request is TokenRequest { + hasCalledTokenRequest = true + } + } + + configurationProvider.isInstalledHandler = { _, _ in + true + } + + let applicationLauncher = ApplicationLaunchingMock() + applicationLauncher.openHandler = { _, _, completion in + completion?(true) + } + + let provider = AuthorizationCodeAuthProvider( + shouldExchangeAuthCode: true, + configurationProvider: configurationProvider, + applicationLauncher: applicationLauncher, + networkProvider: networkProvider + ) + + provider.execute( + authDestination: .native(appPriority: [.rides]), + completion: { result in } + ) + + let url = URL(string: "test://app?code=123")! + _ = provider.handle(response: url) + + XCTAssertTrue(hasCalledTokenRequest) + } + + func test_nativeAuth_noTokenExchange_doesNotTriggerTokenRequest() { + + var hasCalledTokenRequest = false + + let networkProvider = NetworkProvidingMock() + networkProvider.executeHandler = { request, _ in + if request is TokenRequest { + hasCalledTokenRequest = true + } + } + + configurationProvider.isInstalledHandler = { _, _ in + true + } + + let applicationLauncher = ApplicationLaunchingMock() + applicationLauncher.openHandler = { _, _, completion in + completion?(true) + } + + let provider = AuthorizationCodeAuthProvider( + configurationProvider: configurationProvider, + applicationLauncher: applicationLauncher, + networkProvider: networkProvider + ) + + provider.execute( + authDestination: .native(appPriority: [.rides]), + completion: { result in } + ) + + let url = URL(string: "test://app?code=123")! + _ = provider.handle(response: url) + + XCTAssertFalse(hasCalledTokenRequest) + } + + func test_nativeAuth_tokenExchange() { + + let token = Token( + accessToken: "123", + tokenType: "test_token" + ) + + let networkProvider = NetworkProvidingMock() + networkProvider.executeHandler = { request, completion in + if request is TokenRequest { + let completion = completion as! (Result) -> () + completion(.success(token)) + } + else if request is ParRequest { + let completion = completion as! (Result) -> () + completion(.success(Par(requestURI: nil, expiresIn: .now))) + } + } + + configurationProvider.isInstalledHandler = { _, _ in + true + } + + let applicationLauncher = ApplicationLaunchingMock() + applicationLauncher.openHandler = { _, _, completion in + completion?(true) + } + + let provider = AuthorizationCodeAuthProvider( + shouldExchangeAuthCode: true, + configurationProvider: configurationProvider, + applicationLauncher: applicationLauncher, + networkProvider: networkProvider + ) + + let expectation = XCTestExpectation() + + provider.execute( + authDestination: .native(appPriority: [.rides]), + completion: { result in + expectation.fulfill() + + switch result { + case .failure: + XCTFail() + case .success(let client): + XCTAssertEqual( + client, + Client( + accessToken: "123", + tokenType: "test_token", + scope: [] + ) + ) + } + } + ) + + let url = URL(string: "test://app?code=123")! + _ = provider.handle(response: url) + + wait(for: [expectation], timeout: 0.1) + } } diff --git a/examples/UberSDK/UberSDKTests/UberAuth/RequestTests.swift b/examples/UberSDK/UberSDKTests/UberAuth/RequestTests.swift index 4d7e791..1a20cb5 100644 --- a/examples/UberSDK/UberSDKTests/UberAuth/RequestTests.swift +++ b/examples/UberSDK/UberSDKTests/UberAuth/RequestTests.swift @@ -87,7 +87,7 @@ final class RequestTests: XCTestCase { XCTAssertNotNil(urlRequest?.httpBody) } - fileprivate struct TestRequest: Request { + fileprivate struct TestRequest: NetworkRequest { var body: [String: String]? = nil var contentType: String? = nil var headers: [String: String]? = nil