Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift (286 lines of code) (raw):

// // AuthorizationCodeAuthProvider.swift // UberAuth // // Copyright © 2024 Uber Technologies, Inc. All rights reserved. // // 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 AuthenticationServices import Foundation import UberCore public final class AuthorizationCodeAuthProvider: AuthProviding { // MARK: Public Properties public let clientID: String public let redirectURI: String public typealias Completion = (Result<Client, UberAuthError>) -> Void public static let defaultScopes = ["profile"] // MARK: Internal Properties var currentSession: AuthenticationSessioning? typealias AuthenticationSessionBuilder = (ASPresentationAnchor, String, URL, AuthCompletion) -> (AuthenticationSessioning) // MARK: Private Properties private let applicationLauncher: ApplicationLaunching private let authenticationSessionBuilder: AuthenticationSessionBuilder? private var completion: Completion? private let configurationProvider: ConfigurationProviding private let pkce = PKCE() private let presentationAnchor: ASPresentationAnchor private let responseParser: AuthorizationCodeResponseParsing private let shouldExchangeAuthCode: Bool private let networkProvider: NetworkProviding private let tokenManager: TokenManaging private let scopes: [String] private let prompt: Prompt? // MARK: Initializers public init(presentationAnchor: ASPresentationAnchor = .init(), scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes, shouldExchangeAuthCode: Bool = false, prompt: Prompt? = nil) { self.configurationProvider = ConfigurationProvider() self.applicationLauncher = UIApplication.shared self.authenticationSessionBuilder = nil self.clientID = configurationProvider.clientID self.presentationAnchor = presentationAnchor self.redirectURI = configurationProvider.redirectURI self.responseParser = AuthorizationCodeResponseParser() self.shouldExchangeAuthCode = shouldExchangeAuthCode self.networkProvider = NetworkProvider(baseUrl: Constants.baseUrl) self.tokenManager = TokenManager() self.scopes = scopes self.prompt = prompt } init(presentationAnchor: ASPresentationAnchor = .init(), authenticationSessionBuilder: AuthenticationSessionBuilder? = nil, scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes, prompt: Prompt? = nil, shouldExchangeAuthCode: Bool = false, configurationProvider: ConfigurationProviding = ConfigurationProvider(), applicationLauncher: ApplicationLaunching = UIApplication.shared, responseParser: AuthorizationCodeResponseParsing = AuthorizationCodeResponseParser(), networkProvider: NetworkProviding = NetworkProvider(baseUrl: Constants.baseUrl), tokenManager: TokenManaging = TokenManager()) { self.applicationLauncher = applicationLauncher self.authenticationSessionBuilder = authenticationSessionBuilder self.clientID = configurationProvider.clientID self.configurationProvider = configurationProvider self.presentationAnchor = presentationAnchor self.redirectURI = configurationProvider.redirectURI self.responseParser = responseParser self.shouldExchangeAuthCode = shouldExchangeAuthCode self.networkProvider = networkProvider self.tokenManager = tokenManager self.scopes = scopes self.prompt = prompt } // MARK: AuthProviding public func execute(authDestination: AuthDestination, prefill: Prefill? = nil, completion: @escaping 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: authCompletion ) } ) self.completion = authCompletion } public func logout() -> Bool { tokenManager.deleteToken(identifier: TokenManager.defaultAccessTokenIdentifier) } public func handle(response url: URL) -> Bool { guard responseParser.isValidResponse(url: url, matching: redirectURI) else { return false } let result = responseParser(url: url) completion?(result) return true } public var isLoggedIn: Bool { tokenManager.getToken(identifier: TokenManager.defaultAccessTokenIdentifier) != nil } // MARK: - Private private func executeLogin(authDestination: AuthDestination, requestURI: String?, completion: @escaping Completion) { switch authDestination { case .inApp: executeInAppLogin( requestURI: requestURI, completion: completion ) case .native(let appPriority): executeNativeLogin( appPriority: appPriority, requestURI: requestURI, completion: completion ) } } /// Performs login using an embedded browser within the third party client. /// - Parameters: /// - completion: A closure to handle the login result private func executeInAppLogin(requestURI: String?, completion: @escaping Completion) { // Only execute one authentication session at a time guard currentSession == nil else { completion(.failure(.existingAuthSession)) return } let request = AuthorizeRequest( app: nil, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, scopes: scopes ) guard let url = request.url(baseUrl: Constants.baseUrl) else { completion(.failure(.invalidRequest("Invalid base URL"))) return } guard let callbackURL = URL(string: redirectURI), let callbackURLScheme = callbackURL.scheme else { completion(.failure(.invalidRequest("Invalid redirect URI"))) return } currentSession = authenticationSessionBuilder?(ASPresentationAnchor(), callbackURLScheme, url, completion) ?? AuthenticationSession( anchor: presentationAnchor, callbackURLScheme: callbackURLScheme, url: url, completion: { [weak self] result in guard let self else { return } completion(result) currentSession = nil } ) currentSession?.start() } /// Performs login using one of the native Uber applications if available. /// /// There are three possible destinations for auth through this method: /// 1. The native Uber app /// 2. The OS supplied Safari browser /// 3. In app auth through ASWebAuthenticationSession /// /// This method will run through the desired native app destinations supplied in `appPriority`. /// For each one it will: /// * Use the configuration provider to determine if the app is installed, using UIApplication's openUrl. /// If the app's scheme has not been registered in the Info.plist and is not queryable it will default to true /// and continue with the auth flow. If it is registered but not installed, we will continue to the next app. /// * Build a universal link specific to the current app destination /// * Attempt to launch the app using the `applicationLauncher`. If the app is installed, the native app /// should be launched (1), if not the OS supplied browser will be launched (2) /// /// If all app destinations have been exhausted and no url has been launched we fall back to in app auth (3) /// /// - Parameters: /// - appPriority: An ordered list of Uber applications to use to perform login /// - completion: A closure to handle the login result private func executeNativeLogin(appPriority: [UberApp], requestURI: String?, completion: @escaping Completion) { var nativeLaunched = false // Executes the asynchronous operation `launch` serially for each app in appPriority // Stops the execution after the first app is successfully launched AsyncDispatcher.exec( for: appPriority.map { ($0, requestURI) }, with: { _ in }, asyncMethod: launch(context:completion:), continue: { launched in if launched { nativeLaunched = true } return !launched // Continue only if app was not launched }, finally: { [weak self] in guard !nativeLaunched else { return } // If no native app was launched, fall back to in app login self?.executeInAppLogin( requestURI: requestURI, completion: completion ) } ) } /// 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 guard configurationProvider.isInstalled( app: app, defaultIfUnregistered: true ) else { completion?(false) return } // .login not supported for native auth var prompt = prompt prompt?.remove(.login) let request = AuthorizeRequest( app: app, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, scopes: scopes ) guard let url = request.url(baseUrl: Constants.baseUrl) else { completion?(false) return } DispatchQueue.main.async { self.applicationLauncher.launch( url, completion: { opened in completion?(opened) } ) } } private func executePar(prefill: Prefill?, completion: @escaping (_ requestURI: String?) -> Void) { guard let prefill else { completion(nil) return } let request = ParRequest( clientID: clientID, prefill: prefill.dictValue ) networkProvider.execute( request: request, completion: { result in switch result { case .success(let response): completion(response.requestURI) case .failure: completion(nil) } } ) } // 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) if let accessToken = client.accessToken { self?.tokenManager.saveToken( accessToken, identifier: TokenManager.defaultAccessTokenIdentifier ) } completion(.success(client)) case .failure(let error): completion(.failure(error)) } } ) } // MARK: Constants private enum Constants { static let clientIDKey = "ClientID" static let redirectURI = "RedirectURI" static let baseUrl = "https://auth.uber.com/v2" } } fileprivate extension Client { init(tokenResponse: TokenRequest.Response) { self = Client( authorizationCode: nil, accessToken: AccessToken( tokenString: tokenResponse.tokenString, refreshToken: tokenResponse.refreshToken, tokenType: tokenResponse.tokenType, expiresIn: tokenResponse.expiresIn, scope: tokenResponse.scope ) ) } }