From d8c53db32f6e5e5a81db24c69bc22d432c6b8fa1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 28 Mar 2024 16:38:30 -0300 Subject: [PATCH 01/16] feat(auth): add `signInWithOAuth` method --- .../Auth/GoogleSignInWithWebFlow.swift | 33 ++++++-- Examples/Examples/ExamplesApp.swift | 7 +- Sources/Auth/AuthClient.swift | 82 +++++++++++++++++-- Sources/Auth/AuthError.swift | 7 +- Sources/Supabase/SupabaseClient.swift | 1 + Sources/Supabase/Types.swift | 7 ++ 6 files changed, 117 insertions(+), 20 deletions(-) diff --git a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift b/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift index aea09de8..25257a56 100644 --- a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift +++ b/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift @@ -19,17 +19,14 @@ struct GoogleSignInWithWebFlow: View { } } + @MainActor private func signInWithGoogleButtonTapped() async { do { - let url = try await supabase.auth.getOAuthSignInURL( - provider: .google, - redirectTo: Constants.redirectToURL - ) - let urlWithToken = try await webAuthenticationSession.authenticate( - using: url, - callbackURLScheme: Constants.redirectToURL.scheme! - ) - try await supabase.auth.session(from: urlWithToken) + let contextProvider = DefaultPresentationContextProvider() + + try await supabase.auth.signInWithOAuth(provider: .google) { + $0.presentationContextProvider = contextProvider + } } catch { print("failed to sign in with Google: \(error)") } @@ -39,3 +36,21 @@ struct GoogleSignInWithWebFlow: View { #Preview { GoogleSignInWithWebFlow() } + +final class DefaultPresentationContextProvider: NSObject, + ASWebAuthenticationPresentationContextProviding, Sendable +{ + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + if Thread.isMainThread { + return MainActor.assumeIsolated { + ASPresentationAnchor() + } + } else { + return DispatchQueue.main.sync { + MainActor.assumeIsolated { + ASPresentationAnchor() + } + } + } + } +} diff --git a/Examples/Examples/ExamplesApp.swift b/Examples/Examples/ExamplesApp.swift index a79fe15a..9a70a425 100644 --- a/Examples/Examples/ExamplesApp.swift +++ b/Examples/Examples/ExamplesApp.swift @@ -22,7 +22,12 @@ struct ExamplesApp: App { let supabase = SupabaseClient( supabaseURL: Secrets.supabaseURL, supabaseKey: Secrets.supabaseAnonKey, - options: .init(global: .init(logger: ConsoleLogger())) + options: .init( + auth: .init(redirectToURL: Constants.redirectToURL), + global: .init( + logger: ConsoleLogger() + ) + ) ) struct ConsoleLogger: SupabaseLogger { diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 25f605a9..0c89b5f2 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1,5 +1,6 @@ import _Helpers import Foundation +import AuthenticationServices #if canImport(FoundationNetworking) import FoundationNetworking @@ -16,6 +17,7 @@ public actor AuthClient { public let url: URL public var headers: [String: String] public let flowType: AuthFlowType + public let redirectToURL: URL? public let localStorage: any AuthLocalStorage public let logger: (any SupabaseLogger)? public let encoder: JSONEncoder @@ -28,6 +30,7 @@ public actor AuthClient { /// - url: The base URL of the Auth server. /// - headers: Custom headers to be included in requests. /// - flowType: The authentication flow type. + /// - redirectToURL: Default URL to be used for redirect on the flows that requires it. /// - localStorage: The storage mechanism for local data. /// - logger: The logger to use. /// - encoder: The JSON encoder to use for encoding requests. @@ -37,6 +40,7 @@ public actor AuthClient { url: URL, headers: [String: String] = [:], flowType: AuthFlowType = Configuration.defaultFlowType, + redirectToURL: URL? = nil, localStorage: any AuthLocalStorage, logger: (any SupabaseLogger)? = nil, encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, @@ -48,6 +52,7 @@ public actor AuthClient { self.url = url self.headers = headers self.flowType = flowType + self.redirectToURL = redirectToURL self.localStorage = localStorage self.logger = logger self.encoder = encoder @@ -100,6 +105,7 @@ public actor AuthClient { /// - url: The base URL of the Auth server. /// - headers: Custom headers to be included in requests. /// - flowType: The authentication flow type.. + /// - redirectToURL: Default URL to be used for redirect on the flows that requires it. /// - localStorage: The storage mechanism for local data.. /// - logger: The logger to use. /// - encoder: The JSON encoder to use for encoding requests. @@ -109,6 +115,7 @@ public actor AuthClient { url: URL, headers: [String: String] = [:], flowType: AuthFlowType = AuthClient.Configuration.defaultFlowType, + redirectToURL: URL? = nil, localStorage: any AuthLocalStorage, logger: (any SupabaseLogger)? = nil, encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, @@ -120,6 +127,7 @@ public actor AuthClient { url: url, headers: headers, flowType: flowType, + redirectToURL: redirectToURL, localStorage: localStorage, logger: logger, encoder: encoder, @@ -243,7 +251,10 @@ public actor AuthClient { path: "/signup", method: .post, query: [ - redirectTo.map { URLQueryItem(name: "redirect_to", value: $0.absoluteString) }, + (redirectTo ?? configuration.redirectToURL).map { URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) }, ].compactMap { $0 }, body: configuration.encoder.encode( SignUpRequest( @@ -428,7 +439,10 @@ public actor AuthClient { path: "/otp", method: .post, query: [ - redirectTo.map { URLQueryItem(name: "redirect_to", value: $0.absoluteString) }, + (redirectTo ?? configuration.redirectToURL).map { URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) }, ].compactMap { $0 }, body: configuration.encoder.encode( OTPParams( @@ -504,7 +518,7 @@ public actor AuthClient { SignInWithSSORequest( providerId: nil, domain: domain, - redirectTo: redirectTo, + redirectTo: redirectTo ?? configuration.redirectToURL, gotrueMetaSecurity: captchaToken.map { AuthMetaSecurity(captchaToken: $0) }, codeChallenge: codeChallenge, codeChallengeMethod: codeChallengeMethod @@ -539,7 +553,7 @@ public actor AuthClient { SignInWithSSORequest( providerId: providerId, domain: nil, - redirectTo: redirectTo, + redirectTo: redirectTo ?? configuration.redirectToURL, gotrueMetaSecurity: captchaToken.map { AuthMetaSecurity(captchaToken: $0) }, codeChallenge: codeChallenge, codeChallengeMethod: codeChallengeMethod @@ -595,6 +609,49 @@ public actor AuthClient { ) } + @discardableResult + public func signInWithOAuth( + provider: Provider, + redirectTo: URL? = nil, + scopes: String? = nil, + queryParams: [(name: String, value: String?)] = [], + configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void = { _ in } + ) async throws -> Session { + guard let redirectTo = (redirectTo ?? configuration.redirectToURL), + let callbackScheme = redirectTo.scheme + else { + throw AuthError.invalidRedirectScheme + } + + let url = try getOAuthSignInURL( + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams + ) + + let resultURL: URL = try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackScheme + ) { url, error in + if let error { + continuation.resume(throwing: error) + } else if let url { + continuation.resume(returning: url) + } else { + fatalError() + } + } + + configure(session) + + session.start() + } + + return try await session(from: resultURL) + } + /// Gets the session data from a OAuth2 callback URL. @discardableResult public func session(from url: URL) async throws -> Session { @@ -756,7 +813,10 @@ public actor AuthClient { path: "/verify", method: .post, query: [ - redirectTo.map { URLQueryItem(name: "redirect_to", value: $0.absoluteString) }, + (redirectTo ?? configuration.redirectToURL).map { URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) }, ].compactMap { $0 }, body: configuration.encoder.encode( VerifyOTPParams.email( @@ -840,7 +900,10 @@ public actor AuthClient { path: "/resend", method: .post, query: [ - emailRedirectTo.map { URLQueryItem(name: "redirect_to", value: $0.absoluteString) }, + (emailRedirectTo ?? configuration.redirectToURL).map { URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) }, ].compactMap { $0 }, body: configuration.encoder.encode( ResendEmailParams( @@ -983,7 +1046,10 @@ public actor AuthClient { path: "/recover", method: .post, query: [ - redirectTo.map { URLQueryItem(name: "redirect_to", value: $0.absoluteString) }, + (redirectTo ?? configuration.redirectToURL).map { URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) }, ].compactMap { $0 }, body: configuration.encoder.encode( RecoverParams( @@ -1084,7 +1150,7 @@ public actor AuthClient { queryItems.append(URLQueryItem(name: "scopes", value: scopes)) } - if let redirectTo { + if let redirectTo = redirectTo ?? configuration.redirectToURL { queryItems.append(URLQueryItem(name: "redirect_to", value: redirectTo.absoluteString)) } diff --git a/Sources/Auth/AuthError.swift b/Sources/Auth/AuthError.swift index 5490b0b2..102c2bce 100644 --- a/Sources/Auth/AuthError.swift +++ b/Sources/Auth/AuthError.swift @@ -7,6 +7,8 @@ public enum AuthError: LocalizedError, Sendable, Equatable { case api(APIError) case pkce(PKCEFailureReason) case invalidImplicitGrantFlowURL + case missingURL + case invalidRedirectScheme public struct APIError: Error, Decodable, Sendable, Equatable { /// A basic message describing the problem with the request. Usually missing if @@ -49,8 +51,9 @@ public enum AuthError: LocalizedError, Sendable, Equatable { case .sessionNotFound: return "Unable to get a valid session." case .pkce(.codeVerifierNotFound): return "A code verifier wasn't found in PKCE flow." case .pkce(.invalidPKCEFlowURL): return "Not a valid PKCE flow url." - case .invalidImplicitGrantFlowURL: - return "Not a valid implicit grant flow url." + case .invalidImplicitGrantFlowURL: return "Not a valid implicit grant flow url." + case .missingURL: return "Missing URL." + case .invalidRedirectScheme: return "Invalid redirect scheme." } } } diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index df6b0657..a2ce7908 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -126,6 +126,7 @@ public final class SupabaseClient: @unchecked Sendable { url: supabaseURL.appendingPathComponent("/auth/v1"), headers: defaultHeaders, flowType: options.auth.flowType, + redirectToURL: options.auth.redirectToURL, localStorage: options.auth.storage, logger: options.global.logger, encoder: options.auth.encoder, diff --git a/Sources/Supabase/Types.swift b/Sources/Supabase/Types.swift index 99a754f2..b268b4f3 100644 --- a/Sources/Supabase/Types.swift +++ b/Sources/Supabase/Types.swift @@ -38,6 +38,9 @@ public struct SupabaseClientOptions: Sendable { /// A storage provider. Used to store the logged-in session. public let storage: any AuthLocalStorage + /// Default URL to be used for redirect on the flows that requires it. + public let redirectToURL: URL? + /// OAuth flow to use - defaults to PKCE flow. PKCE is recommended for mobile and server-side /// applications. public let flowType: AuthFlowType @@ -50,11 +53,13 @@ public struct SupabaseClientOptions: Sendable { public init( storage: any AuthLocalStorage, + redirectToURL: URL? = nil, flowType: AuthFlowType = AuthClient.Configuration.defaultFlowType, encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder ) { self.storage = storage + self.redirectToURL = redirectToURL self.flowType = flowType self.encoder = encoder self.decoder = decoder @@ -109,12 +114,14 @@ extension SupabaseClientOptions { extension SupabaseClientOptions.AuthOptions { #if !os(Linux) public init( + redirectToURL: URL? = nil, flowType: AuthFlowType = AuthClient.Configuration.defaultFlowType, encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder ) { self.init( storage: AuthClient.Configuration.defaultLocalStorage, + redirectToURL: redirectToURL, flowType: flowType, encoder: encoder, decoder: decoder From 393884351de971581effa4f780e121ee01120c5b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 1 Apr 2024 15:58:07 -0300 Subject: [PATCH 02/16] wip --- .../Auth/GoogleSignInWithWebFlow.swift | 24 +--------- Sources/Auth/AuthClient.swift | 44 +++++++++++++++++-- Sources/Auth/Deprecated.swift | 2 +- Sources/Auth/Internal/Dependencies.swift | 8 ++-- 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift b/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift index 25257a56..3d124592 100644 --- a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift +++ b/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift @@ -22,11 +22,7 @@ struct GoogleSignInWithWebFlow: View { @MainActor private func signInWithGoogleButtonTapped() async { do { - let contextProvider = DefaultPresentationContextProvider() - - try await supabase.auth.signInWithOAuth(provider: .google) { - $0.presentationContextProvider = contextProvider - } + try await supabase.auth.signInWithOAuth(provider: .google, using: webAuthenticationSession) } catch { print("failed to sign in with Google: \(error)") } @@ -36,21 +32,3 @@ struct GoogleSignInWithWebFlow: View { #Preview { GoogleSignInWithWebFlow() } - -final class DefaultPresentationContextProvider: NSObject, - ASWebAuthenticationPresentationContextProviding, Sendable -{ - func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { - if Thread.isMainThread { - return MainActor.assumeIsolated { - ASPresentationAnchor() - } - } else { - return DispatchQueue.main.sync { - MainActor.assumeIsolated { - ASPresentationAnchor() - } - } - } - } -} diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 0c89b5f2..2af96ac7 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -6,7 +6,7 @@ import AuthenticationServices import FoundationNetworking #endif -public actor AuthClient { +public final class AuthClient: Sendable { /// FetchHandler is a type alias for asynchronous network request handling. public typealias FetchHandler = @Sendable ( _ request: URLRequest @@ -111,7 +111,7 @@ public actor AuthClient { /// - encoder: The JSON encoder to use for encoding requests. /// - decoder: The JSON decoder to use for decoding responses. /// - fetch: The asynchronous fetch handler for network requests. - public init( + public convenience init( url: URL, headers: [String: String] = [:], flowType: AuthFlowType = AuthClient.Configuration.defaultFlowType, @@ -141,7 +141,7 @@ public actor AuthClient { /// /// - Parameters: /// - configuration: The client configuration. - public init(configuration: Configuration) { + public convenience init(configuration: Configuration) { let api = APIClient.live( configuration: configuration, http: HTTPClient( @@ -1190,3 +1190,41 @@ extension AuthClient { /// ``AuthClient/didChangeAuthStateNotification`` notification. public static let authChangeSessionInfoKey = "AuthClient.authChangeSession" } + +#if canImport(SwiftUI) + import SwiftUI + + extension AuthClient { + @available(iOS 16.4, *) + @discardableResult + public func signInWithOAuth( + provider: Provider, + using webAuthenticationSession: WebAuthenticationSession, + preferredBrowserSession: WebAuthenticationSession.BrowserSession? = nil, + redirectTo: URL? = nil, + scopes: String? = nil, + queryParams: [(name: String, value: String?)] = [] + ) async throws -> Session { + guard let redirectTo = (redirectTo ?? configuration.redirectToURL), + let callbackScheme = redirectTo.scheme + else { + throw AuthError.invalidRedirectScheme + } + + let url = try getOAuthSignInURL( + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams + ) + + let resultURL = try await webAuthenticationSession.authenticate( + using: url, + callbackURLScheme: callbackScheme, + preferredBrowserSession: preferredBrowserSession + ) + + return try await session(from: resultURL) + } + } +#endif diff --git a/Sources/Auth/Deprecated.swift b/Sources/Auth/Deprecated.swift index cd8d4e8f..3d547e76 100644 --- a/Sources/Auth/Deprecated.swift +++ b/Sources/Auth/Deprecated.swift @@ -105,7 +105,7 @@ extension AuthClient { deprecated, message: "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" ) - public init( + public convenience init( url: URL, headers: [String: String] = [:], flowType: AuthFlowType = Configuration.defaultFlowType, diff --git a/Sources/Auth/Internal/Dependencies.swift b/Sources/Auth/Internal/Dependencies.swift index a7f23ccd..6424ace9 100644 --- a/Sources/Auth/Internal/Dependencies.swift +++ b/Sources/Auth/Internal/Dependencies.swift @@ -31,14 +31,14 @@ var Current: Dependencies { } @propertyWrapper -struct Dependency { +struct Dependency: Sendable { var wrappedValue: Value { - Current[keyPath: keyPath] + Current[keyPath: keyPath.value] } - let keyPath: KeyPath + let keyPath: UncheckedSendable> init(_ keyPath: KeyPath) { - self.keyPath = keyPath + self.keyPath = UncheckedSendable(keyPath) } } From d50b71450f17e3585e2f72bbf66c4cda5c1c26c7 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 1 Apr 2024 16:38:19 -0300 Subject: [PATCH 03/16] Set unchecked sendable to AuthClient --- Sources/Auth/AuthClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 2af96ac7..49d09d6e 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -6,7 +6,7 @@ import AuthenticationServices import FoundationNetworking #endif -public final class AuthClient: Sendable { +public final class AuthClient: @unchecked Sendable { /// FetchHandler is a type alias for asynchronous network request handling. public typealias FetchHandler = @Sendable ( _ request: URLRequest From 69e06bc328934c2f704ef912f4f9abe72c0f06bd Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 1 Apr 2024 17:25:14 -0300 Subject: [PATCH 04/16] style: sort imports --- Sources/Auth/AuthClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 49d09d6e..132e5606 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1,6 +1,6 @@ import _Helpers -import Foundation import AuthenticationServices +import Foundation #if canImport(FoundationNetworking) import FoundationNetworking From c3c4e087987af11c58e3056325ff5cdd4a3dba5d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 1 Apr 2024 17:36:49 -0300 Subject: [PATCH 05/16] Add availability checks --- Sources/Auth/AuthClient.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 132e5606..d7a4d6e9 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -609,6 +609,7 @@ public final class AuthClient: @unchecked Sendable { ) } + @available(watchOS 6.2, tvOS 16.0, *) @discardableResult public func signInWithOAuth( provider: Provider, @@ -1195,7 +1196,7 @@ extension AuthClient { import SwiftUI extension AuthClient { - @available(iOS 16.4, *) + @available(iOS 16.4, macOS 13.3, watchOS 9.4, tvOS 16.4, *) @discardableResult public func signInWithOAuth( provider: Provider, From 1cf00ec03d5a78ad73669f42472d61a2a023744b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 05:20:46 -0300 Subject: [PATCH 06/16] feat: add generic launchFlow param --- Sources/Auth/AuthClient.swift | 97 ++++++++++++++++----------- Sources/Supabase/SupabaseClient.swift | 2 +- 2 files changed, 59 insertions(+), 40 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index d7a4d6e9..27a23196 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -609,18 +609,15 @@ public final class AuthClient: @unchecked Sendable { ) } - @available(watchOS 6.2, tvOS 16.0, *) @discardableResult public func signInWithOAuth( provider: Provider, redirectTo: URL? = nil, scopes: String? = nil, queryParams: [(name: String, value: String?)] = [], - configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void = { _ in } + launchFlow: @MainActor @Sendable (_ url: URL) async throws -> URL ) async throws -> Session { - guard let redirectTo = (redirectTo ?? configuration.redirectToURL), - let callbackScheme = redirectTo.scheme - else { + guard let redirectTo = (redirectTo ?? configuration.redirectToURL) else { throw AuthError.invalidRedirectScheme } @@ -631,24 +628,7 @@ public final class AuthClient: @unchecked Sendable { queryParams: queryParams ) - let resultURL: URL = try await withCheckedThrowingContinuation { continuation in - let session = ASWebAuthenticationSession( - url: url, - callbackURLScheme: callbackScheme - ) { url, error in - if let error { - continuation.resume(throwing: error) - } else if let url { - continuation.resume(returning: url) - } else { - fatalError() - } - } - - configure(session) - - session.start() - } + let resultURL = try await launchFlow(url) return try await session(from: resultURL) } @@ -1192,6 +1172,49 @@ extension AuthClient { public static let authChangeSessionInfoKey = "AuthClient.authChangeSession" } +extension AuthClient { + @available(watchOS 6.2, tvOS 16.0, *) + @discardableResult + public func signInWithOAuth( + provider: Provider, + redirectTo: URL? = nil, + scopes: String? = nil, + queryParams: [(name: String, value: String?)] = [], + configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void + ) async throws -> Session { + try await signInWithOAuth( + provider: provider, + redirectTo: redirectTo, + scopes: scopes, + queryParams: queryParams + ) { url in + try await withCheckedThrowingContinuation { continuation in + guard let callbackScheme = url.scheme else { + continuation.resume(throwing: AuthError.invalidRedirectScheme) + return + } + + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackScheme + ) { url, error in + if let error { + continuation.resume(throwing: error) + } else if let url { + continuation.resume(returning: url) + } else { + fatalError() + } + } + + configure(session) + + session.start() + } + } + } +} + #if canImport(SwiftUI) import SwiftUI @@ -1206,26 +1229,22 @@ extension AuthClient { scopes: String? = nil, queryParams: [(name: String, value: String?)] = [] ) async throws -> Session { - guard let redirectTo = (redirectTo ?? configuration.redirectToURL), - let callbackScheme = redirectTo.scheme - else { - throw AuthError.invalidRedirectScheme - } - - let url = try getOAuthSignInURL( + try await signInWithOAuth( provider: provider, - scopes: scopes, redirectTo: redirectTo, + scopes: scopes, queryParams: queryParams - ) - - let resultURL = try await webAuthenticationSession.authenticate( - using: url, - callbackURLScheme: callbackScheme, - preferredBrowserSession: preferredBrowserSession - ) + ) { url in + guard let callbackScheme = url.scheme else { + throw AuthError.invalidRedirectScheme + } - return try await session(from: resultURL) + return try await webAuthenticationSession.authenticate( + using: url, + callbackURLScheme: callbackScheme, + preferredBrowserSession: preferredBrowserSession + ) + } } } #endif diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index a2ce7908..0e8bddf5 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -257,7 +257,7 @@ public final class SupabaseClient: @unchecked Sendable { private func listenForAuthEvents() { listenForAuthEventsTask.setValue( Task { - for await (event, session) in await auth.authStateChanges { + for await (event, session) in auth.authStateChanges { await handleTokenChanged(event: event, session: session) } } From 651595d90011da6ea944858af5c595283435ced2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 11:56:07 -0300 Subject: [PATCH 07/16] feat(auth): add default OAuth flow that uses ASWebAuthenticationSession --- Sources/Auth/AuthClient.swift | 142 +++++++++++++++------------------- 1 file changed, 64 insertions(+), 78 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 27a23196..c1d6d3b2 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -609,13 +609,24 @@ public final class AuthClient: @unchecked Sendable { ) } + /// Sign-in an existing user via a third-party provider. + /// + /// - Parameters: + /// - provider: The third-party provider. + /// - redirectTo: A URL to send the user to after they are confirmed. + /// - scopes: A space-separated list of scopes granted to the OAuth application. + /// - queryParams: Additional query params. + /// - launchFlow: A launch closure that you can use to implement the authentication flow. Use + /// the `url` to initiate the flow and return a `URL` that contains the OAuth result. + /// + /// - Note: This method support the PKCE flow. @discardableResult public func signInWithOAuth( provider: Provider, redirectTo: URL? = nil, scopes: String? = nil, queryParams: [(name: String, value: String?)] = [], - launchFlow: @MainActor @Sendable (_ url: URL) async throws -> URL + launchFlow: @Sendable (_ url: URL) async throws -> URL ) async throws -> Session { guard let redirectTo = (redirectTo ?? configuration.redirectToURL) else { throw AuthError.invalidRedirectScheme @@ -633,6 +644,58 @@ public final class AuthClient: @unchecked Sendable { return try await session(from: resultURL) } + /// Sign-in an existing user via a third-party provider. + /// + /// - Parameters: + /// - provider: The third-party provider. + /// - redirectTo: A URL to send the user to after they are confirmed. + /// - scopes: A space-separated list of scopes granted to the OAuth application. + /// - queryParams: Additional query params. + /// - configure: A configuration closure that you can use to customize the internal + /// ``ASWebAuthenticationSession`` object. + /// + /// - Note: This method support the PKCE flow. + @available(watchOS 6.2, tvOS 16.0, *) + @discardableResult + public func signInWithOAuth( + provider: Provider, + redirectTo: URL? = nil, + scopes: String? = nil, + queryParams: [(name: String, value: String?)] = [], + configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void + ) async throws -> Session { + try await signInWithOAuth( + provider: provider, + redirectTo: redirectTo, + scopes: scopes, + queryParams: queryParams + ) { @MainActor url in + try await withCheckedThrowingContinuation { continuation in + guard let callbackScheme = url.scheme else { + continuation.resume(throwing: AuthError.invalidRedirectScheme) + return + } + + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackScheme + ) { url, error in + if let error { + continuation.resume(throwing: error) + } else if let url { + continuation.resume(returning: url) + } else { + continuation.resume(throwing: AuthError.missingURL) + } + } + + configure(session) + + session.start() + } + } + } + /// Gets the session data from a OAuth2 callback URL. @discardableResult public func session(from url: URL) async throws -> Session { @@ -1171,80 +1234,3 @@ extension AuthClient { /// ``AuthClient/didChangeAuthStateNotification`` notification. public static let authChangeSessionInfoKey = "AuthClient.authChangeSession" } - -extension AuthClient { - @available(watchOS 6.2, tvOS 16.0, *) - @discardableResult - public func signInWithOAuth( - provider: Provider, - redirectTo: URL? = nil, - scopes: String? = nil, - queryParams: [(name: String, value: String?)] = [], - configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void - ) async throws -> Session { - try await signInWithOAuth( - provider: provider, - redirectTo: redirectTo, - scopes: scopes, - queryParams: queryParams - ) { url in - try await withCheckedThrowingContinuation { continuation in - guard let callbackScheme = url.scheme else { - continuation.resume(throwing: AuthError.invalidRedirectScheme) - return - } - - let session = ASWebAuthenticationSession( - url: url, - callbackURLScheme: callbackScheme - ) { url, error in - if let error { - continuation.resume(throwing: error) - } else if let url { - continuation.resume(returning: url) - } else { - fatalError() - } - } - - configure(session) - - session.start() - } - } - } -} - -#if canImport(SwiftUI) - import SwiftUI - - extension AuthClient { - @available(iOS 16.4, macOS 13.3, watchOS 9.4, tvOS 16.4, *) - @discardableResult - public func signInWithOAuth( - provider: Provider, - using webAuthenticationSession: WebAuthenticationSession, - preferredBrowserSession: WebAuthenticationSession.BrowserSession? = nil, - redirectTo: URL? = nil, - scopes: String? = nil, - queryParams: [(name: String, value: String?)] = [] - ) async throws -> Session { - try await signInWithOAuth( - provider: provider, - redirectTo: redirectTo, - scopes: scopes, - queryParams: queryParams - ) { url in - guard let callbackScheme = url.scheme else { - throw AuthError.invalidRedirectScheme - } - - return try await webAuthenticationSession.authenticate( - using: url, - callbackURLScheme: callbackScheme, - preferredBrowserSession: preferredBrowserSession - ) - } - } - } -#endif From d3f3ca87bb3391c7249ab05229d5cc23d96d261c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 13:19:06 -0300 Subject: [PATCH 08/16] chore: add example usage for new signInWithOAuth method --- Examples/Examples/Auth/GoogleSignInWithWebFlow.swift | 7 ++++++- Sources/Auth/AuthClient.swift | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift b/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift index 3d124592..78367d8f 100644 --- a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift +++ b/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift @@ -22,7 +22,12 @@ struct GoogleSignInWithWebFlow: View { @MainActor private func signInWithGoogleButtonTapped() async { do { - try await supabase.auth.signInWithOAuth(provider: .google, using: webAuthenticationSession) + try await supabase.auth.signInWithOAuth(provider: .google) { url in + try await webAuthenticationSession.authenticate( + using: url, + callbackURLScheme: url.scheme! + ) + } } catch { print("failed to sign in with Google: \(error)") } diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index c1d6d3b2..0130c9fe 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -626,7 +626,7 @@ public final class AuthClient: @unchecked Sendable { redirectTo: URL? = nil, scopes: String? = nil, queryParams: [(name: String, value: String?)] = [], - launchFlow: @Sendable (_ url: URL) async throws -> URL + launchFlow: @MainActor @Sendable (_ url: URL) async throws -> URL ) async throws -> Session { guard let redirectTo = (redirectTo ?? configuration.redirectToURL) else { throw AuthError.invalidRedirectScheme From 0cdd5b37ba015fb56777c0e103e0492ec7ca1d45 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 14:00:41 -0300 Subject: [PATCH 09/16] test: add test for signInWithOAuth method --- Tests/AuthTests/AuthClientTests.swift | 95 ++++++++++++++++++++++++++- Tests/AuthTests/RequestsTests.swift | 58 ++++++++-------- 2 files changed, 120 insertions(+), 33 deletions(-) diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index bea1f254..6a171912 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -20,6 +20,8 @@ final class AuthClientTests: XCTestCase { var eventEmitter: Auth.EventEmitter! var sessionManager: SessionManager! + var sessionStorage: SessionStorage! + var codeVerifierStorage: CodeVerifierStorage! var api: APIClient! var sut: AuthClient! @@ -33,6 +35,8 @@ final class AuthClientTests: XCTestCase { super.setUp() Current = .mock + sessionStorage = .mock + codeVerifierStorage = .mock eventEmitter = .mock sessionManager = .mock api = .mock @@ -250,6 +254,93 @@ final class AuthClientTests: XCTestCase { XCTAssertEqual(events, [.signedIn]) } + func testSignInWithOAuth() async throws { + let emitReceivedEvents = LockIsolated<[(AuthChangeEvent, Session?)]>([]) + + eventEmitter.emit = { @Sendable event, session, _ in + emitReceivedEvents.withValue { + $0.append((event, session)) + } + } + + sessionStorage = .live + codeVerifierStorage = .live + sessionManager = .live + + api.execute = { @Sendable _ in + .stub( + """ + { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjQ4NjQwMDIxLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6Imd1aWxoZXJtZTJAZ3Jkcy5kZXYiLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImVtYWlsIiwicHJvdmlkZXJzIjpbImVtYWlsIl19LCJ1c2VyX21ldGFkYXRhIjp7fSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQifQ.4lMvmz2pJkWu1hMsBgXP98Fwz4rbvFYl4VA9joRv6kY", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "GGduTeu95GraIXQ56jppkw", + "user": { + "id": "f33d3ec9-a2ee-47c4-80e1-5bd919f3d8b8", + "aud": "authenticated", + "role": "authenticated", + "email": "guilherme@binaryscraping.co", + "email_confirmed_at": "2022-03-30T10:33:41.018575157Z", + "phone": "", + "last_sign_in_at": "2022-03-30T10:33:41.021531328Z", + "app_metadata": { + "provider": "email", + "providers": [ + "email" + ] + }, + "user_metadata": {}, + "identities": [ + { + "id": "f33d3ec9-a2ee-47c4-80e1-5bd919f3d8b8", + "user_id": "f33d3ec9-a2ee-47c4-80e1-5bd919f3d8b8", + "identity_id": "859f402d-b3de-4105-a1b9-932836d9193b", + "identity_data": { + "sub": "f33d3ec9-a2ee-47c4-80e1-5bd919f3d8b8" + }, + "provider": "email", + "last_sign_in_at": "2022-03-30T10:33:41.015557063Z", + "created_at": "2022-03-30T10:33:41.015612Z", + "updated_at": "2022-03-30T10:33:41.015616Z" + } + ], + "created_at": "2022-03-30T10:33:41.005433Z", + "updated_at": "2022-03-30T10:33:41.022688Z" + } + } + """ + ) + } + + let sut = makeSUT() + + try await sut.signInWithOAuth( + provider: .google, + redirectTo: URL(string: "supabase://auth-callback") + ) { (url: URL) in + URL(string: "supabase://auth-callback?code=12345") ?? url + } + + XCTAssertEqual(emitReceivedEvents.value.map(\.0), [.signedIn]) + } + + func testSignInWithOAuthWithInvalidRedirecTo() async { + let sut = makeSUT() + + do { + try await sut.signInWithOAuth( + provider: .google, + redirectTo: nil + ) { _ in + XCTFail("Should not call launchFlow.") + } + } catch let error as AuthError { + XCTAssertEqual(error, .invalidRedirectScheme) + } catch { + XCTFail("Unexcpted error: \(error)") + } + } + private func makeSUT() -> AuthClient { let configuration = AuthClient.Configuration( url: clientURL, @@ -261,10 +352,10 @@ final class AuthClientTests: XCTestCase { let sut = AuthClient( configuration: configuration, sessionManager: sessionManager, - codeVerifierStorage: .mock, + codeVerifierStorage: codeVerifierStorage, api: api, eventEmitter: eventEmitter, - sessionStorage: .mock, + sessionStorage: sessionStorage, logger: nil ) diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 60ad5ec0..89d93ba1 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -125,7 +125,7 @@ final class RequestsTests: XCTestCase { func testGetOAuthSignInURL() async throws { let sut = makeSUT() - let url = try await sut.getOAuthSignInURL( + let url = try sut.getOAuthSignInURL( provider: .github, scopes: "read,write", redirectTo: URL(string: "https://dummy-url.com/redirect")!, queryParams: [("extra_key", "extra_value")] @@ -146,40 +146,36 @@ final class RequestsTests: XCTestCase { } } - #if !os(Windows) && !os(Linux) - // For some reason this crashes the testing bundle - // on Linux and Windows, skipping it. - func testSessionFromURL() async throws { - let sut = makeSUT(fetch: { request in - let authorizationHeader = request.allHTTPHeaderFields?["Authorization"] - XCTAssertEqual(authorizationHeader, "bearer accesstoken") - return (json(named: "user"), HTTPURLResponse()) - }) + func testSessionFromURL() async throws { + let sut = makeSUT(fetch: { request in + let authorizationHeader = request.allHTTPHeaderFields?["Authorization"] + XCTAssertEqual(authorizationHeader, "bearer accesstoken") + return (json(named: "user"), HTTPURLResponse()) + }) - let currentDate = Date() + let currentDate = Date() - Current.sessionManager = .live - Current.sessionStorage.storeSession = { _ in } - Current.codeVerifierStorage.get = { nil } - Current.currentDate = { currentDate } + Current.sessionManager = .live + Current.sessionStorage.storeSession = { _ in } + Current.codeVerifierStorage.get = { nil } + Current.currentDate = { currentDate } - let url = URL( - string: - "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken&token_type=bearer" - )! + let url = URL( + string: + "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken&token_type=bearer" + )! - let session = try await sut.session(from: url) - let expectedSession = Session( - accessToken: "accesstoken", - tokenType: "bearer", - expiresIn: 60, - expiresAt: currentDate.addingTimeInterval(60).timeIntervalSince1970, - refreshToken: "refreshtoken", - user: User(fromMockNamed: "user") - ) - XCTAssertEqual(session, expectedSession) - } - #endif + let session = try await sut.session(from: url) + let expectedSession = Session( + accessToken: "accesstoken", + tokenType: "bearer", + expiresIn: 60, + expiresAt: currentDate.addingTimeInterval(60).timeIntervalSince1970, + refreshToken: "refreshtoken", + user: User(fromMockNamed: "user") + ) + XCTAssertEqual(session, expectedSession) + } func testSessionFromURLWithMissingComponent() async { let sut = makeSUT() From 2168d48f3a08bdfd16b248cd056564ab0975615c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 14:52:05 -0300 Subject: [PATCH 10/16] chore: add examples for `signInWithOAuth` method --- Examples/Examples.xcodeproj/project.pbxproj | 12 +- Examples/Examples/Auth/AuthView.swift | 10 +- .../Auth/GoogleSignInWithWebFlow.swift | 39 ------ Examples/Examples/Auth/SignInWithOAuth.swift | 129 ++++++++++++++++++ .../Examples/UIViewControllerWrapper.swift | 26 ++++ Sources/Auth/AuthClient.swift | 27 +++- 6 files changed, 193 insertions(+), 50 deletions(-) delete mode 100644 Examples/Examples/Auth/GoogleSignInWithWebFlow.swift create mode 100644 Examples/Examples/Auth/SignInWithOAuth.swift create mode 100644 Examples/Examples/UIViewControllerWrapper.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index f91f5d37..47f0f4ed 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -14,7 +14,8 @@ 793E03092B2CED5D00AC7DED /* Contants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793E03082B2CED5D00AC7DED /* Contants.swift */; }; 793E030B2B2CEDDA00AC7DED /* ActionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793E030A2B2CEDDA00AC7DED /* ActionState.swift */; }; 793E030D2B2DAB5700AC7DED /* SignInWithApple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 793E030C2B2DAB5700AC7DED /* SignInWithApple.swift */; }; - 7940E3152B36187A0089BEE1 /* GoogleSignInWithWebFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7940E3142B36187A0089BEE1 /* GoogleSignInWithWebFlow.swift */; }; + 79401F332BC6FEAE004C9C0F /* SignInWithOAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79401F322BC6FEAE004C9C0F /* SignInWithOAuth.swift */; }; + 79401F352BC708C8004C9C0F /* UIViewControllerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79401F342BC708C8004C9C0F /* UIViewControllerWrapper.swift */; }; 794C61D62BAD1E12000E6B0F /* UserIdentityList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794C61D52BAD1E12000E6B0F /* UserIdentityList.swift */; }; 794EF1222955F26A008C9526 /* AddTodoListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794EF1212955F26A008C9526 /* AddTodoListView.swift */; }; 794EF1242955F3DE008C9526 /* TodoListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794EF1232955F3DE008C9526 /* TodoListRow.swift */; }; @@ -83,7 +84,8 @@ 793E03082B2CED5D00AC7DED /* Contants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contants.swift; sourceTree = ""; }; 793E030A2B2CEDDA00AC7DED /* ActionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionState.swift; sourceTree = ""; }; 793E030C2B2DAB5700AC7DED /* SignInWithApple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInWithApple.swift; sourceTree = ""; }; - 7940E3142B36187A0089BEE1 /* GoogleSignInWithWebFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleSignInWithWebFlow.swift; sourceTree = ""; }; + 79401F322BC6FEAE004C9C0F /* SignInWithOAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInWithOAuth.swift; sourceTree = ""; }; + 79401F342BC708C8004C9C0F /* UIViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerWrapper.swift; sourceTree = ""; }; 794C61D52BAD1E12000E6B0F /* UserIdentityList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIdentityList.swift; sourceTree = ""; }; 794EF1212955F26A008C9526 /* AddTodoListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTodoListView.swift; sourceTree = ""; }; 794EF1232955F3DE008C9526 /* TodoListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoListRow.swift; sourceTree = ""; }; @@ -221,6 +223,7 @@ 79E2B55B2B97A2310042CD21 /* UIApplicationExtensions.swift */, 797EFB672BABD90500098D6B /* Stringfy.swift */, 79B1C80D2BAC017C00D991AA /* AnyJSONView.swift */, + 79401F342BC708C8004C9C0F /* UIViewControllerWrapper.swift */, ); path = Examples; sourceTree = ""; @@ -258,9 +261,9 @@ 79AF047E2B2CE207008761AD /* AuthWithEmailAndPassword.swift */, 79AF04832B2CE408008761AD /* AuthWithMagicLink.swift */, 793E030C2B2DAB5700AC7DED /* SignInWithApple.swift */, - 7940E3142B36187A0089BEE1 /* GoogleSignInWithWebFlow.swift */, 79E2B5542B9788BF0042CD21 /* GoogleSignInSDKFlow.swift */, 79C9B8E42BBB16C0003AD942 /* SignInAnonymously.swift */, + 79401F322BC6FEAE004C9C0F /* SignInWithOAuth.swift */, ); path = Auth; sourceTree = ""; @@ -486,6 +489,8 @@ 796298992AEBBA77000AA957 /* MFAFlow.swift in Sources */, 79AF04862B2CE586008761AD /* Debug.swift in Sources */, 79AF04842B2CE408008761AD /* AuthWithMagicLink.swift in Sources */, + 79401F352BC708C8004C9C0F /* UIViewControllerWrapper.swift in Sources */, + 79401F332BC6FEAE004C9C0F /* SignInWithOAuth.swift in Sources */, 79B1C80E2BAC017C00D991AA /* AnyJSONView.swift in Sources */, 79E2B5552B9788BF0042CD21 /* GoogleSignInSDKFlow.swift in Sources */, 793E03092B2CED5D00AC7DED /* Contants.swift in Sources */, @@ -504,7 +509,6 @@ 795640602954AE140088A06F /* AuthController.swift in Sources */, 79AF047F2B2CE207008761AD /* AuthWithEmailAndPassword.swift in Sources */, 795640622955AD2B0088A06F /* HomeView.swift in Sources */, - 7940E3152B36187A0089BEE1 /* GoogleSignInWithWebFlow.swift in Sources */, 793895CA2954ABFF0044F2B8 /* ExamplesApp.swift in Sources */, 797EFB682BABD90500098D6B /* Stringfy.swift in Sources */, 797EFB6C2BABE1B800098D6B /* FileObjectDetailView.swift in Sources */, diff --git a/Examples/Examples/Auth/AuthView.swift b/Examples/Examples/Auth/AuthView.swift index ee9ab364..93adb741 100644 --- a/Examples/Examples/Auth/AuthView.swift +++ b/Examples/Examples/Auth/AuthView.swift @@ -12,7 +12,8 @@ struct AuthView: View { case emailAndPassword case magicLink case signInWithApple - case googleSignInWebFlow + case signInWithOAuth + case signInWithOAuthUsingUIKit case googleSignInSDKFlow case signInAnonymously @@ -21,7 +22,8 @@ struct AuthView: View { case .emailAndPassword: "Auth with Email & Password" case .magicLink: "Auth with Magic Link" case .signInWithApple: "Sign in with Apple" - case .googleSignInWebFlow: "Google Sign in (Web Flow)" + case .signInWithOAuth: "Sign in with OAuth flow" + case .signInWithOAuthUsingUIKit: "Sign in with OAuth flow (UIKit)" case .googleSignInSDKFlow: "Google Sign in (GIDSignIn SDK Flow)" case .signInAnonymously: "Sign in Anonymously" } @@ -50,7 +52,9 @@ extension AuthView.Option: View { case .emailAndPassword: AuthWithEmailAndPassword() case .magicLink: AuthWithMagicLink() case .signInWithApple: SignInWithApple() - case .googleSignInWebFlow: GoogleSignInWithWebFlow() + case .signInWithOAuth: SignInWithOAuth() + case .signInWithOAuthUsingUIKit: UIViewControllerWrapper(SignInWithOAuthViewController()) + .edgesIgnoringSafeArea(.all) case .googleSignInSDKFlow: GoogleSignInSDKFlow() case .signInAnonymously: SignInAnonymously() } diff --git a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift b/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift deleted file mode 100644 index 78367d8f..00000000 --- a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// GoogleSignInWithWebFlow.swift -// Examples -// -// Created by Guilherme Souza on 22/12/23. -// - -import AuthenticationServices -import SwiftUI - -struct GoogleSignInWithWebFlow: View { - @Environment(\.webAuthenticationSession) var webAuthenticationSession - - var body: some View { - Button("Sign in with Google") { - Task { - await signInWithGoogleButtonTapped() - } - } - } - - @MainActor - private func signInWithGoogleButtonTapped() async { - do { - try await supabase.auth.signInWithOAuth(provider: .google) { url in - try await webAuthenticationSession.authenticate( - using: url, - callbackURLScheme: url.scheme! - ) - } - } catch { - print("failed to sign in with Google: \(error)") - } - } -} - -#Preview { - GoogleSignInWithWebFlow() -} diff --git a/Examples/Examples/Auth/SignInWithOAuth.swift b/Examples/Examples/Auth/SignInWithOAuth.swift new file mode 100644 index 00000000..f66f9b89 --- /dev/null +++ b/Examples/Examples/Auth/SignInWithOAuth.swift @@ -0,0 +1,129 @@ +// +// SignInWithOAuth.swift +// Examples +// +// Created by Guilherme Souza on 10/04/24. +// + +import AuthenticationServices +import Supabase +import SwiftUI + +struct SignInWithOAuth: View { + let providers = Provider.allCases + + @State var provider = Provider.allCases[0] + @Environment(\.webAuthenticationSession) var webAuthenticationSession + + var body: some View { + VStack { + Picker("Provider", selection: $provider) { + ForEach(providers) { provider in + Text("\(provider)").tag(provider) + } + } + + Button("Start Sign-in Flow") { + Task { + do { + try await supabase.auth.signInWithOAuth( + provider: provider, + redirectTo: Constants.redirectToURL, + launchFlow: { @MainActor url in + try await webAuthenticationSession.authenticate( + using: url, + callbackURLScheme: Constants.redirectToURL.scheme! + ) + } + ) + } catch { + debug("Failed to sign-in with OAuth flow: \(error)") + } + } + } + } + } +} + +final class SignInWithOAuthViewController: UIViewController, UIPickerViewDataSource, + UIPickerViewDelegate, ASWebAuthenticationPresentationContextProviding +{ + let providers = Provider.allCases + var provider = Provider.allCases[0] + + let providerPicker = UIPickerView() + let signInButton = UIButton(type: .system) + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + } + + func setupViews() { + view.backgroundColor = .white + + providerPicker.dataSource = self + providerPicker.delegate = self + view.addSubview(providerPicker) + providerPicker.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + providerPicker.centerXAnchor.constraint(equalTo: view.centerXAnchor), + providerPicker.centerYAnchor.constraint(equalTo: view.centerYAnchor), + providerPicker.widthAnchor.constraint(equalToConstant: 200), + providerPicker.heightAnchor.constraint(equalToConstant: 100), + ]) + + signInButton.setTitle("Start Sign-in Flow", for: .normal) + signInButton.addTarget(self, action: #selector(signInButtonTapped), for: .touchUpInside) + view.addSubview(signInButton) + signInButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + signInButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + signInButton.topAnchor.constraint(equalTo: providerPicker.bottomAnchor, constant: 20), + ]) + } + + @objc func signInButtonTapped() { + Task { + do { + try await supabase.auth.signInWithOAuth( + provider: provider, + redirectTo: Constants.redirectToURL, + configure: { session in + session.presentationContextProvider = self + } + ) + } catch { + debug("Failed to sign-in with OAuth flow: \(error)") + } + } + } + + func numberOfComponents(in _: UIPickerView) -> Int { + 1 + } + + func pickerView(_: UIPickerView, numberOfRowsInComponent _: Int) -> Int { + providers.count + } + + func pickerView(_: UIPickerView, titleForRow row: Int, forComponent _: Int) -> String? { + "\(providers[row])" + } + + func pickerView(_: UIPickerView, didSelectRow row: Int, inComponent _: Int) { + provider = providers[row] + } + + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + view.window ?? UIWindow() + } +} + +#Preview("SwiftUI") { + SignInWithOAuth() +} + +#Preview("UIKit") { + SignInWithOAuthViewController() +} diff --git a/Examples/Examples/UIViewControllerWrapper.swift b/Examples/Examples/UIViewControllerWrapper.swift new file mode 100644 index 00000000..7738a041 --- /dev/null +++ b/Examples/Examples/UIViewControllerWrapper.swift @@ -0,0 +1,26 @@ +// +// UIViewControllerWrapper.swift +// Examples +// +// Created by Guilherme Souza on 10/04/24. +// + +import SwiftUI + +struct UIViewControllerWrapper: UIViewControllerRepresentable { + typealias UIViewControllerType = T + + let viewController: T + + init(_ viewController: T) { + self.viewController = viewController + } + + func makeUIViewController(context _: Context) -> T { + viewController + } + + func updateUIViewController(_: T, context _: Context) { + // Update the view controller if needed + } +} diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 0130c9fe..7a1c03de 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -594,14 +594,18 @@ public final class AuthClient: @unchecked Sendable { } /// Log in an existing user via a third-party provider. + @available( + *, + deprecated, + message: "Use `signInWithOAuth` method to implement sign-in with third-party provider. Use parameter `launchFlow` to customize how the OAuth flow is launched in your application." + ) public func getOAuthSignInURL( provider: Provider, scopes: String? = nil, redirectTo: URL? = nil, queryParams: [(name: String, value: String?)] = [] ) throws -> URL { - try getURLForProvider( - url: configuration.url.appendingPathComponent("authorize"), + try _getOAuthSignInURL( provider: provider, scopes: scopes, redirectTo: redirectTo, @@ -632,7 +636,7 @@ public final class AuthClient: @unchecked Sendable { throw AuthError.invalidRedirectScheme } - let url = try getOAuthSignInURL( + let url = try _getOAuthSignInURL( provider: provider, scopes: scopes, redirectTo: redirectTo, @@ -644,7 +648,7 @@ public final class AuthClient: @unchecked Sendable { return try await session(from: resultURL) } - /// Sign-in an existing user via a third-party provider. + /// Sign-in an existing user via a third-party provider using ``ASWebAuthenticationSession``. /// /// - Parameters: /// - provider: The third-party provider. @@ -1218,6 +1222,21 @@ public final class AuthClient: @unchecked Sendable { return url } + + private func _getOAuthSignInURL( + provider: Provider, + scopes: String? = nil, + redirectTo: URL? = nil, + queryParams: [(name: String, value: String?)] = [] + ) throws -> URL { + try getURLForProvider( + url: configuration.url.appendingPathComponent("authorize"), + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams + ) + } } extension AuthClient { From 6f7472ff9909e95ebe07f0d4e4b0c166a5236371 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 14:55:23 -0300 Subject: [PATCH 11/16] fix: callbackScheme for ASWebAuthenticationSession --- Sources/Auth/AuthClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 7a1c03de..02cbe5e8 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -675,7 +675,7 @@ public final class AuthClient: @unchecked Sendable { queryParams: queryParams ) { @MainActor url in try await withCheckedThrowingContinuation { continuation in - guard let callbackScheme = url.scheme else { + guard let callbackScheme = (configuration.redirectToURL ?? redirectTo)?.scheme else { continuation.resume(throwing: AuthError.invalidRedirectScheme) return } From 1403b53a53a966ff51be6dfe2386ede928804f1f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 15:04:22 -0300 Subject: [PATCH 12/16] keep getOAuthSignInURL and improve documentation --- Sources/Auth/AuthClient.swift | 37 +++++++++++++---------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 02cbe5e8..2ae28f75 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -593,19 +593,23 @@ public final class AuthClient: @unchecked Sendable { return session } - /// Log in an existing user via a third-party provider. - @available( - *, - deprecated, - message: "Use `signInWithOAuth` method to implement sign-in with third-party provider. Use parameter `launchFlow` to customize how the OAuth flow is launched in your application." - ) + /// Get a URL which you can use to start an OAuth flow for a third-party provider. + /// + /// Use this method if you want to have full control over the OAuth flow implementation, once you + /// have result URL with a OAuth token, use method ``session(from:)`` to load the session + /// into the client. + /// + /// If that isn't the case, you should consider using + /// ``signInWithOAuth(provider:redirectTo:scopes:queryParams:launchFlow:)`` or + /// ``signInWithOAuth(provider:redirectTo:scopes:queryParams:configure:)``. public func getOAuthSignInURL( provider: Provider, scopes: String? = nil, redirectTo: URL? = nil, queryParams: [(name: String, value: String?)] = [] ) throws -> URL { - try _getOAuthSignInURL( + try getURLForProvider( + url: configuration.url.appendingPathComponent("authorize"), provider: provider, scopes: scopes, redirectTo: redirectTo, @@ -636,7 +640,7 @@ public final class AuthClient: @unchecked Sendable { throw AuthError.invalidRedirectScheme } - let url = try _getOAuthSignInURL( + let url = try getOAuthSignInURL( provider: provider, scopes: scopes, redirectTo: redirectTo, @@ -659,6 +663,8 @@ public final class AuthClient: @unchecked Sendable { /// ``ASWebAuthenticationSession`` object. /// /// - Note: This method support the PKCE flow. + /// - Warning: Do not call `start()` on the `ASWebAuthenticationSession` object inside the + /// `configure` closure, as the method implementation calls it already. @available(watchOS 6.2, tvOS 16.0, *) @discardableResult public func signInWithOAuth( @@ -1222,21 +1228,6 @@ public final class AuthClient: @unchecked Sendable { return url } - - private func _getOAuthSignInURL( - provider: Provider, - scopes: String? = nil, - redirectTo: URL? = nil, - queryParams: [(name: String, value: String?)] = [] - ) throws -> URL { - try getURLForProvider( - url: configuration.url.appendingPathComponent("authorize"), - provider: provider, - scopes: scopes, - redirectTo: redirectTo, - queryParams: queryParams - ) - } } extension AuthClient { From e3109019ab0e5e5dee458f75473d7f878fd5b83e Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 15:13:20 -0300 Subject: [PATCH 13/16] add available check to tests --- Tests/AuthTests/AuthClientTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 6a171912..4d2cd6b8 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -324,6 +324,7 @@ final class AuthClientTests: XCTestCase { XCTAssertEqual(emitReceivedEvents.value.map(\.0), [.signedIn]) } + @available(watchOS 6.2, tvOS 16.0, *) func testSignInWithOAuthWithInvalidRedirecTo() async { let sut = makeSUT() From 6687e1f02484a421c84ded1724a87c83ff8c186e Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 17:28:54 -0300 Subject: [PATCH 14/16] Add default implementation of ASWebAuthenticationPresentationContextProviding --- Sources/Auth/AuthClient.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 2ae28f75..0ffd28c8 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -672,7 +672,7 @@ public final class AuthClient: @unchecked Sendable { redirectTo: URL? = nil, scopes: String? = nil, queryParams: [(name: String, value: String?)] = [], - configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void + configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void = { _ in } ) async throws -> Session { try await signInWithOAuth( provider: provider, @@ -686,6 +686,8 @@ public final class AuthClient: @unchecked Sendable { return } + var presentationContextProvider: DefaultPresentationContextProvider? + let session = ASWebAuthenticationSession( url: url, callbackURLScheme: callbackScheme @@ -697,10 +699,18 @@ public final class AuthClient: @unchecked Sendable { } else { continuation.resume(throwing: AuthError.missingURL) } + + // Keep a strong reference to presentationContextProvider until the flow completes. + _ = presentationContextProvider } configure(session) + if session.presentationContextProvider == nil { + presentationContextProvider = DefaultPresentationContextProvider() + session.presentationContextProvider = presentationContextProvider + } + session.start() } } @@ -1244,3 +1254,12 @@ extension AuthClient { /// ``AuthClient/didChangeAuthStateNotification`` notification. public static let authChangeSessionInfoKey = "AuthClient.authChangeSession" } + +@MainActor +final class DefaultPresentationContextProvider: NSObject, + ASWebAuthenticationPresentationContextProviding +{ + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + ASPresentationAnchor() + } +} From bb072493ef32768b21aa6358d92d38c70df68bb9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 10 Apr 2024 17:30:08 -0300 Subject: [PATCH 15/16] remove custom configuration from oauth example --- Examples/Examples/Auth/SignInWithOAuth.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Examples/Examples/Auth/SignInWithOAuth.swift b/Examples/Examples/Auth/SignInWithOAuth.swift index f66f9b89..cc325a6f 100644 --- a/Examples/Examples/Auth/SignInWithOAuth.swift +++ b/Examples/Examples/Auth/SignInWithOAuth.swift @@ -46,7 +46,7 @@ struct SignInWithOAuth: View { } final class SignInWithOAuthViewController: UIViewController, UIPickerViewDataSource, - UIPickerViewDelegate, ASWebAuthenticationPresentationContextProviding + UIPickerViewDelegate { let providers = Provider.allCases var provider = Provider.allCases[0] @@ -88,10 +88,7 @@ final class SignInWithOAuthViewController: UIViewController, UIPickerViewDataSou do { try await supabase.auth.signInWithOAuth( provider: provider, - redirectTo: Constants.redirectToURL, - configure: { session in - session.presentationContextProvider = self - } + redirectTo: Constants.redirectToURL ) } catch { debug("Failed to sign-in with OAuth flow: \(error)") @@ -114,10 +111,6 @@ final class SignInWithOAuthViewController: UIViewController, UIPickerViewDataSou func pickerView(_: UIPickerView, didSelectRow row: Int, inComponent _: Int) { provider = providers[row] } - - func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { - view.window ?? UIWindow() - } } #Preview("SwiftUI") { From 2afffb0e7c64738bc5c4a052003e90b8d28dc0ea Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 11 Apr 2024 10:16:57 -0300 Subject: [PATCH 16/16] chore: remove presentationContextProvider from tvOS and watchOS --- Sources/Auth/AuthClient.swift | 36 +++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 0ffd28c8..7910274d 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -686,7 +686,9 @@ public final class AuthClient: @unchecked Sendable { return } - var presentationContextProvider: DefaultPresentationContextProvider? + #if !os(tvOS) && !os(watchOS) + var presentationContextProvider: DefaultPresentationContextProvider? + #endif let session = ASWebAuthenticationSession( url: url, @@ -700,16 +702,20 @@ public final class AuthClient: @unchecked Sendable { continuation.resume(throwing: AuthError.missingURL) } - // Keep a strong reference to presentationContextProvider until the flow completes. - _ = presentationContextProvider + #if !os(tvOS) && !os(watchOS) + // Keep a strong reference to presentationContextProvider until the flow completes. + _ = presentationContextProvider + #endif } configure(session) - if session.presentationContextProvider == nil { - presentationContextProvider = DefaultPresentationContextProvider() - session.presentationContextProvider = presentationContextProvider - } + #if !os(tvOS) && !os(watchOS) + if session.presentationContextProvider == nil { + presentationContextProvider = DefaultPresentationContextProvider() + session.presentationContextProvider = presentationContextProvider + } + #endif session.start() } @@ -1255,11 +1261,13 @@ extension AuthClient { public static let authChangeSessionInfoKey = "AuthClient.authChangeSession" } -@MainActor -final class DefaultPresentationContextProvider: NSObject, - ASWebAuthenticationPresentationContextProviding -{ - func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { - ASPresentationAnchor() +#if !os(tvOS) && !os(watchOS) + @MainActor + final class DefaultPresentationContextProvider: NSObject, + ASWebAuthenticationPresentationContextProviding + { + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + ASPresentationAnchor() + } } -} +#endif