From ced7ac13e841968d0c192511469223486f1400c7 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 8 Mar 2024 05:19:38 -0300 Subject: [PATCH 01/11] feat: add userIdentities method --- Sources/Auth/AuthClient.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index d39988ca..b2f49500 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -855,6 +855,11 @@ public actor AuthClient { eventEmitter.emit(.userUpdated, session: session) return updatedUser } + + /// Gets all the identities linked to a user. + public func userIdentities() async throws -> [UserIdentity] { + try await user().identities ?? [] + } /// Sends a reset request to an email address. public func resetPasswordForEmail( From 639c43efde79ea649a03e527a7a18230ac349cce Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 8 Mar 2024 05:29:31 -0300 Subject: [PATCH 02/11] Update dependencies --- .../xcshareddata/swiftpm/Package.resolved | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4904c629..0dd4e53d 100644 --- a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,15 +99,6 @@ "version" : "1.3.0" } }, - { - "identity" : "swift-identified-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections.git", - "state" : { - "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", - "version" : "1.0.0" - } - }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -131,6 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation.git", "state" : { + "revision" : "43c802fb7f96e090dde015344a94b5e85779eff1", + "version" : "509.1.0" "revision" : "d9e72f3083c08375794afa216fb2f89c0114f303", "version" : "1.2.1" } From d13e9f82352e6bb981c9e9592e150c5b199588ac Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 8 Mar 2024 08:19:31 -0300 Subject: [PATCH 03/11] wip --- Sources/Auth/AuthClient.swift | 105 +++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index b2f49500..8c639350 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. @@ -861,6 +829,16 @@ public actor AuthClient { try await user().identities ?? [] } + public func linkIdentity(provider: Provider) async throws { + let url = try getURLForProvider(url: configuration.url.appendingPathComponent("user/identities/authorize"), provider: provider) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.allHTTPHeaderFields = configuration.headers + + let (data, response) = try await configuration.fetch(request) + } + /// Sends a reset request to an email address. public func resetPasswordForEmail( _ email: String, @@ -963,6 +941,55 @@ 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 { From 190593f2afd2f3dd46b7bd80174dd5aa926b3e13 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 21 Mar 2024 12:28:06 +0700 Subject: [PATCH 04/11] fix corrupted Package.resolved --- .../xcshareddata/swiftpm/Package.resolved | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0dd4e53d..4904c629 100644 --- a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,6 +99,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections.git", + "state" : { + "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", + "version" : "1.0.0" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -122,8 +131,6 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation.git", "state" : { - "revision" : "43c802fb7f96e090dde015344a94b5e85779eff1", - "version" : "509.1.0" "revision" : "d9e72f3083c08375794afa216fb2f89c0114f303", "version" : "1.2.1" } From 41d09875572484eda83a58b24b102b9198e32731 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 21 Mar 2024 16:53:30 +0700 Subject: [PATCH 05/11] wip --- Examples/Examples.xcodeproj/project.pbxproj | 16 +++ Examples/Examples/AnyJSONView.swift | 109 ++++++++++++++++++++ Examples/Examples/HomeView.swift | 25 ++--- Examples/Examples/Profile/ProfileView.swift | 55 ++++++++++ Examples/Examples/Storage/BucketList.swift | 2 +- Examples/supabase/config.toml | 9 +- Sources/Auth/AuthClient.swift | 40 +++++-- Sources/Auth/Types.swift | 4 +- 8 files changed, 231 insertions(+), 29 deletions(-) create mode 100644 Examples/Examples/AnyJSONView.swift create mode 100644 Examples/Examples/Profile/ProfileView.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 3609953e..6e99bd02 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -39,6 +39,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 */; }; @@ -102,6 +104,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 +194,7 @@ 793895C82954ABFF0044F2B8 /* Examples */ = { isa = PBXGroup; children = ( + 79B1C80A2BABFF6F00D991AA /* Profile */, 797EFB642BABD7FF00098D6B /* Storage */, 79AF04822B2CE3BD008761AD /* Auth */, 7962989A2AEBBD9F000AA957 /* Info.plist */, @@ -211,6 +216,7 @@ 793E030A2B2CEDDA00AC7DED /* ActionState.swift */, 79E2B55B2B97A2310042CD21 /* UIApplicationExtensions.swift */, 797EFB672BABD90500098D6B /* Stringfy.swift */, + 79B1C80D2BAC017C00D991AA /* AnyJSONView.swift */, ); path = Examples; sourceTree = ""; @@ -254,6 +260,14 @@ path = Auth; sourceTree = ""; }; + 79B1C80A2BABFF6F00D991AA /* Profile */ = { + isa = PBXGroup; + children = ( + 79B1C80B2BABFF8000D991AA /* ProfileView.swift */, + ); + path = Profile; + sourceTree = ""; + }; 79D884C82B3C18830009EA4A /* SlackClone */ = { isa = PBXGroup; children = ( @@ -466,6 +480,7 @@ 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 */, 793895CC2954ABFF0044F2B8 /* RootView.swift in Sources */, @@ -477,6 +492,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/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..ac0e3d60 --- /dev/null +++ b/Examples/Examples/Profile/ProfileView.swift @@ -0,0 +1,55 @@ +// +// ProfileView.swift +// Examples +// +// Created by Guilherme Souza on 21/03/24. +// + +import Supabase +import SwiftUI + +struct ProfileView: View { + @Environment(\.webAuthenticationSession) var webAuthenticationSession + + @State var user: User? + + var providers: [Provider] { + Provider.allCases + } + + var body: some View { + NavigationStack { + List { + if let user = user.flatMap({ try? AnyJSON($0) }) { + Section { + AnyJSONView(value: user) + } + } + + Button("Reauthenticate") { + Task { + try! await supabase.auth.reauthenticate() + } + } + + 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/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..3075bf5d 100644 --- a/Examples/supabase/config.toml +++ b/Examples/supabase/config.toml @@ -48,6 +48,8 @@ additional_redirect_urls = ["https://localhost:3000"] 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. @@ -61,9 +63,10 @@ enable_confirmations = false # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, # `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch`, `twitter`, `slack`, `spotify`. [auth.external.apple] -enabled = false -client_id = "" -secret = "" +enabled = true +client_id = "com.supabase.swift-examples" +# 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, diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 8c639350..1542dc2a 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -823,20 +823,43 @@ public actor AuthClient { eventEmitter.emit(.userUpdated, session: session) return updatedUser } - + /// Gets all the identities linked to a user. public func userIdentities() async throws -> [UserIdentity] { try await user().identities ?? [] } - public func linkIdentity(provider: Provider) async throws { - let url = try getURLForProvider(url: configuration.url.appendingPathComponent("user/identities/authorize"), provider: provider) - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.allHTTPHeaderFields = configuration.headers + /// 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 you to initiate the OAuth flow. + public func getURLForLinkIdentity( + provider: Provider, + scopes: String? = nil, + redirectTo: URL? = nil, + queryParams: [(name: String, value: String?)] = [] + ) async throws -> URL { + try getURLForProvider( + url: configuration.url.appendingPathComponent("user/identities/authorize"), + provider: provider, + scopes: scopes, + redirectTo: redirectTo, + queryParams: queryParams + ) + } - let (data, response) = try await configuration.fetch(request) + /// 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.id)", + method: .delete + ) + ) } /// Sends a reset request to an email address. @@ -989,7 +1012,6 @@ public actor AuthClient { return url } - } extension AuthClient { diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 00bb152c..43a2b3bb 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -216,7 +216,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 +234,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 { From e51769cd04af77f4f3bfaad7d89f04b6c76d7a3c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 21 Mar 2024 16:56:54 +0700 Subject: [PATCH 06/11] Fix typo --- Sources/Auth/AuthClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 1542dc2a..8c532df2 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -835,7 +835,7 @@ public actor AuthClient { /// - 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 you to initiate the OAuth flow. + /// - Returns: A URL that you can use to initiate the OAuth flow. public func getURLForLinkIdentity( provider: Provider, scopes: String? = nil, From c10c6a9a1d9b7d55fbe64ed9bbca43c85414919a Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 21 Mar 2024 17:18:03 +0700 Subject: [PATCH 07/11] Add example for unlink identity --- Examples/Examples/Profile/ProfileView.swift | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Examples/Examples/Profile/ProfileView.swift b/Examples/Examples/Profile/ProfileView.swift index ac0e3d60..862d9fb4 100644 --- a/Examples/Examples/Profile/ProfileView.swift +++ b/Examples/Examples/Profile/ProfileView.swift @@ -13,8 +13,8 @@ struct ProfileView: View { @State var user: User? - var providers: [Provider] { - Provider.allCases + var identities: [UserIdentity] { + user?.identities ?? [] } var body: some View { @@ -32,6 +32,20 @@ struct ProfileView: View { } } + 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() From 525ca7469a90b05e4a9aab55a53e10d2afb82154 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 21 Mar 2024 17:20:48 +0700 Subject: [PATCH 08/11] test: add snapshot test to unlinkIdentity method --- Tests/AuthTests/RequestsTests.swift | 20 +++++++++++++++++++ .../RequestsTests/testUnlinkIdentity.1.txt | 6 ++++++ 2 files changed, 26 insertions(+) create mode 100644 Tests/AuthTests/__Snapshots__/RequestsTests/testUnlinkIdentity.1.txt diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 5263b621..4ea7f9cb 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -354,6 +354,26 @@ final class RequestsTests: XCTestCase { } } + func testUnlinkIdentity() async { + sessionManager.returnSession = .success(.validSession) + + let sut = makeSUT() + + await assert { + try await sut.unlinkIdentity( + UserIdentity( + id: "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/__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 From 39c1a16b8e7c80e7303e71b5d6c610144693cb8d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 22 Mar 2024 10:58:04 +0700 Subject: [PATCH 09/11] Fix unlink identity and add example --- Examples/Examples.xcodeproj/project.pbxproj | 4 + Examples/Examples/ActionState.swift | 47 ++++++++++ Examples/Examples/Profile/ProfileView.swift | 7 +- .../Examples/Profile/UserIdentityList.swift | 92 +++++++++++++++++++ Examples/supabase/config.toml | 16 +++- Sources/Auth/AuthClient.swift | 4 +- Sources/Auth/Types.swift | 3 + Tests/AuthTests/RequestsTests.swift | 3 +- Tests/AuthTests/Resources/session.json | 1 + .../AuthTests/Resources/signup-response.json | 1 + Tests/AuthTests/Resources/user.json | 1 + 11 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 Examples/Examples/Profile/UserIdentityList.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 6e99bd02..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 */; }; @@ -82,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 = ""; }; @@ -264,6 +266,7 @@ isa = PBXGroup; children = ( 79B1C80B2BABFF8000D991AA /* ProfileView.swift */, + 794C61D52BAD1E12000E6B0F /* UserIdentityList.swift */, ); path = Profile; sourceTree = ""; @@ -483,6 +486,7 @@ 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 */, 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/Profile/ProfileView.swift b/Examples/Examples/Profile/ProfileView.swift index 862d9fb4..f418faa2 100644 --- a/Examples/Examples/Profile/ProfileView.swift +++ b/Examples/Examples/Profile/ProfileView.swift @@ -9,8 +9,6 @@ import Supabase import SwiftUI struct ProfileView: View { - @Environment(\.webAuthenticationSession) var webAuthenticationSession - @State var user: User? var identities: [UserIdentity] { @@ -26,6 +24,11 @@ struct ProfileView: View { } } + NavigationLink("Identities") { + UserIdentityList() + .navigationTitle("Identities") + } + Button("Reauthenticate") { Task { try! await supabase.auth.reauthenticate() diff --git a/Examples/Examples/Profile/UserIdentityList.swift b/Examples/Examples/Profile/UserIdentityList.swift new file mode 100644 index 00000000..c61c9d8c --- /dev/null +++ b/Examples/Examples/Profile/UserIdentityList.swift @@ -0,0 +1,92 @@ +// +// 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) + .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 + } + } + } + } + } + } + } + } +} + +#Preview { + UserIdentityList() +} diff --git a/Examples/supabase/config.toml b/Examples/supabase/config.toml index 3075bf5d..863a2d45 100644 --- a/Examples/supabase/config.toml +++ b/Examples/supabase/config.toml @@ -40,9 +40,12 @@ 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 @@ -63,8 +66,8 @@ enable_confirmations = false # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, # `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch`, `twitter`, `slack`, `spotify`. [auth.external.apple] -enabled = true -client_id = "com.supabase.swift-examples" +enabled = false +client_id = "" # 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. @@ -72,3 +75,8 @@ 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 8c532df2..afe35d56 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -841,7 +841,7 @@ public actor AuthClient { scopes: String? = nil, redirectTo: URL? = nil, queryParams: [(name: String, value: String?)] = [] - ) async throws -> URL { + ) throws -> URL { try getURLForProvider( url: configuration.url.appendingPathComponent("user/identities/authorize"), provider: provider, @@ -856,7 +856,7 @@ public actor AuthClient { public func unlinkIdentity(_ identity: UserIdentity) async throws { try await api.authorizedExecute( Request( - path: "/user/identities/\(identity.id)", + path: "/user/identities/\(identity.identityId)", method: .delete ) ) diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 43a2b3bb..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 diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 4ea7f9cb..60613d47 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -362,7 +362,8 @@ final class RequestsTests: XCTestCase { await assert { try await sut.unlinkIdentity( UserIdentity( - id: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F", + id: "5923044", + identityId: UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!, userId: UUID(), identityData: [:], provider: "email", 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" }, From 9159f136af783a32b274b34ab87dbd003f73f54d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 22 Mar 2024 11:22:59 +0700 Subject: [PATCH 10/11] fix examples build --- .../Examples/Profile/UserIdentityList.swift | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/Examples/Examples/Profile/UserIdentityList.swift b/Examples/Examples/Profile/UserIdentityList.swift index c61c9d8c..3777c499 100644 --- a/Examples/Examples/Profile/UserIdentityList.swift +++ b/Examples/Examples/Profile/UserIdentityList.swift @@ -59,25 +59,28 @@ struct UserIdentityList: View { 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 + #if swift(>=5.10) + 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)"] + ) - 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 + } - debug("\(callbackURL)") - } else { - // Fallback on earlier versions + } catch { + self.error = error } - } catch { - self.error = error - } + #endif } } } From 5b92f7fe74ffd5fcb305e947184270184abc394f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 22 Mar 2024 11:28:16 +0700 Subject: [PATCH 11/11] Hide add button when unsupported --- .../Examples/Profile/UserIdentityList.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Examples/Examples/Profile/UserIdentityList.swift b/Examples/Examples/Profile/UserIdentityList.swift index 3777c499..1b0e976d 100644 --- a/Examples/Examples/Profile/UserIdentityList.swift +++ b/Examples/Examples/Profile/UserIdentityList.swift @@ -53,13 +53,13 @@ struct UserIdentityList: View { } } .id(id) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Menu("Add") { - ForEach(providers) { provider in - Button(provider.rawValue) { - Task { - #if swift(>=5.10) + #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) @@ -80,13 +80,13 @@ struct UserIdentityList: View { } catch { self.error = error } - #endif + } } } } } } - } + #endif } }