From 202383d355dfaa9aab0e03680d9fedb9bdfc02d9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 16 Apr 2024 10:31:20 -0300 Subject: [PATCH] feat(auth): add `getLinkIdentityURL` (#342) * feat: add `getLinkIdentityURL` * test: add test for getLinkIdentityURL method --- .../Examples/Profile/UserIdentityList.swift | 20 +---- Examples/supabase/config.toml | 1 + Sources/Auth/AuthClient.swift | 38 +++++++--- Sources/Auth/Types.swift | 5 ++ Sources/_Helpers/Request.swift | 76 ++++++++++++++----- Tests/AuthTests/AuthClientTests.swift | 30 +++++++- Tests/AuthTests/RequestsTests.swift | 15 ++++ .../testGetLinkIdentityURL.1.txt | 5 ++ 8 files changed, 143 insertions(+), 47 deletions(-) create mode 100644 Tests/AuthTests/__Snapshots__/RequestsTests/testGetLinkIdentityURL.1.txt diff --git a/Examples/Examples/Profile/UserIdentityList.swift b/Examples/Examples/Profile/UserIdentityList.swift index 9adf5d80..49d31c14 100644 --- a/Examples/Examples/Profile/UserIdentityList.swift +++ b/Examples/Examples/Profile/UserIdentityList.swift @@ -10,6 +10,7 @@ import SwiftUI struct UserIdentityList: View { @Environment(\.webAuthenticationSession) private var webAuthenticationSession + @Environment(\.openURL) private var openURL @State private var identities = ActionState<[UserIdentity], any Error>.idle @State private var error: (any Error)? @@ -61,22 +62,9 @@ struct UserIdentityList: View { Button(provider.rawValue) { Task { do { - if #available(iOS 17.4, *) { - let url = try await supabase.auth._getURLForLinkIdentity(provider: provider) - let accessToken = try await supabase.auth.session.accessToken - - let callbackURL = try await webAuthenticationSession.authenticate( - using: url, - callback: .customScheme(Constants.redirectToURL.scheme!), - preferredBrowserSession: .shared, - additionalHeaderFields: ["Authorization": "Bearer \(accessToken)"] - ) - - debug("\(callbackURL)") - } else { - // Fallback on earlier versions - } - + let response = try await supabase.auth.getLinkIdentityURL(provider: provider) + openURL(response.url) + debug("getLinkIdentityURL: \(response.url) opened for provider \(response.provider)") } catch { self.error = error } diff --git a/Examples/supabase/config.toml b/Examples/supabase/config.toml index 96344198..948858d4 100644 --- a/Examples/supabase/config.toml +++ b/Examples/supabase/config.toml @@ -53,6 +53,7 @@ jwt_expiry = 3600 enable_signup = true # Allow/disallow testing manual linking of accounts enable_manual_linking = true +enable_anonymous_sign_ins = true [auth.email] # Allow/disallow new user signups via email to your project. diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 7910274d..82eb8c55 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1068,28 +1068,43 @@ public final class AuthClient: @unchecked Sendable { try await user().identities ?? [] } - /// Gets an URL that can be used for manual linking identity. + /// 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. /// - queryParams: Additional query parameters to use. - /// - Returns: A URL that you can use to initiate the OAuth flow. - /// - /// - Warning: This method is experimental and is expected to change. - public func _getURLForLinkIdentity( + public func getLinkIdentityURL( provider: Provider, scopes: String? = nil, redirectTo: URL? = nil, queryParams: [(name: String, value: String?)] = [] - ) throws -> URL { - try getURLForProvider( + ) async throws -> OAuthResponse { + let url = try getURLForProvider( url: configuration.url.appendingPathComponent("user/identities/authorize"), provider: provider, scopes: scopes, redirectTo: redirectTo, - queryParams: queryParams + queryParams: queryParams, + skipBrowserRedirect: true + ) + + struct Response: Codable { + let url: URL + } + + let response = try await api.authorizedExecute( + Request( + url: url, + method: .get + ) ) + .decoded(as: Response.self, decoder: configuration.decoder) + + return OAuthResponse(provider: provider, url: response.url) } /// Unlinks an identity from a user by deleting it. The user will no longer be able to sign in @@ -1202,7 +1217,8 @@ public final class AuthClient: @unchecked Sendable { provider: Provider, scopes: String? = nil, redirectTo: URL? = nil, - queryParams: [(name: String, value: String?)] = [] + queryParams: [(name: String, value: String?)] = [], + skipBrowserRedirect: Bool? = nil ) throws -> URL { guard var components = URLComponents( @@ -1234,6 +1250,10 @@ public final class AuthClient: @unchecked Sendable { queryItems.append(URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod)) } + if let skipBrowserRedirect { + queryItems.append(URLQueryItem(name: "skip_http_redirect", value: "\(skipBrowserRedirect)")) + } + queryItems.append(contentsOf: queryParams.map(URLQueryItem.init)) components.queryItems = queryItems diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 6e6e3893..1ab44e0d 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -717,3 +717,8 @@ public struct SSOResponse: Codable, Hashable, Sendable { /// identity provider's authentication flow. public let url: URL } + +public struct OAuthResponse: Codable, Hashable, Sendable { + public let provider: Provider + public let url: URL +} diff --git a/Sources/_Helpers/Request.swift b/Sources/_Helpers/Request.swift index e9ee60e8..0a917f88 100644 --- a/Sources/_Helpers/Request.swift +++ b/Sources/_Helpers/Request.swift @@ -78,11 +78,27 @@ package struct HTTPClient: Sendable { } package struct Request: Sendable { - public var path: String - public var method: Method - public var query: [URLQueryItem] - public var headers: [String: String] - public var body: Data? + enum _URL { + case absolute(url: URL) + case relative(path: String) + + func resolve(withBaseURL baseURL: URL) -> URL { + switch self { + case let .absolute(url): url + case let .relative(path): baseURL.appendingPathComponent(path) + } + } + } + + var _url: _URL + package var method: Method + package var query: [URLQueryItem] + package var headers: [String: String] + package var body: Data? + + package func url(withBaseURL baseURL: URL) -> URL { + _url.resolve(withBaseURL: baseURL) + } package enum Method: String, Sendable { case get = "GET" @@ -93,22 +109,8 @@ package struct Request: Sendable { case head = "HEAD" } - package init( - path: String, - method: Method, - query: [URLQueryItem] = [], - headers: [String: String] = [:], - body: Data? = nil - ) { - self.path = path - self.method = method - self.query = query - self.headers = headers - self.body = body - } - package func urlRequest(withBaseURL baseURL: URL) throws -> URLRequest { - var url = baseURL.appendingPathComponent(path) + var url = url(withBaseURL: baseURL) if !query.isEmpty { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw URLError(.badURL) @@ -158,6 +160,40 @@ package struct Request: Sendable { } } +extension Request { + package init( + path: String, + method: Method, + query: [URLQueryItem] = [], + headers: [String: String] = [:], + body: Data? = nil + ) { + self.init( + _url: .relative(path: path), + method: method, + query: query, + headers: headers, + body: body + ) + } + + package init( + url: URL, + method: Method, + query: [URLQueryItem] = [], + headers: [String: String] = [:], + body: Data? = nil + ) { + self.init( + _url: .absolute(url: url), + method: method, + query: query, + headers: headers, + body: body + ) + } +} + extension CharacterSet { /// Creates a CharacterSet from RFC 3986 allowed characters. /// diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 4d2cd6b8..2ed38753 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -6,12 +6,12 @@ // @testable import _Helpers +@testable import Auth import ConcurrencyExtras +import CustomDump import TestHelpers import XCTest -@testable import Auth - #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -342,6 +342,32 @@ final class AuthClientTests: XCTestCase { } } + func testGetLinkIdentityURL() async throws { + api.execute = { @Sendable _ in + .stub( + """ + { + "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" + } + """ + ) + } + + sessionManager.session = { @Sendable _ in .validSession } + codeVerifierStorage = .live + let sut = makeSUT() + + let response = try await sut.getLinkIdentityURL(provider: .github) + + XCTAssertNoDifference( + response, + OAuthResponse( + provider: .github, + url: URL(string: "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")! + ) + ) + } + private func makeSUT() -> AuthClient { let configuration = AuthClient.Configuration( url: clientURL, diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 89d93ba1..5da307c1 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -406,6 +406,21 @@ final class RequestsTests: XCTestCase { } } + func testGetLinkIdentityURL() async { + sessionManager.session = { @Sendable _ in .validSession } + + let sut = makeSUT() + + await assert { + _ = try await sut.getLinkIdentityURL( + provider: .github, + scopes: "user:email", + redirectTo: URL(string: "https://supabase.com"), + queryParams: [("extra_key", "extra_value")] + ) + } + } + private func assert(_ block: () async throws -> Void) async { do { try await block() diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testGetLinkIdentityURL.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testGetLinkIdentityURL.1.txt new file mode 100644 index 00000000..c7ac5bf1 --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testGetLinkIdentityURL.1.txt @@ -0,0 +1,5 @@ +curl \ + --header "Apikey: dummy.api.key" \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: gotrue-swift/x.y.z" \ + "http://localhost:54321/auth/v1/user/identities/authorize?extra_key=extra_value&provider=github&redirect_to=https://supabase.com&scopes=user:email&skip_http_redirect=true" \ No newline at end of file