From 1290bcfb39fb156de0283888b47ba1532107f468 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 11 Apr 2024 21:44:32 -0300 Subject: [PATCH] feat(auth): add `signInWithOAuth` (#299) --- Examples/Examples.xcodeproj/project.pbxproj | 12 +- Examples/Examples/Auth/AuthView.swift | 10 +- .../Auth/GoogleSignInWithWebFlow.swift | 41 ----- Examples/Examples/Auth/SignInWithOAuth.swift | 122 +++++++++++++ Examples/Examples/ExamplesApp.swift | 7 +- .../Examples/UIViewControllerWrapper.swift | 26 +++ Sources/Auth/AuthClient.swift | 171 ++++++++++++++++-- Sources/Auth/AuthError.swift | 7 +- Sources/Auth/Deprecated.swift | 2 +- Sources/Auth/Internal/Dependencies.swift | 8 +- Sources/Supabase/SupabaseClient.swift | 3 +- Sources/Supabase/Types.swift | 7 + Tests/AuthTests/AuthClientTests.swift | 96 +++++++++- Tests/AuthTests/RequestsTests.swift | 58 +++--- 14 files changed, 468 insertions(+), 102 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 aea09de8..00000000 --- a/Examples/Examples/Auth/GoogleSignInWithWebFlow.swift +++ /dev/null @@ -1,41 +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() - } - } - } - - 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) - } 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..cc325a6f --- /dev/null +++ b/Examples/Examples/Auth/SignInWithOAuth.swift @@ -0,0 +1,122 @@ +// +// 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 +{ + 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 + ) + } 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] + } +} + +#Preview("SwiftUI") { + SignInWithOAuth() +} + +#Preview("UIKit") { + SignInWithOAuthViewController() +} 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/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 25f605a9..7910274d 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1,11 +1,12 @@ import _Helpers +import AuthenticationServices import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif -public actor AuthClient { +public final class AuthClient: @unchecked Sendable { /// FetchHandler is a type alias for asynchronous network request handling. public typealias FetchHandler = @Sendable ( _ request: URLRequest @@ -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,15 +105,17 @@ 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. /// - 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, + 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, @@ -133,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( @@ -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 @@ -579,7 +593,15 @@ public actor AuthClient { return session } - /// Log in an existing user via a third-party provider. + /// 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, @@ -595,6 +617,111 @@ public actor AuthClient { ) } + /// 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 + ) async throws -> Session { + guard let redirectTo = (redirectTo ?? configuration.redirectToURL) else { + throw AuthError.invalidRedirectScheme + } + + let url = try getOAuthSignInURL( + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams + ) + + let resultURL = try await launchFlow(url) + + return try await session(from: resultURL) + } + + /// Sign-in an existing user via a third-party provider using ``ASWebAuthenticationSession``. + /// + /// - 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. + /// - 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( + provider: Provider, + redirectTo: URL? = nil, + scopes: String? = nil, + queryParams: [(name: String, value: String?)] = [], + configure: @Sendable (_ session: ASWebAuthenticationSession) -> Void = { _ in } + ) 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 = (configuration.redirectToURL ?? redirectTo)?.scheme else { + continuation.resume(throwing: AuthError.invalidRedirectScheme) + return + } + + #if !os(tvOS) && !os(watchOS) + var presentationContextProvider: DefaultPresentationContextProvider? + #endif + + 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) + } + + #if !os(tvOS) && !os(watchOS) + // Keep a strong reference to presentationContextProvider until the flow completes. + _ = presentationContextProvider + #endif + } + + configure(session) + + #if !os(tvOS) && !os(watchOS) + if session.presentationContextProvider == nil { + presentationContextProvider = DefaultPresentationContextProvider() + session.presentationContextProvider = presentationContextProvider + } + #endif + + session.start() + } + } + } + /// Gets the session data from a OAuth2 callback URL. @discardableResult public func session(from url: URL) async throws -> Session { @@ -756,7 +883,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 +970,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 +1116,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 +1220,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)) } @@ -1124,3 +1260,14 @@ extension AuthClient { /// ``AuthClient/didChangeAuthStateNotification`` notification. public static let authChangeSessionInfoKey = "AuthClient.authChangeSession" } + +#if !os(tvOS) && !os(watchOS) + @MainActor + final class DefaultPresentationContextProvider: NSObject, + ASWebAuthenticationPresentationContextProviding + { + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { + ASPresentationAnchor() + } + } +#endif 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/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) } } diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index df6b0657..0e8bddf5 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, @@ -256,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) } } 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 diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index bea1f254..4d2cd6b8 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,94 @@ 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]) + } + + @available(watchOS 6.2, tvOS 16.0, *) + 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 +353,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()