diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index a01462071..977bca30f 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -815,7 +815,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; @@ -855,7 +854,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; @@ -896,7 +894,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; @@ -936,7 +933,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 5776a3a20..1ce15d5af 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 e5324ac16..bb7f2d410 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/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 9948ea1f2..5a36766f1 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -578,7 +578,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 +805,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 +823,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 +1180,30 @@ 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 { + var credentials = credentials + credentials.linkIdentity = true + + let session = try await api.execute( + .init( + url: configuration.url.appendingPathComponent("token"), + method: .post, + query: [URLQueryItem(name: "grant_type", value: "id_token")], + headers: [.authorization: "Bearer \(session.accessToken)"], + body: configuration.encoder.encode(credentials) + ) + ).decoded(as: Session.self, decoder: configuration.decoder) + + await sessionManager.update(session) + eventEmitter.emit(.userUpdated, session: session) + + return session + } + /// Links an OAuth identity to an existing user. /// /// This method supports the PKCE flow. @@ -1378,7 +1405,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 3aca69ca9..d03cf8a22 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, diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index 896199ac3..f43063471 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", @@ -217,5 +218,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 2a3640458..2fdab67d8 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -111,12 +111,11 @@ final class AuthClientTests: XCTestCase { Dependencies[sut.clientID].sessionStorage.store(.validSession) - let eventsTask = Task { - await sut.authStateChanges.prefix(2).collect() - } - await Task.megaYield() - - try await sut.signOut() + try await assertAuthStateChanges( + sut: sut, + action: { try await sut.signOut() }, + expectedEvents: [.initialSession, .signedOut] + ) do { _ = try await sut.session @@ -128,9 +127,6 @@ final class AuthClientTests: XCTestCase { """ } } - - let events = await eventsTask.value.map(\.event) - expectNoDifference(events, [.initialSession, .signedOut]) } func testSignOutWithOthersScopeShouldNotRemoveLocalSession() async throws { @@ -331,19 +327,12 @@ final class AuthClientTests: XCTestCase { let sut = makeSUT() - let eventsTask = Task { - await sut.authStateChanges.prefix(2).collect() - } - - await Task.megaYield() - - try await sut.signInAnonymously() - - let events = await eventsTask.value.map(\.event) - let sessions = await eventsTask.value.map(\.session) - - expectNoDifference(events, [.initialSession, .signedIn]) - expectNoDifference(sessions, [nil, session]) + try await assertAuthStateChanges( + sut: sut, + action: { try await sut.signInAnonymously() }, + expectedEvents: [.initialSession, .signedIn], + expectedSessions: [nil, session] + ) expectNoDifference(sut.currentSession, session) expectNoDifference(sut.currentUser, session.user) @@ -484,6 +473,54 @@ 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 "Authorization: Bearer accesstoken" \ + --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() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + let updatedSession = try await assertAuthStateChanges( + sut: sut, + action: { + try await sut.linkIdentityWithIdToken( + credentials: OpenIDConnectCredentials( + provider: .apple, + idToken: "id-token", + accessToken: "access-token", + nonce: "nonce", + gotrueMetaSecurity: AuthMetaSecurity( + captchaToken: "captcha-token" + ) + ) + ) + }, + expectedEvents: [.initialSession, .userUpdated] + ) + + expectNoDifference(sut.currentSession, updatedSession) + } + func testAdminListUsers() async throws { Mock( url: clientURL.appendingPathComponent("admin/users"), @@ -712,12 +749,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" """# } @@ -2042,46 +2079,46 @@ final class AuthClientTests: XCTestCase { _ = try await sut.admin.createUser(attributes: attributes) } -// func testGenerateLink_signUp() async throws { -// let sut = makeSUT() -// -// let user = User(fromMockNamed: "user") -// let encoder = JSONEncoder.supabase() -// encoder.keyEncodingStrategy = .convertToSnakeCase -// -// let userData = try encoder.encode(user) -// var json = try JSONSerialization.jsonObject(with: userData, options: []) as! [String: Any] -// -// json["action_link"] = "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com" -// json["email_otp"] = "123456" -// json["hashed_token"] = "hashed_token" -// json["redirect_to"] = "https://example.com" -// json["verification_type"] = "signup" -// -// let responseData = try JSONSerialization.data(withJSONObject: json) -// -// Mock( -// url: clientURL.appendingPathComponent("admin/generate_link"), -// statusCode: 200, -// data: [ -// .post: responseData -// ] -// ) -// .register() -// -// let link = try await sut.admin.generateLink( -// params: .signUp( -// email: "test@example.com", -// password: "password", -// data: ["full_name": "John Doe"] -// ) -// ) -// -// expectNoDifference( -// link.properties.actionLink.absoluteString, -// "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) -// ) -// } + // func testGenerateLink_signUp() async throws { + // let sut = makeSUT() + // + // let user = User(fromMockNamed: "user") + // let encoder = JSONEncoder.supabase() + // encoder.keyEncodingStrategy = .convertToSnakeCase + // + // let userData = try encoder.encode(user) + // var json = try JSONSerialization.jsonObject(with: userData, options: []) as! [String: Any] + // + // json["action_link"] = "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com" + // json["email_otp"] = "123456" + // json["hashed_token"] = "hashed_token" + // json["redirect_to"] = "https://example.com" + // json["verification_type"] = "signup" + // + // let responseData = try JSONSerialization.data(withJSONObject: json) + // + // Mock( + // url: clientURL.appendingPathComponent("admin/generate_link"), + // statusCode: 200, + // data: [ + // .post: responseData + // ] + // ) + // .register() + // + // let link = try await sut.admin.generateLink( + // params: .signUp( + // email: "test@example.com", + // password: "password", + // data: ["full_name": "John Doe"] + // ) + // ) + // + // expectNoDifference( + // link.properties.actionLink.absoluteString, + // "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + // ) + // } func testInviteUserByEmail() async throws { let sut = makeSUT() @@ -2149,6 +2186,43 @@ final class AuthClientTests: XCTestCase { return sut } + + /// Convenience method for testing auth state changes and asserting events + /// - Parameters: + /// - sut: The AuthClient instance to monitor + /// - action: The async action to perform that should trigger events + /// - expectedEvents: Array of expected AuthChangeEvent values + /// - expectedSessions: Array of expected Session values (optional) + private func assertAuthStateChanges( + sut: AuthClient, + action: () async throws -> T, + expectedEvents: [AuthChangeEvent], + expectedSessions: [Session?]? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async throws -> T { + let eventsTask = Task { + await sut.authStateChanges.prefix(expectedEvents.count).collect() + } + + await Task.megaYield() + + let result = try await action() + + let authStateChanges = await eventsTask.value + let events = authStateChanges.map(\.event) + let sessions = authStateChanges.map(\.session) + + expectNoDifference(events, expectedEvents, fileID: fileID, filePath: filePath, line: line, column: column) + + if let expectedSessions = expectedSessions { + expectNoDifference(sessions, expectedSessions, fileID: fileID, filePath: filePath, line: line, column: column) + } + + return result + } } extension HTTPResponse { diff --git a/Tests/AuthTests/__Snapshots__/RequestsTests/testSignInWithIdToken.1.txt b/Tests/AuthTests/__Snapshots__/RequestsTests/testSignInWithIdToken.1.txt index 37477c441..5c0100f90 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