Skip to content

feat(auth): add getLinkIdentityURL #342

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions Examples/Examples/Profile/UserIdentityList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions Examples/supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 29 additions & 9 deletions Sources/Auth/AuthClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Sources/Auth/Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
76 changes: 56 additions & 20 deletions Sources/_Helpers/Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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.
///
Expand Down
30 changes: 28 additions & 2 deletions Tests/AuthTests/AuthClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions Tests/AuthTests/RequestsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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"