From 31bd49e192184ea78e88f32711ab51cc4b4506bf Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 8 Aug 2025 09:26:53 -0300 Subject: [PATCH 1/4] feat(auth): implement linkIdentity with OIDC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add linkIdentityWithIdToken method to AuthClient - Refactor signInWithIdToken to support identity linking - Add linkIdentity property to OpenIDConnectCredentials - Improve code formatting consistency 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Sources/Auth/AuthClient.swift | 30 +++++++++++++++++++++++++----- Sources/Auth/Types.swift | 2 ++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 9948ea1f..22d674ca 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -380,7 +380,15 @@ public actor AuthClient { /// The ID token is verified for validity and a new session is established. @discardableResult public func signInWithIdToken(credentials: OpenIDConnectCredentials) async throws -> Session { - try await _signIn( + try await _signInWithIdToken(credentials: credentials, linkIdentity: false) + } + + private func _signInWithIdToken(credentials: OpenIDConnectCredentials, linkIdentity: Bool) + async throws -> Session + { + var credentials = credentials + credentials.linkIdentity = linkIdentity + return try await _signIn( request: .init( url: configuration.url.appendingPathComponent("token"), method: .post, @@ -578,7 +586,8 @@ public actor AuthClient { if codeVerifier == nil { logger?.error( - "code verifier not found, a code verifier should exist when calling this method.") + "code verifier not found, a code verifier should exist when calling this method." + ) } let session: Session = try await api.execute( @@ -804,7 +813,8 @@ public actor AuthClient { case .implicit: guard isImplicitGrantFlow(params: params) else { throw AuthError.implicitGrantRedirect( - message: "Not a valid implicit grant flow URL: \(url)") + message: "Not a valid implicit grant flow URL: \(url)" + ) } return try await handleImplicitGrantFlow(params: params) @@ -821,7 +831,8 @@ public actor AuthClient { if let errorDescription = params["error_description"] { throw AuthError.implicitGrantRedirect( - message: errorDescription.replacingOccurrences(of: "+", with: " ")) + message: errorDescription.replacingOccurrences(of: "+", with: " ") + ) } guard @@ -1177,6 +1188,14 @@ public actor AuthClient { try await user().identities ?? [] } + /// Link an identity to the current user using an ID token. + @discardableResult + public func linkIdentityWithIdToken( + credentials: OpenIDConnectCredentials + ) async throws -> Session { + try await _signInWithIdToken(credentials: credentials, linkIdentity: true) + } + /// Links an OAuth identity to an existing user. /// /// This method supports the PKCE flow. @@ -1378,7 +1397,8 @@ public actor AuthClient { ) throws -> URL { guard var components = URLComponents( - url: url, resolvingAgainstBaseURL: false + url: url, + resolvingAgainstBaseURL: false ) else { throw URLError(.badURL) diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 3aca69ca..d03cf8a2 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -336,6 +336,8 @@ public struct OpenIDConnectCredentials: Codable, Hashable, Sendable { /// Verification token received when the user completes the captcha on the site. public var gotrueMetaSecurity: AuthMetaSecurity? + var linkIdentity: Bool = false + public init( provider: Provider, idToken: String, From e23515cbbd90cdf000f70728c2ea995ae83c39f1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 8 Aug 2025 09:42:29 -0300 Subject: [PATCH 2/4] test(auth): record snapshots --- Tests/AuthTests/AuthClientTests.swift | 4 ++-- .../__Snapshots__/RequestsTests/testSignInWithIdToken.1.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 2a364045..c621d7fd 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -712,12 +712,12 @@ final class AuthClientTests: XCTestCase { #""" curl \ --request POST \ - --header "Content-Length: 145" \ + --header "Content-Length: 167" \ --header "Content-Type: application/json" \ --header "X-Client-Info: auth-swift/0.0.0" \ --header "X-Supabase-Api-Version: 2024-01-01" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - --data "{\"access_token\":\"access-token\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"id_token\":\"id-token\",\"nonce\":\"nonce\",\"provider\":\"apple\"}" \ + --data "{\"access_token\":\"access-token\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"id_token\":\"id-token\",\"link_identity\":false,\"nonce\":\"nonce\",\"provider\":\"apple\"}" \ "http://localhost:54321/auth/v1/token?grant_type=id_token" """# } diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testSignInWithIdToken.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testSignInWithIdToken.1.txt index 37477c44..5c0100f9 100644 --- a/Tests/AuthTests/__Snapshots__/RequestsTests/testSignInWithIdToken.1.txt +++ b/Tests/AuthTests/__Snapshots__/RequestsTests/testSignInWithIdToken.1.txt @@ -4,5 +4,5 @@ curl \ --header "Content-Type: application/json" \ --header "X-Client-Info: gotrue-swift/x.y.z" \ --header "X-Supabase-Api-Version: 2024-01-01" \ - --data "{\"access_token\":\"access-token\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"id_token\":\"id-token\",\"nonce\":\"nonce\",\"provider\":\"apple\"}" \ + --data "{\"access_token\":\"access-token\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"id_token\":\"id-token\",\"link_identity\":false,\"nonce\":\"nonce\",\"provider\":\"apple\"}" \ "http://localhost:54321/auth/v1/token?grant_type=id_token" \ No newline at end of file From c4cfc4b99369d08b487e50ed0044b471acc25443 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 8 Aug 2025 09:44:39 -0300 Subject: [PATCH 3/4] test: add snapshot test for linkIdentityWithIdToken method --- Tests/AuthTests/AuthClientTests.swift | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index c621d7fd..477f06d8 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -484,6 +484,43 @@ final class AuthClientTests: XCTestCase { expectNoDifference(receivedURL.value?.absoluteString, url) } + func testLinkIdentityWithIdToken() async throws { + Mock( + url: clientURL.appendingPathComponent("token"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 166" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"access_token\":\"access-token\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"id_token\":\"id-token\",\"link_identity\":true,\"nonce\":\"nonce\",\"provider\":\"apple\"}" \ + "http://localhost:54321/auth/v1/token?grant_type=id_token" + """# + } + .register() + + let sut = makeSUT() + + try await sut.linkIdentityWithIdToken( + credentials: OpenIDConnectCredentials( + provider: .apple, + idToken: "id-token", + accessToken: "access-token", + nonce: "nonce", + gotrueMetaSecurity: AuthMetaSecurity( + captchaToken: "captcha-token" + ) + ) + ) + } + func testAdminListUsers() async throws { Mock( url: clientURL.appendingPathComponent("admin/users"), From da36f30ae547ee9a0f25c2679f5d2ec15d38b8ab Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 18 Aug 2025 16:56:39 -0300 Subject: [PATCH 4/4] add link identity with oidc example --- Examples/Examples.xcodeproj/project.pbxproj | 4 -- Examples/Examples/Examples.entitlements | 4 ++ .../Examples/Profile/UserIdentityList.swift | 66 ++++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 3 +- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 9eaca694..999ba83e 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -807,7 +807,6 @@ SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; @@ -847,7 +846,6 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; @@ -888,7 +886,6 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -928,7 +925,6 @@ SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/Examples/Examples/Examples.entitlements b/Examples/Examples/Examples.entitlements index 5776a3a2..1ce15d5a 100644 --- a/Examples/Examples/Examples.entitlements +++ b/Examples/Examples/Examples.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.applesignin + + Default + com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only diff --git a/Examples/Examples/Profile/UserIdentityList.swift b/Examples/Examples/Profile/UserIdentityList.swift index e5324ac1..bb7f2d41 100644 --- a/Examples/Examples/Profile/UserIdentityList.swift +++ b/Examples/Examples/Profile/UserIdentityList.swift @@ -5,6 +5,7 @@ // Created by Guilherme Souza on 22/03/24. // +import AuthenticationServices import Supabase import SwiftUI @@ -62,7 +63,11 @@ struct UserIdentityList: View { Button(provider.rawValue) { Task { do { - try await supabase.auth.linkIdentity(provider: provider) + if provider == .apple { + try await linkAppleIdentity() + } else { + try await supabase.auth.linkIdentity(provider: provider) + } } catch { self.error = error } @@ -74,8 +79,67 @@ struct UserIdentityList: View { } #endif } + + private func linkAppleIdentity() async throws { + let provider = ASAuthorizationAppleIDProvider() + let request = provider.createRequest() + request.requestedScopes = [.email, .fullName] + + let controller = ASAuthorizationController(authorizationRequests: [request]) + let authorization = try await controller.performRequests() + + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else { + debug("Invalid credential") + return + } + + guard + let identityToken = credential.identityToken.flatMap({ String(data: $0, encoding: .utf8) }) + else { + debug("Invalid identity token") + return + } + + try await supabase.auth.linkIdentityWithIdToken( + credentials: OpenIDConnectCredentials( + provider: .apple, + idToken: identityToken + ) + ) + } } #Preview { UserIdentityList() } + +extension ASAuthorizationController { + @MainActor + func performRequests() async throws -> ASAuthorization { + let delegate = _Delegate() + self.delegate = delegate + return try await withCheckedThrowingContinuation { continuation in + delegate.continuation = continuation + + self.performRequests() + } + } + + private final class _Delegate: NSObject, ASAuthorizationControllerDelegate { + var continuation: CheckedContinuation? + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + continuation?.resume(returning: authorization) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: any Error + ) { + continuation?.resume(throwing: error) + } + } +} diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index d0804396..98efa879 100644 --- a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "68a31593121bf823182bc731b17208689dafb38f7cb085035de5e74a0ed41e89", "pins" : [ { "identity" : "appauth-ios", @@ -208,5 +209,5 @@ } } ], - "version" : 2 + "version" : 3 }