Skip to content

Commit

Permalink
Native app authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
mohssenfathi committed Mar 22, 2024
1 parent 026087d commit 03833c1
Show file tree
Hide file tree
Showing 18 changed files with 875 additions and 102 deletions.
24 changes: 23 additions & 1 deletion Sources/UberAuth/AuthDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public enum AuthDestination {
}

/// An enum corresponding to each Uber client application
public enum UberApp {
public enum UberApp: CaseIterable {

// Uber Eats
case eats
Expand All @@ -31,4 +31,26 @@ public enum UberApp {

// Uber
case rides

var deeplinkScheme: String {
switch self {
case .eats:
return "ubereats"
case .driver:
return "uberdriver"
case .rides:
return "uber"
}
}

var urlIdentifier: String {
switch self {
case .eats:
return "eats"
case .driver:
return "drivers"
case .rides:
return "riders"
}
}
}
119 changes: 108 additions & 11 deletions Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,30 @@ public final class AuthorizationCodeAuthProvider: AuthProviding {

// MARK: Private Properties

private let shouldExchangeAuthCode: Bool
private let applicationLauncher: ApplicationLaunching

private var completion: Completion?

private let configurationProvider: ConfigurationProviding

var currentSession: AuthenticationSessioning?

private let pkce = PKCE()

private let presentationAnchor: ASPresentationAnchor

private let pkce = PKCE()
private let responseParser: AuthorizationCodeResponseParsing

private var completion: Completion?
private let shouldExchangeAuthCode: Bool

// MARK: Initializers

public init(presentationAnchor: ASPresentationAnchor = .init(),
scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes,
shouldExchangeAuthCode: Bool = true,
configurationProvider: ConfigurationProviding = PlistParser(plistName: "Info")) {
configurationProvider: ConfigurationProviding = DefaultConfigurationProvider(),
applicationLauncher: ApplicationLaunching = UIApplication.shared,
responseParser: AuthorizationCodeResponseParsing = AuthorizationCodeResponseParser()) {

guard let clientID: String = configurationProvider.clientID else {
preconditionFailure("No clientID specified in Info.plist")
Expand All @@ -45,9 +53,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding {
preconditionFailure("No redirectURI specified in Info.plist")
}

self.redirectURI = redirectURI
self.applicationLauncher = applicationLauncher
self.clientID = clientID
self.configurationProvider = configurationProvider
self.presentationAnchor = presentationAnchor
self.redirectURI = redirectURI
self.responseParser = responseParser
self.shouldExchangeAuthCode = shouldExchangeAuthCode
}

Expand All @@ -68,8 +79,11 @@ public final class AuthorizationCodeAuthProvider: AuthProviding {
}

public func handle(response url: URL) -> Bool {
// TODO: Implement
true
guard responseParser.isValidResponse(url: url, matching: redirectURI) else {
return false
}
completion?(responseParser(url: url))
return true
}

// MARK: - Private
Expand All @@ -83,9 +97,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding {
requestURI: requestURI,
completion: completion
)
case .native:
// TODO: Implement
break
case .native(let appPriority):
executeNativeLogin(
appPriority: appPriority,
requestURI: requestURI,
completion: completion
)
}
}

Expand All @@ -102,7 +119,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding {
}

let request = AuthorizeRequest(
type: .url,
app: nil,
clientID: clientID,
codeChallenge: pkce.codeChallenge,
redirectURI: redirectURI,
Expand Down Expand Up @@ -140,6 +157,86 @@ public final class AuthorizationCodeAuthProvider: AuthProviding {
currentSession?.start()
}

/// Performs login using one of the native Uber applications if available.
///
/// There are three possible desinations 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 queyable 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 launced 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

func launch(app: UberApp, completion: ((Bool) -> Void)?) {
guard configurationProvider.isInstalled(
app: app,
defaultIfUnregistered: true
) else {
completion?(false)
return
}

let request = AuthorizeRequest(
app: app,
clientID: clientID,
codeChallenge: pkce.codeChallenge,
redirectURI: redirectURI,
requestURI: requestURI
)

guard let url = request.url(baseUrl: Constants.baseUrl) else {
completion?(false)
return
}

applicationLauncher.open(
url,
options: [:],
completionHandler: { opened in
if opened { nativeLaunched = true }
completion?(opened)
}
)
}

// 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,
with: { _ in },
asyncMethod: launch(app:completion:),
continue: { !$0 }, // Do not continue if app 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
)
}
)
}

// MARK: Constants

private enum Constants {
Expand Down
80 changes: 80 additions & 0 deletions Sources/UberAuth/Authorize/AuthorizationCodeResponseParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// Copyright © Uber Technologies, Inc. All rights reserved.
//


import Foundation

/// @mockable
public protocol AuthorizationCodeResponseParsing {
func isValidResponse(url: URL, matching redirectURI: String) -> Bool
func callAsFunction(url: URL) -> Result<Client, UberAuthError>
}

///
/// A struct that validates and extracts values from a url containing an authorization code response
///
public struct AuthorizationCodeResponseParser: AuthorizationCodeResponseParsing {

public init() {}

/// Determines whether the provided url corresponds to an authorization code response
/// by verifying that the url matches the expected redirect URI
///
/// - Parameters:
/// - url: The url to parse
/// - redirectURI: The expected redirect url
/// - Returns: A boolean indicating whether or not the URLs match
public func isValidResponse(url: URL, matching redirectURI: String) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let expectedComponents = URLComponents(string: redirectURI) else {
return false
}

// Verify incoming scheme matches redirect_uri scheme
guard let scheme = components.scheme?.lowercased(),
let expectedScheme = expectedComponents.scheme?.lowercased(),
scheme == expectedScheme else {
return false
}

// Verify incoming host matches redirect_uri host
guard let scheme = components.host?.lowercased(),
let expectedScheme = expectedComponents.host?.lowercased(),
scheme == expectedScheme else {
return false
}

return true
}

/// Parses the provided url and attempts to pull an authorization code or an error
/// from the query parameters
///
/// - Parameter url: The url to parse
/// - Returns: A Result containing a client object built from the parsed values
public func callAsFunction(url: URL) -> Result<Client, UberAuthError> {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return .failure(.invalidResponse)
}

if let authorizationCode = components.queryItems?.first(where: {
$0.name == "code"
})?.value {
return .success(
Client(authorizationCode: authorizationCode)
)
}

let error: UberAuthError
if let errorString = components.queryItems?.first(where: { $0.name == "error" })?.value,
let oAuthError = OAuthError(rawValue: errorString) {
error = .oAuth(oAuthError)
} else {
error = .invalidAuthCode
}

return .failure(error)
}
}

37 changes: 7 additions & 30 deletions Sources/UberAuth/Authorize/AuthorizeRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,22 @@ import Foundation

struct AuthorizeRequest: Request {

// MARK: Properties

let host: String?
let path: String

// MARK: Private Properties

private let app: UberApp?
private let codeChallenge: String
private let clientID: String
private let redirectURI: String
private let requestURI: String?

// MARK: Initializers

init(type: LinkType,
init(app: UberApp?,
clientID: String,
codeChallenge: String,
redirectURI: String,
requestURI: String?) {
self.host = type.host
self.path = type.path
self.app = app
self.clientID = clientID
self.codeChallenge = codeChallenge
self.redirectURI = redirectURI
Expand All @@ -50,28 +45,10 @@ struct AuthorizeRequest: Request {
.compactMapValues { $0 }
}

// MARK: LinkType
var host: String? = nil

enum LinkType {
case url
case deeplink

var host: String? {
switch self {
case .url:
return nil
case .deeplink:
return "authorize"
}
}

var path: String {
switch self {
case .url:
return "/oauth/v2/authorize"
case .deeplink:
return ""
}
}
var path: String {
let identifier = app?.urlIdentifier ?? "universal"
return "/oauth/v2/\(identifier)/authorize"
}
}
5 changes: 5 additions & 0 deletions Sources/UberAuth/Errors/UberAuthError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public enum UberAuthError: Error {
// The auth code was not found or is malformed
case invalidAuthCode

// The response url could not be parsed
case invalidResponse

// Failed to build the auth request
case invalidRequest(String)

Expand Down Expand Up @@ -48,6 +51,8 @@ extension UberAuthError: LocalizedError {
return "The auth code was not found or is malformed"
case .oAuth(let error):
return error.errorDescription
case .invalidResponse:
return "The response url could not be parsed"
case .invalidRequest(let details):
return "Failed to build the auth request: \(details)"
case .other(let error):
Expand Down
15 changes: 15 additions & 0 deletions Sources/UberAuth/Utilities/ApplicationLauncher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// Copyright © Uber Technologies, Inc. All rights reserved.
//


import Foundation
import UIKit

/// @mockable
public protocol ApplicationLaunching {

func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler: ((Bool) -> Void)?)
}

extension UIApplication: ApplicationLaunching {}
Loading

0 comments on commit 03833c1

Please sign in to comment.