Skip to content

Commit

Permalink
feat(auth): add getLinkIdentityURL (#342)
Browse files Browse the repository at this point in the history
* feat: add `getLinkIdentityURL`

* test: add test for getLinkIdentityURL method
  • Loading branch information
grdsdev committed Apr 16, 2024
1 parent 8843529 commit 202383d
Show file tree
Hide file tree
Showing 8 changed files with 143 additions and 47 deletions.
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"

0 comments on commit 202383d

Please sign in to comment.