diff --git a/Examples/Examples/Profile/UserIdentityList.swift b/Examples/Examples/Profile/UserIdentityList.swift index 49d31c14..e5324ac1 100644 --- a/Examples/Examples/Profile/UserIdentityList.swift +++ b/Examples/Examples/Profile/UserIdentityList.swift @@ -62,9 +62,7 @@ struct UserIdentityList: View { Button(provider.rawValue) { Task { do { - let response = try await supabase.auth.getLinkIdentityURL(provider: provider) - openURL(response.url) - debug("getLinkIdentityURL: \(response.url) opened for provider \(response.provider)") + try await supabase.auth.linkIdentity(provider: provider) } catch { self.error = error } diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 779f0400..7332f4d9 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -955,14 +955,67 @@ public final class AuthClient: Sendable { try await user().identities ?? [] } + /// Links an OAuth identity to an existing user. + /// + /// This method supports the PKCE flow. + /// + /// - Parameters: + /// - provider: The provider you want to link the user with. + /// - scopes: A space-separated list of scopes granted to the OAuth application. + /// - redirectTo: A URL to send the user to after they are confirmed. + /// - queryParams: Additional query parameters to use. + /// - launchURL: Custom launch URL logic. + public func linkIdentity( + provider: Provider, + scopes: String? = nil, + redirectTo: URL? = nil, + queryParams: [(name: String, value: String?)] = [], + launchURL: @MainActor (_ url: URL) -> Void + ) async throws { + let response = try await getLinkIdentityURL( + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams + ) + + await launchURL(response.url) + } + + /// Links an OAuth identity to an existing user. + /// + /// This method supports the PKCE flow. + /// + /// - Parameters: + /// - provider: The provider you want to link the user with. + /// - scopes: A space-separated list of scopes granted to the OAuth application. + /// - redirectTo: A URL to send the user to after they are confirmed. + /// - queryParams: Additional query parameters to use. + /// + /// - Note: This method opens the URL using the default URL opening mechanism for the platform, if you with to provide your own URL opening logic use ``linkIdentity(provider:scopes:redirectTo:queryParams:launchURL:)``. + public func linkIdentity( + provider: Provider, + scopes: String? = nil, + redirectTo: URL? = nil, + queryParams: [(name: String, value: String?)] = [] + ) async throws { + try await linkIdentity( + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams, + launchURL: { Current.urlOpener.open($0) } + ) + } + /// Returns the URL to link the user's identity with an OAuth provider. /// /// This method supports the PKCE flow. /// /// - Parameters: /// - provider: The provider you want to link the user with. - /// - scopes: The scopes to request from the OAuth provider. - /// - redirectTo: The redirect URL to use, specify a configured deep link. + /// - scopes: A space-separated list of scopes granted to the OAuth application. + /// - redirectTo: A URL to send the user to after they are confirmed. /// - queryParams: Additional query parameters to use. public func getLinkIdentityURL( provider: Provider, diff --git a/Sources/Auth/Internal/Dependencies.swift b/Sources/Auth/Internal/Dependencies.swift index a0e5f651..2489c5ad 100644 --- a/Sources/Auth/Internal/Dependencies.swift +++ b/Sources/Auth/Internal/Dependencies.swift @@ -12,6 +12,7 @@ struct Dependencies: Sendable { var eventEmitter: AuthStateChangeEventEmitter = .shared var date: @Sendable () -> Date = { Date() } var codeVerifierStorage = CodeVerifierStorage.live + var urlOpener: URLOpener = .live var encoder: JSONEncoder { configuration.encoder } var decoder: JSONDecoder { configuration.decoder } diff --git a/Sources/Auth/Internal/URLOpener.swift b/Sources/Auth/Internal/URLOpener.swift new file mode 100644 index 00000000..53d625dd --- /dev/null +++ b/Sources/Auth/Internal/URLOpener.swift @@ -0,0 +1,38 @@ +// +// URLOpener.swift +// +// +// Created by Guilherme Souza on 17/05/24. +// + +import Foundation + +#if canImport(WatchKit) + import WatchKit +#endif + +#if canImport(UIKit) + import UIKit +#endif + +#if canImport(AppKit) + import AppKit +#endif + +struct URLOpener { + var open: @MainActor @Sendable (_ url: URL) -> Void +} + +extension URLOpener { + static var live: Self { + URLOpener { url in + #if os(macOS) + NSWorkspace.shared.open(url) + #elseif os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) + UIApplication.shared.open(url) + #elseif os(watchOS) + WKExtension.shared().openSystemURL(url) + #endif + } + } +} diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 3eeec284..d0c784f4 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -298,6 +298,30 @@ final class AuthClientTests: XCTestCase { ) } + func testLinkIdentity() async throws { + let url = "https://github.com/login/oauth/authorize?client_id=1234&redirect_to=com.supabase.swift-examples://&redirect_uri=http://127.0.0.1:54321/auth/v1/callback&response_type=code&scope=user:email&skip_http_redirect=true&state=jwt" + let sut = makeSUT { _ in + .stub( + """ + { + "url" : "\(url)" + } + """ + ) + } + + try storage.storeSession(.init(session: .validSession)) + + let receivedURL = LockIsolated(nil) + Current.urlOpener.open = { url in + receivedURL.setValue(url) + } + + try await sut.linkIdentity(provider: .github) + + XCTAssertEqual(receivedURL.value?.absoluteString, url) + } + private func makeSUT( fetch: ((URLRequest) async throws -> HTTPResponse)? = nil ) -> AuthClient { diff --git a/Tests/IntegrationTests/AuthClientIntegrationTests.swift b/Tests/IntegrationTests/AuthClientIntegrationTests.swift index 4275789a..3603d182 100644 --- a/Tests/IntegrationTests/AuthClientIntegrationTests.swift +++ b/Tests/IntegrationTests/AuthClientIntegrationTests.swift @@ -210,6 +210,14 @@ final class AuthClientIntegrationTests: XCTestCase { } } + func testLinkIdentity() async throws { + try await signUpIfNeededOrSignIn(email: mockEmail(), password: mockPassword()) + + try await authClient.linkIdentity(provider: .github) { url in + XCTAssertTrue(url.absoluteString.contains("github.com")) + } + } + @discardableResult private func signUpIfNeededOrSignIn( email: String,