From b805cdc628764a5bc97a38b093767de717f76f4e Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 22 Mar 2024 11:43:55 +0700 Subject: [PATCH] feat(auth): link identity (#274) * feat: add userIdentities method * Update dependencies * wip * fix corrupted Package.resolved * wip * Fix typo * Add example for unlink identity * test: add snapshot test to unlinkIdentity method * Fix unlink identity and add example * fix examples build * Hide add button when unsupported --- Examples/Examples.xcodeproj/project.pbxproj | 20 +++ Examples/Examples/ActionState.swift | 47 +++++++ Examples/Examples/AnyJSONView.swift | 109 +++++++++++++++ Examples/Examples/HomeView.swift | 25 ++-- Examples/Examples/Profile/ProfileView.swift | 72 ++++++++++ .../Examples/Profile/UserIdentityList.swift | 95 +++++++++++++ Examples/Examples/Storage/BucketList.swift | 2 +- Examples/supabase/config.toml | 17 ++- Sources/Auth/AuthClient.swift | 132 ++++++++++++------ Sources/Auth/Types.swift | 7 +- Tests/AuthTests/RequestsTests.swift | 21 +++ Tests/AuthTests/Resources/session.json | 1 + .../AuthTests/Resources/signup-response.json | 1 + Tests/AuthTests/Resources/user.json | 1 + .../RequestsTests/testUnlinkIdentity.1.txt | 6 + 15 files changed, 497 insertions(+), 59 deletions(-) create mode 100644 Examples/Examples/AnyJSONView.swift create mode 100644 Examples/Examples/Profile/ProfileView.swift create mode 100644 Examples/Examples/Profile/UserIdentityList.swift create mode 100644 Tests/AuthTests/__Snapshots__/RequestsTests/testUnlinkIdentity.1.txt diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 3609953e..2cb24500 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 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 */; }; + 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 */; }; 7956405E2954ADE00088A06F /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7956405D2954ADE00088A06F /* Secrets.swift */; }; @@ -39,6 +40,8 @@ 79AF04812B2CE261008761AD /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF04802B2CE261008761AD /* AuthView.swift */; }; 79AF04842B2CE408008761AD /* AuthWithMagicLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF04832B2CE408008761AD /* AuthWithMagicLink.swift */; }; 79AF04862B2CE586008761AD /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF04852B2CE586008761AD /* Debug.swift */; }; + 79B1C80C2BABFF8000D991AA /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B1C80B2BABFF8000D991AA /* ProfileView.swift */; }; + 79B1C80E2BAC017C00D991AA /* AnyJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B1C80D2BAC017C00D991AA /* AnyJSONView.swift */; }; 79B8F4242B5FED7C0000E839 /* IdentifiedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 79B8F4232B5FED7C0000E839 /* IdentifiedCollections */; }; 79B8F4262B602F640000E839 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B8F4252B602F640000E839 /* Logger.swift */; }; 79BD76772B59C3E300CA3D68 /* UserStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BD76762B59C3E300CA3D68 /* UserStore.swift */; }; @@ -80,6 +83,7 @@ 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 = ""; }; + 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 = ""; }; 7956405D2954ADE00088A06F /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; @@ -102,6 +106,8 @@ 79AF04802B2CE261008761AD /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; 79AF04832B2CE408008761AD /* AuthWithMagicLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthWithMagicLink.swift; sourceTree = ""; }; 79AF04852B2CE586008761AD /* Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug.swift; sourceTree = ""; }; + 79B1C80B2BABFF8000D991AA /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + 79B1C80D2BAC017C00D991AA /* AnyJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyJSONView.swift; sourceTree = ""; }; 79B8F4252B602F640000E839 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 79BD76762B59C3E300CA3D68 /* UserStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserStore.swift; sourceTree = ""; }; 79BD76782B59C53900CA3D68 /* ChannelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelStore.swift; sourceTree = ""; }; @@ -190,6 +196,7 @@ 793895C82954ABFF0044F2B8 /* Examples */ = { isa = PBXGroup; children = ( + 79B1C80A2BABFF6F00D991AA /* Profile */, 797EFB642BABD7FF00098D6B /* Storage */, 79AF04822B2CE3BD008761AD /* Auth */, 7962989A2AEBBD9F000AA957 /* Info.plist */, @@ -211,6 +218,7 @@ 793E030A2B2CEDDA00AC7DED /* ActionState.swift */, 79E2B55B2B97A2310042CD21 /* UIApplicationExtensions.swift */, 797EFB672BABD90500098D6B /* Stringfy.swift */, + 79B1C80D2BAC017C00D991AA /* AnyJSONView.swift */, ); path = Examples; sourceTree = ""; @@ -254,6 +262,15 @@ path = Auth; sourceTree = ""; }; + 79B1C80A2BABFF6F00D991AA /* Profile */ = { + isa = PBXGroup; + children = ( + 79B1C80B2BABFF8000D991AA /* ProfileView.swift */, + 794C61D52BAD1E12000E6B0F /* UserIdentityList.swift */, + ); + path = Profile; + sourceTree = ""; + }; 79D884C82B3C18830009EA4A /* SlackClone */ = { isa = PBXGroup; children = ( @@ -466,8 +483,10 @@ 796298992AEBBA77000AA957 /* MFAFlow.swift in Sources */, 79AF04862B2CE586008761AD /* Debug.swift in Sources */, 79AF04842B2CE408008761AD /* AuthWithMagicLink.swift in Sources */, + 79B1C80E2BAC017C00D991AA /* AnyJSONView.swift in Sources */, 79E2B5552B9788BF0042CD21 /* GoogleSignInSDKFlow.swift in Sources */, 793E03092B2CED5D00AC7DED /* Contants.swift in Sources */, + 794C61D62BAD1E12000E6B0F /* UserIdentityList.swift in Sources */, 793895CC2954ABFF0044F2B8 /* RootView.swift in Sources */, 7956406A2955AFBD0088A06F /* ErrorText.swift in Sources */, 79AF04812B2CE261008761AD /* AuthView.swift in Sources */, @@ -477,6 +496,7 @@ 794EF1222955F26A008C9526 /* AddTodoListView.swift in Sources */, 7956405E2954ADE00088A06F /* Secrets.swift in Sources */, 795640682955AEB30088A06F /* Models.swift in Sources */, + 79B1C80C2BABFF8000D991AA /* ProfileView.swift in Sources */, 795640662955AE9C0088A06F /* TodoListView.swift in Sources */, 795640602954AE140088A06F /* AuthController.swift in Sources */, 79AF047F2B2CE207008761AD /* AuthWithEmailAndPassword.swift in Sources */, diff --git a/Examples/Examples/ActionState.swift b/Examples/Examples/ActionState.swift index 0c7e5899..166cf63a 100644 --- a/Examples/Examples/ActionState.swift +++ b/Examples/Examples/ActionState.swift @@ -7,10 +7,57 @@ import CasePaths import Foundation +import SwiftUI @CasePathable enum ActionState { case idle case inFlight case result(Result) + + var success: Success? { + if case let .result(.success(success)) = self { return success } + return nil + } +} + +struct ActionStateView: View { + @Binding var state: ActionState + + let action: () async throws -> Success + @ViewBuilder var content: (Success) -> SuccessContent + + var body: some View { + Group { + switch state { + case .idle: + Color.clear + case .inFlight: + ProgressView() + case let .result(.success(value)): + content(value) + case let .result(.failure(error)): + VStack { + ErrorText(error) + Button("Retry") { + Task { await load() } + } + } + } + } + .task { + await load() + } + } + + @MainActor + private func load() async { + state = .inFlight + do { + let value = try await action() + state = .result(.success(value)) + } catch { + state = .result(.failure(error)) + } + } } diff --git a/Examples/Examples/AnyJSONView.swift b/Examples/Examples/AnyJSONView.swift new file mode 100644 index 00000000..f142b08b --- /dev/null +++ b/Examples/Examples/AnyJSONView.swift @@ -0,0 +1,109 @@ +// +// AnyJSONView.swift +// Examples +// +// Created by Guilherme Souza on 21/03/24. +// + +import Supabase +import SwiftUI + +struct AnyJSONView: View { + let value: AnyJSON + + var body: some View { + switch value { + case .null: Text("") + case let .bool(value): Text(value.description) + case let .double(value): Text(value.description) + case let .integer(value): Text(value.description) + case let .string(value): Text(value) + case let .array(value): + ForEach(0 ..< value.count, id: \.self) { index in + if value[index].isPrimitive { + LabeledContent("\(index)") { + AnyJSONView(value: value[index]) + } + } else { + NavigationLink("\(index)") { + List { + AnyJSONView(value: value[index]) + } + .navigationTitle("\(index)") + } + } + } + + case let .object(object): + let elements = Array(object).sorted(by: { $0.key < $1.key }) + ForEach(elements, id: \.key) { element in + if element.value.isPrimitive { + LabeledContent(element.key) { + AnyJSONView(value: element.value) + } + } else { + NavigationLink(element.key) { + List { + AnyJSONView(value: element.value) + } + .navigationTitle(element.key) + } + } + } + } + } +} + +extension AnyJSON { + var isPrimitive: Bool { + switch self { + case .null, .bool, .integer, .double, .string: + return true + case .object, .array: + return false + } + } +} + +#Preview { + NavigationStack { + AnyJSONView( + value: [ + "app_metadata": [ + "provider": "email", + "providers": [ + "email", + ], + ], + "aud": "authenticated", + "confirmed_at": "2024-03-21T03:19:10.147869Z", + "created_at": "2024-03-21T03:19:10.142559Z", + "email": "test@mail.com", + "email_confirmed_at": "2024-03-21T03:19:10.147869Z", + "id": "06f83324-e553-4d39-a609-fd30682ee127", + "identities": [ + [ + "created_at": "2024-03-21T03:19:10.146262Z", + "email": "test@mail.com", + "id": "06f83324-e553-4d39-a609-fd30682ee127", + "identity_data": [ + "email": "test@mail.com", + "email_verified": false, + "phone_verified": false, + "sub": "06f83324-e553-4d39-a609-fd30682ee127", + ], + "identity_id": "35aafcdf-f12e-4e3d-8302-63ff587c041c", + "last_sign_in_at": "2024-03-21T03:19:10.146245Z", + "provider": "email", + "updated_at": "2024-03-21T03:19:10.146262Z", + "user_id": "06f83324-e553-4d39-a609-fd30682ee127", + ], + ], + "last_sign_in_at": "2024-03-21T03:19:10.149557Z", + "phone": "", + "role": "authenticated", + "updated_at": "2024-03-21T05:37:40.596682Z", + ] + ) + } +} diff --git a/Examples/Examples/HomeView.swift b/Examples/Examples/HomeView.swift index 2f70c151..29caa1e5 100644 --- a/Examples/Examples/HomeView.swift +++ b/Examples/Examples/HomeView.swift @@ -14,23 +14,18 @@ struct HomeView: View { @State private var mfaStatus: MFAStatus? var body: some View { - NavigationStack { - BucketList() - .navigationDestination(for: Bucket.self, destination: BucketDetailView.init) - } - .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { - Button("Sign out") { - Task { - try! await supabase.auth.signOut() - } + TabView { + ProfileView() + .tabItem { + Label("Profile", systemImage: "person.circle") } - Button("Reauthenticate") { - Task { - try! await supabase.auth.reauthenticate() - } - } + NavigationStack { + BucketList() + .navigationDestination(for: Bucket.self, destination: BucketDetailView.init) + } + .tabItem { + Label("Storage", systemImage: "externaldrive") } } .task { diff --git a/Examples/Examples/Profile/ProfileView.swift b/Examples/Examples/Profile/ProfileView.swift new file mode 100644 index 00000000..f418faa2 --- /dev/null +++ b/Examples/Examples/Profile/ProfileView.swift @@ -0,0 +1,72 @@ +// +// ProfileView.swift +// Examples +// +// Created by Guilherme Souza on 21/03/24. +// + +import Supabase +import SwiftUI + +struct ProfileView: View { + @State var user: User? + + var identities: [UserIdentity] { + user?.identities ?? [] + } + + var body: some View { + NavigationStack { + List { + if let user = user.flatMap({ try? AnyJSON($0) }) { + Section { + AnyJSONView(value: user) + } + } + + NavigationLink("Identities") { + UserIdentityList() + .navigationTitle("Identities") + } + + Button("Reauthenticate") { + Task { + try! await supabase.auth.reauthenticate() + } + } + + Menu("Unlink identity") { + ForEach(identities) { identity in + Button(identity.provider) { + Task { + do { + try await supabase.auth.unlinkIdentity(identity) + } catch { + debug("Fail to unlink identity: \(error)") + } + } + } + } + } + + Button("Sign out", role: .destructive) { + Task { + try! await supabase.auth.signOut() + } + } + } + .navigationTitle("Profile") + } + .task { + do { + user = try await supabase.auth.user() + } catch { + debug("Fail to fetch user: \(error)") + } + } + } +} + +#Preview { + ProfileView() +} diff --git a/Examples/Examples/Profile/UserIdentityList.swift b/Examples/Examples/Profile/UserIdentityList.swift new file mode 100644 index 00000000..1b0e976d --- /dev/null +++ b/Examples/Examples/Profile/UserIdentityList.swift @@ -0,0 +1,95 @@ +// +// UserIdentityList.swift +// Examples +// +// Created by Guilherme Souza on 22/03/24. +// + +import Supabase +import SwiftUI + +struct UserIdentityList: View { + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + + @State private var identities = ActionState<[UserIdentity], any Error>.idle + @State private var error: (any Error)? + @State private var id = UUID() + + private var providers: [Provider] { + let allProviders = Provider.allCases + let identities = identities.success ?? [] + + return allProviders.filter { provider in + !identities.contains(where: { $0.provider == provider.rawValue }) + } + } + + var body: some View { + ActionStateView(state: $identities) { + try await supabase.auth.userIdentities() + } content: { identities in + List { + if let error { + ErrorText(error) + } + + ForEach(identities) { identity in + Section { + AnyJSONView(value: try! AnyJSON(identity)) + } footer: { + Button("Unlink") { + Task { + do { + error = nil + try await supabase.auth.unlinkIdentity(identity) + id = UUID() + } catch { + self.error = error + } + } + } + } + } + } + } + .id(id) + #if swift(>=5.10) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu("Add") { + ForEach(providers) { provider in + 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 + } + + } catch { + self.error = error + } + } + } + } + } + } + } + #endif + } +} + +#Preview { + UserIdentityList() +} diff --git a/Examples/Examples/Storage/BucketList.swift b/Examples/Examples/Storage/BucketList.swift index 216d3ab2..61c0e8fa 100644 --- a/Examples/Examples/Storage/BucketList.swift +++ b/Examples/Examples/Storage/BucketList.swift @@ -43,7 +43,7 @@ struct BucketList: View { .task { await load() } - .navigationTitle("Bucket list") + .navigationTitle("All buckets") .toolbar { ToolbarItem(placement: .primaryAction) { Button("Add") { diff --git a/Examples/supabase/config.toml b/Examples/supabase/config.toml index 187a7f5d..863a2d45 100644 --- a/Examples/supabase/config.toml +++ b/Examples/supabase/config.toml @@ -40,14 +40,19 @@ file_size_limit = "50MiB" [auth] # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. -site_url = "http://localhost:3000" +site_url = "http://127.0.0.1:3000" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://localhost:3000"] +additional_redirect_urls = [ + "http://127.0.0.1:3000", + "com.supabase.swift-examples://", +] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one # week). jwt_expiry = 3600 # Allow/disallow new user signups to your project. enable_signup = true +# Allow/disallow testing manual linking of accounts +enable_manual_linking = true [auth.email] # Allow/disallow new user signups via email to your project. @@ -63,9 +68,15 @@ enable_confirmations = false [auth.external.apple] enabled = false client_id = "" -secret = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" # Overrides the default auth redirectUrl. redirect_uri = "" # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, # or any other third-party OIDC providers. url = "" + +[auth.external.github] +enabled = true +client_id = "12d1131cd3582f942c71" +secret = "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)" \ No newline at end of file diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index d39988ca..afe35d56 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -480,45 +480,13 @@ public actor AuthClient { redirectTo: URL? = nil, queryParams: [(name: String, value: String?)] = [] ) throws -> URL { - guard - var components = URLComponents( - url: configuration.url.appendingPathComponent("authorize"), resolvingAgainstBaseURL: false - ) - else { - throw URLError(.badURL) - } - - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "provider", value: provider.rawValue), - ] - - if let scopes { - queryItems.append(URLQueryItem(name: "scopes", value: scopes)) - } - - if let redirectTo { - queryItems.append(URLQueryItem(name: "redirect_to", value: redirectTo.absoluteString)) - } - - let (codeChallenge, codeChallengeMethod) = prepareForPKCE() - - if let codeChallenge { - queryItems.append(URLQueryItem(name: "code_challenge", value: codeChallenge)) - } - - if let codeChallengeMethod { - queryItems.append(URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod)) - } - - queryItems.append(contentsOf: queryParams.map(URLQueryItem.init)) - - components.queryItems = queryItems - - guard let url = components.url else { - throw URLError(.badURL) - } - - return url + try getURLForProvider( + url: configuration.url.appendingPathComponent("authorize"), + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams + ) } /// Gets the session data from a OAuth2 callback URL. @@ -856,6 +824,44 @@ public actor AuthClient { return updatedUser } + /// Gets all the identities linked to a user. + public func userIdentities() async throws -> [UserIdentity] { + try await user().identities ?? [] + } + + /// Gets an URL that can be used for manual linking identity. + /// - 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. + public func getURLForLinkIdentity( + provider: Provider, + scopes: String? = nil, + redirectTo: URL? = nil, + queryParams: [(name: String, value: String?)] = [] + ) throws -> URL { + try getURLForProvider( + url: configuration.url.appendingPathComponent("user/identities/authorize"), + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams + ) + } + + /// Unlinks an identity from a user by deleting it. The user will no longer be able to sign in + /// with that identity once it's unlinked. + public func unlinkIdentity(_ identity: UserIdentity) async throws { + try await api.authorizedExecute( + Request( + path: "/user/identities/\(identity.identityId)", + method: .delete + ) + ) + } + /// Sends a reset request to an email address. public func resetPasswordForEmail( _ email: String, @@ -958,6 +964,54 @@ public actor AuthClient { let currentCodeVerifier = try? codeVerifierStorage.getCodeVerifier() return fragments.contains(where: { $0.name == "code" }) && currentCodeVerifier != nil } + + private func getURLForProvider( + url: URL, + provider: Provider, + scopes: String? = nil, + redirectTo: URL? = nil, + queryParams: [(name: String, value: String?)] = [] + ) throws -> URL { + guard + var components = URLComponents( + url: url, resolvingAgainstBaseURL: false + ) + else { + throw URLError(.badURL) + } + + var queryItems: [URLQueryItem] = [ + URLQueryItem(name: "provider", value: provider.rawValue), + ] + + if let scopes { + queryItems.append(URLQueryItem(name: "scopes", value: scopes)) + } + + if let redirectTo { + queryItems.append(URLQueryItem(name: "redirect_to", value: redirectTo.absoluteString)) + } + + let (codeChallenge, codeChallengeMethod) = prepareForPKCE() + + if let codeChallenge { + queryItems.append(URLQueryItem(name: "code_challenge", value: codeChallenge)) + } + + if let codeChallengeMethod { + queryItems.append(URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod)) + } + + queryItems.append(contentsOf: queryParams.map(URLQueryItem.init)) + + components.queryItems = queryItems + + guard let url = components.url else { + throw URLError(.badURL) + } + + return url + } } extension AuthClient { diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 00bb152c..f6ed0f05 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -190,6 +190,7 @@ public struct User: Codable, Hashable, Identifiable, Sendable { public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { public var id: String + public var identityId: UUID public var userId: UUID public var identityData: [String: AnyJSON]? public var provider: String @@ -199,6 +200,7 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { public init( id: String, + identityId: UUID, userId: UUID, identityData: [String: AnyJSON], provider: String, @@ -207,6 +209,7 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { updatedAt: Date ) { self.id = id + self.identityId = identityId self.userId = userId self.identityData = identityData self.provider = provider @@ -216,7 +219,7 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { } } -public enum Provider: String, Codable, CaseIterable, Sendable { +public enum Provider: String, Identifiable, Codable, CaseIterable, Sendable { case apple case azure case bitbucket @@ -234,6 +237,8 @@ public enum Provider: String, Codable, CaseIterable, Sendable { case twitch case twitter case workos + + public var id: RawValue { rawValue } } public struct OpenIDConnectCredentials: Codable, Hashable, Sendable { diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 5263b621..60613d47 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -354,6 +354,27 @@ final class RequestsTests: XCTestCase { } } + func testUnlinkIdentity() async { + sessionManager.returnSession = .success(.validSession) + + let sut = makeSUT() + + await assert { + try await sut.unlinkIdentity( + UserIdentity( + id: "5923044", + identityId: UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!, + userId: UUID(), + identityData: [:], + provider: "email", + createdAt: Date(), + lastSignInAt: Date(), + updatedAt: Date() + ) + ) + } + } + private func assert(_ block: () async throws -> Void) async { do { try await block() diff --git a/Tests/AuthTests/Resources/session.json b/Tests/AuthTests/Resources/session.json index 24eeff1b..49bd10ab 100644 --- a/Tests/AuthTests/Resources/session.json +++ b/Tests/AuthTests/Resources/session.json @@ -22,6 +22,7 @@ { "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" }, diff --git a/Tests/AuthTests/Resources/signup-response.json b/Tests/AuthTests/Resources/signup-response.json index 7cbd6883..81680727 100644 --- a/Tests/AuthTests/Resources/signup-response.json +++ b/Tests/AuthTests/Resources/signup-response.json @@ -16,6 +16,7 @@ { "id": "859f402d-b3de-4105-a1b9-932836d9193b", "user_id": "859f402d-b3de-4105-a1b9-932836d9193b", + "identity_id": "859f402d-b3de-4105-a1b9-932836d9193b", "identity_data": { "sub": "859f402d-b3de-4105-a1b9-932836d9193b" }, diff --git a/Tests/AuthTests/Resources/user.json b/Tests/AuthTests/Resources/user.json index a9e81cdc..0cf2af1c 100644 --- a/Tests/AuthTests/Resources/user.json +++ b/Tests/AuthTests/Resources/user.json @@ -18,6 +18,7 @@ { "id": "859f402d-b3de-4105-a1b9-932836d9193b", "user_id": "859f402d-b3de-4105-a1b9-932836d9193b", + "identity_id": "859f402d-b3de-4105-a1b9-932836d9193b", "identity_data": { "sub": "859f402d-b3de-4105-a1b9-932836d9193b" }, diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testUnlinkIdentity.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testUnlinkIdentity.1.txt new file mode 100644 index 00000000..ed7ae860 --- /dev/null +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testUnlinkIdentity.1.txt @@ -0,0 +1,6 @@ +curl \ + --request DELETE \ + --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/E621E1F8-C36C-495A-93FC-0C247A3E6E5F" \ No newline at end of file