Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class SignInViewController: UIViewController {
}

@IBAction func signIn(_ sender: Any) {
signInButton.isEnabled = false
let window = viewIfLoaded?.window
auth?.signIn(from: window) { result in
switch result {
Expand All @@ -72,6 +73,10 @@ class SignInViewController: UIViewController {
case .failure(let error):
self.show(error: error)
}

Task { @MainActor in
self.signInButton.isEnabled = true
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ struct SignInView: View {
@State var signInError: (any Error)?
@State var hasError: Bool = false
@State var clientId: String?
@State var state: SignInState = .unconfigured

enum SignInState: Sendable {
case unconfigured
case ready
case signingIn
case signedIn
}

init(signedIn: Binding<Bool>) {
self._signedIn = signedIn
Expand All @@ -46,6 +54,7 @@ struct SignInView: View {

Button("Sign In") {
guard let auth = BrowserSignin.shared else { return }
state = .signingIn
auth.ephemeralSession = ephemeralSession
Task {
do {
Expand All @@ -58,7 +67,7 @@ struct SignInView: View {
}
}
}
.disabled(clientId == nil)
.disabled(state != .ready)
.alert(isPresented: $hasError) {
Alert(
title: Text("Error"),
Expand All @@ -78,8 +87,9 @@ struct SignInView: View {
.padding()
}
.onAppear {
Task {
clientId = await BrowserSignin.shared?.signInFlow.client.configuration.clientId
if let configuration = BrowserSignin.shared?.signInFlow.client.configuration {
clientId = configuration.clientId
state = .ready
Comment thread
AlexNachbaur marked this conversation as resolved.
}
}
}
Expand All @@ -93,9 +103,11 @@ struct SignInView: View {
let token = try await auth.signIn(from: ASPresentationAnchor())
try Credential.store(token)
signedIn = true
state = .signedIn
} catch {
signInError = error
signedIn = false
state = .ready
}
}
}
Expand Down
65 changes: 50 additions & 15 deletions Sources/BrowserSignin/BrowserSignin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public enum BrowserSigninError: Error {
case noAuthenticatorProviderResonse
case serverError(_ error: OAuth2ServerError)
case invalidRedirectScheme(_ scheme: String?)
case userCancelledLogin
case userCancelledLogin(_ reason: String? = nil)
case missingIdToken
case oauth2(error: OAuth2Error)
case generic(error: any Error)
Expand All @@ -49,7 +49,11 @@ public enum BrowserSigninError: Error {
///
/// To customize the authentication flow, please read more about the underlying OAuth2 client within the OAuth2Auth library, and how that relates to the ``signInFlow`` or ``signOutFlow`` properties.
///
/// > Important: If your application targets iOS 9.x-10.x, you should add the redirect URI for your client configuration to your app's supported URL schemes. This is because users on devices older than iOS 11 will be prompted to sign in using `SFSafariViewController`, which does not allow your application to detect the final token redirect.
/// ## Redirect URI Support
///
/// This library supports both custom URIs and HTTPS redirect URLs on supporting platforms and iOS versions. This requires that your application is configured to use associated domains, with the application's identifier included in the associated domain's `webcredentials` list.
///
/// > Note: The use of HTTPS addresses within the redirect callback URI is limited by the availability of support within ASWebAuthenticationSession, which currently requires a minimum of iOS 17.4, macOS 14.4, watchOS 10.4, tvOS 17.4, or visionOS 1.1.
@MainActor
@available(iOS 13.0, macOS 10.15, tvOS 16.0, watchOS 7.0, visionOS 1.0, macCatalyst 13.0, *)
public final class BrowserSignin {
Expand All @@ -61,6 +65,17 @@ public final class BrowserSignin {
public typealias WindowAnchor = Void
#endif

/// Defines the options used to control the behavior of the browser and its presentation.
public struct Option: Sendable, OptionSet {
public let rawValue: Int
public init(rawValue: Int) { self.rawValue = rawValue }

#if canImport(AuthenticationServices)
/// Requests that the browser utilizes an ephemeral session, which does not persist cookies or other browser storage between launches.
public static let ephemeralSession = Option(rawValue: 1 << 0)
#endif
}

/// Active / default shared instance of the ``BrowserSignin`` session.
///
/// This convenience property can be used in one of two ways:
Expand All @@ -81,16 +96,32 @@ public final class BrowserSignin {
return result
}
}

/// The underlying OAuth2 flow that implements the authentication behavior.
public let signInFlow: AuthorizationCodeFlow
nonisolated public let signInFlow: AuthorizationCodeFlow

/// The underlying OAuth2 flow that implements the session logout behaviour.
public let signOutFlow: SessionLogoutFlow?
nonisolated public let signOutFlow: SessionLogoutFlow?

/// Used to control the options which dictates the presentation and behavior of the sign in session.
public var options: Option = []

#if canImport(AuthenticationServices)
/// Indicates whether or not the developer prefers an ephemeral browser session, or if the user's browser state should be shared with the system browser.
public var ephemeralSession: Bool = false

public var ephemeralSession: Bool {
get {
options.contains(.ephemeralSession)
}
set {
if newValue {
options.insert(.ephemeralSession)
} else {
options.remove(.ephemeralSession)
}
}
}
#endif

/// Starts sign-in using the configured client.
/// - Parameters:
/// - window: Window from which the sign in process will be started.
Expand All @@ -113,7 +144,7 @@ public final class BrowserSignin {
guard let provider = try await Self.providerFactory.createWebAuthenticationProvider(
for: self,
from: window,
usesEphemeralSession: ephemeralSession)
options: options)
else {
throw BrowserSigninError.noCompatibleAuthenticationProviders
}
Expand Down Expand Up @@ -180,7 +211,7 @@ public final class BrowserSignin {
guard let provider = try await Self.providerFactory.createWebAuthenticationProvider(
for: self,
from: window,
usesEphemeralSession: ephemeralSession)
options: options)
else {
throw BrowserSigninError.noCompatibleAuthenticationProviders
}
Expand Down Expand Up @@ -290,26 +321,30 @@ public final class BrowserSignin {
BrowserSignin.shared = self
}

/// Used to assign a custom ``BrowserSignin/ProviderFactory``.
///
/// > Important: The default implementation will use the most appropriate browser session for use when authenticating. This facility should only be used when a built-in browser capability is unavailable in your target environment.
public static var providerFactory: any BrowserSignin.ProviderFactory.Type = BrowserSignin.self

// MARK: Internal members
private static var _shared: BrowserSignin?
static var providerFactory: any BrowserSigninProviderFactory.Type = BrowserSignin.self

// Used for testing only
static func resetToDefault() {
providerFactory = BrowserSignin.self
}

var provider: (any BrowserSigninProvider)?
var provider: (any BrowserSignin.Provider)?
}

@available(iOS 13.0, macOS 10.15, tvOS 16.0, watchOS 7.0, visionOS 1.0, macCatalyst 13.0, *)
extension BrowserSignin: BrowserSigninProviderFactory {
nonisolated static func createWebAuthenticationProvider(
extension BrowserSignin: BrowserSignin.ProviderFactory {
public nonisolated static func createWebAuthenticationProvider(
for webAuth: BrowserSignin,
from window: BrowserSignin.WindowAnchor?,
usesEphemeralSession: Bool = false) throws -> (any BrowserSigninProvider)?
options: Option) throws -> (any BrowserSignin.Provider)?
{
try AuthenticationServicesProvider(from: window, usesEphemeralSession: usesEphemeralSession)
try AuthenticationServicesProvider(from: window, usesEphemeralSession: options.contains(.ephemeralSession))
}
}

Expand Down
24 changes: 17 additions & 7 deletions Sources/BrowserSignin/Internal/BrowserSigninError+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ extension BrowserSigninError: LocalizedError {
if nsError.domain == ASWebAuthenticationSessionErrorDomain,
nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue
{
self = .userCancelledLogin
self = .userCancelledLogin(nsError.localizedFailureReason)
} else if let error = error as? OAuth2Error {
self = .oauth2(error: error)
} else if let error = error as? OAuth2ServerError {
Expand Down Expand Up @@ -65,11 +65,20 @@ extension BrowserSigninError: LocalizedError {
bundle: .browserSignin,
comment: ""))

case .userCancelledLogin:
return NSLocalizedString("user_cancelled_login_description",
tableName: "BrowserSignin",
bundle: .browserSignin,
comment: "")
case .userCancelledLogin(let reason):
if let reason {
return String.localizedStringWithFormat(
NSLocalizedString("user_cancelled_login_reason_description",
tableName: "BrowserSignin",
bundle: .browserSignin,
comment: ""),
reason)
} else {
return NSLocalizedString("user_cancelled_login_description",
tableName: "BrowserSignin",
bundle: .browserSignin,
comment: "")
}

case .missingIdToken:
return NSLocalizedString("missing_id_token_description",
Expand Down Expand Up @@ -118,7 +127,8 @@ extension BrowserSigninError: Equatable {
case (.noSignOutFlowProvided, .noSignOutFlowProvided): return true
case (.cannotStartBrowserSession, .cannotStartBrowserSession): return true
case (.cannotComposeAuthenticationURL, .cannotComposeAuthenticationURL): return true
case (.userCancelledLogin, .userCancelledLogin): return true
case (.userCancelledLogin(let lhs), .userCancelledLogin(let rhs)):
return lhs == rhs
case (.noAuthenticatorProviderResonse, .noAuthenticatorProviderResonse): return true
case (.missingIdToken, .missingIdToken): return true
case (.authenticationProvider(error: let lhsValue), .authenticationProvider(error: let rhsValue)):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import AuthenticationServices
@available(iOS 12.0, macCatalyst 13.0, macOS 10.15, tvOS 16.0, visionOS 1.0, watchOS 6.2, *)
protocol AuthenticationServicesProviderSession: NSObjectProtocol, Sendable {
init(url URL: URL, callbackURLScheme: String?, completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler)

@available(iOS 17.4, macOS 14.4, watchOS 10.4, tvOS 17.4, visionOS 1.1, *)
init(url URL: URL, callback: ASWebAuthenticationSession.Callback, completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler)

@available(iOS 13.4, macCatalyst 13.4, macOS 10.15.4, watchOS 6.2, visionOS 1.0, tvOS 16.0, *)
var canStart: Bool { get }
Expand Down Expand Up @@ -48,14 +51,7 @@ extension ASWebAuthenticationSession: @retroactive @unchecked Sendable, Authenti
#endif

@available(iOS 13.0, macOS 10.15, tvOS 16.0, watchOS 7.0, visionOS 1.0, macCatalyst 13.0, *)
protocol BrowserSigninProviderFactory: Sendable {
static func createWebAuthenticationProvider(for webAuth: BrowserSignin,
from window: BrowserSignin.WindowAnchor?,
usesEphemeralSession: Bool) async throws -> (any BrowserSigninProvider)?
}

@available(iOS 13.0, macOS 10.15, tvOS 16.0, watchOS 7.0, visionOS 1.0, macCatalyst 13.0, *)
final class AuthenticationServicesProvider: NSObject, BrowserSigninProvider {
final class AuthenticationServicesProvider: NSObject, BrowserSignin.Provider {
private(set) var authenticationSession: (any AuthenticationServicesProviderSession)? {
get {
lock.withLock { _authenticationSession }
Expand All @@ -73,18 +69,42 @@ final class AuthenticationServicesProvider: NSObject, BrowserSigninProvider {

super.init()
}


func createSession(authorizeUrl url: URL, callbackURL: URL, completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler) -> (any AuthenticationServicesProviderSession) {
Comment thread
AlexNachbaur marked this conversation as resolved.
if #available(iOS 17.4, macOS 14.4, watchOS 10.4, tvOS 17.4, visionOS 1.1, *) {
if let scheme = callbackURL.scheme {
let callback: ASWebAuthenticationSession.Callback?
if scheme == "https",
let host = callbackURL.host
{
callback = .https(host: host, path: callbackURL.path)
} else {
callback = .customScheme(scheme)
}

if let callback {
return Self.authenticationSessionClass.init(
url: url,
callback: callback,
completionHandler: completionHandler)
}
}
}

return Self.authenticationSessionClass.init(
url: url,
callbackURLScheme: callbackURL.scheme,
completionHandler: completionHandler)
}

@MainActor
func open(authorizeUrl: URL, redirectUri: URL) async throws -> URL {
return try await withCheckedThrowingContinuation { continuation in
let session = Self.authenticationSessionClass.init(
url: authorizeUrl,
callbackURLScheme: redirectUri.scheme,
completionHandler: { url, error in
continuation.resume(with: self.process(redirectUri: redirectUri,
url: url,
error: error))
})
let session = createSession(authorizeUrl: authorizeUrl, callbackURL: redirectUri) { url, error in
continuation.resume(with: self.process(redirectUri: redirectUri,
url: url,
error: error))
}

#if !os(watchOS) && !os(tvOS)
session.presentationContextProvider = self
Expand Down
34 changes: 31 additions & 3 deletions Sources/BrowserSignin/Providers/BrowserSigninProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,35 @@ import Foundation
import AuthFoundation
import OAuth2Auth

protocol BrowserSigninProvider: Sendable {
func open(authorizeUrl: URL, redirectUri: URL) async throws -> URL
func cancel()
extension BrowserSignin {
/// Protocol used to represent a specific provider type for use in presenting a browser.
///
/// > Important: The default implementation will use the most appropriate browser session for use when authenticating. This facility should only be used when a built-in browser capability is unavailable in your target environment.
public protocol Provider: Sendable {
/// Used by ``BrowserSignin`` when it determines a browser should be presented to the user.
/// - Parameters:
/// - authorizeUrl: Authorization URL to open within the browser.
/// - redirectUri: The redirect URI configured for the client.
/// - Returns: The final URI the browser redirects to which matches the `redirectUri` parameter.
func open(authorizeUrl: URL, redirectUri: URL) async throws -> URL

/// Used by ``BrowserSignin`` when the browser window should be canceled and closed.
func cancel()
}

/// Protocol used to customize the presentation of the browser sign in interface.
///
/// > Important: The default implementation will use the most appropriate browser session for use when authenticating. This facility should only be used when a built-in browser capability is unavailable in your target environment.
public protocol ProviderFactory: Sendable {
/// Creates an object conforming to ``BrowserSignin/Provider`` for use in presenting a browser to the user when they are signing in.
/// - Parameters:
/// - browserSignin: The ``BrowserSignin`` instance triggering this operation.
/// - window: The window anchor the sign-in is initiated from.
/// - options: The options used to control the sign in provider's behavior.
/// - Returns: ``BrowserSignin/Provider`` that is capable of signing in, or `nil` if browser sign in is unsupported on this platform.
nonisolated static func createWebAuthenticationProvider(
for browserSignin: BrowserSignin,
from window: BrowserSignin.WindowAnchor?,
options: BrowserSignin.Option) async throws -> (any Provider)?
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"authentication_provider_error" = "Could not set up a provider for handling web authentication.";
"invalid_redirect_scheme_description" = "Cannot resume sign in with an invalid URL scheme: %@.";
"user_cancelled_login_description" = "Authentication cancelled by the user.";
"user_cancelled_login_reason_description" = "Authentication cancelled by the user: %@.";
"generic_description" = "Authentication error: %@";
"unknown_error_message" = "Unknown error";
"no_scheme_defined" = "No URL scheme defined";
Expand Down
Loading
Loading