Skip to content

Commit 661e321

Browse files
grdsdevclaude
andauthored
feat(auth): implement linkIdentity with OIDC (#776)
* feat(auth): implement linkIdentity with OIDC - 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 <[email protected]> * test(auth): record snapshots * test: add snapshot test for linkIdentityWithIdToken method * add link identity with oidc example * refactor: simplify linkIdentityWithIdToken implementation - Remove private _signInWithIdToken helper method - Inline the implementation directly in linkIdentityWithIdToken - Update test to include Authorization header in expected request - Add session storage setup for linkIdentityWithIdToken test * test: add event assertions to testLinkIdentityWithIdToken - Add event monitoring to verify userUpdated event is triggered - Assert correct event sequence: [.initialSession, .userUpdated] - Verify session state is properly updated - Follow consistent testing pattern with other auth tests * refactor: add convenience methods for auth event assertions - Add assertAuthStateChanges convenience methods to reduce code duplication - Support both action-based and post-action event assertion patterns - Refactor testSignOut, testSignInAnonymously, and testLinkIdentityWithIdToken to use new methods - Improve test readability and maintainability - Reduce boilerplate code in auth state change tests * refactor: simplify assertAuthStateChanges by using expectedEvents.count - Remove expectedEventCount parameter from convenience methods - Use expectedEvents.count to determine number of events to collect - Reduces API surface and eliminates redundant parameter - Makes the methods more intuitive and less error-prone * refactor: improve testLinkIdentityWithIdToken and assertAuthStateChanges - Refactor testLinkIdentityWithIdToken to use action-based assertAuthStateChanges - Add proper error location tracking with fileID, filePath, line, and column parameters - Remove unused post-action assertAuthStateChanges method - Improve test structure by combining action execution with event assertion - Fix indentation in commented testGenerateLink_signUp test * refactor: improve linkIdentityWithIdToken implementation - Replace _signIn helper with direct api.execute call - Add explicit session manager update after successful API call - Add explicit eventEmitter.emit(.userUpdated) for proper event handling - Ensure consistent behavior with other identity linking methods - Improve code clarity by removing unnecessary abstraction layer --------- Co-authored-by: Claude <[email protected]>
1 parent 055740b commit 661e321

File tree

8 files changed

+244
-75
lines changed

8 files changed

+244
-75
lines changed

Examples/Examples.xcodeproj/project.pbxproj

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,6 @@
815815
SUPPORTS_MACCATALYST = NO;
816816
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
817817
SWIFT_EMIT_LOC_STRINGS = YES;
818-
SWIFT_VERSION = 5.0;
819818
TARGETED_DEVICE_FAMILY = "1,2,7";
820819
};
821820
name = Debug;
@@ -855,7 +854,6 @@
855854
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
856855
SUPPORTS_MACCATALYST = NO;
857856
SWIFT_EMIT_LOC_STRINGS = YES;
858-
SWIFT_VERSION = 5.0;
859857
TARGETED_DEVICE_FAMILY = "1,2,7";
860858
};
861859
name = Release;
@@ -896,7 +894,6 @@
896894
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
897895
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
898896
SWIFT_EMIT_LOC_STRINGS = YES;
899-
SWIFT_VERSION = 5.0;
900897
TARGETED_DEVICE_FAMILY = "1,2";
901898
};
902899
name = Debug;
@@ -936,7 +933,6 @@
936933
SDKROOT = auto;
937934
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
938935
SWIFT_EMIT_LOC_STRINGS = YES;
939-
SWIFT_VERSION = 5.0;
940936
TARGETED_DEVICE_FAMILY = "1,2";
941937
};
942938
name = Release;

Examples/Examples/Examples.entitlements

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>com.apple.developer.applesignin</key>
6+
<array>
7+
<string>Default</string>
8+
</array>
59
<key>com.apple.security.app-sandbox</key>
610
<true/>
711
<key>com.apple.security.files.user-selected.read-only</key>

Examples/Examples/Profile/UserIdentityList.swift

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Created by Guilherme Souza on 22/03/24.
66
//
77

8+
import AuthenticationServices
89
import Supabase
910
import SwiftUI
1011

@@ -62,7 +63,11 @@ struct UserIdentityList: View {
6263
Button(provider.rawValue) {
6364
Task {
6465
do {
65-
try await supabase.auth.linkIdentity(provider: provider)
66+
if provider == .apple {
67+
try await linkAppleIdentity()
68+
} else {
69+
try await supabase.auth.linkIdentity(provider: provider)
70+
}
6671
} catch {
6772
self.error = error
6873
}
@@ -74,8 +79,67 @@ struct UserIdentityList: View {
7479
}
7580
#endif
7681
}
82+
83+
private func linkAppleIdentity() async throws {
84+
let provider = ASAuthorizationAppleIDProvider()
85+
let request = provider.createRequest()
86+
request.requestedScopes = [.email, .fullName]
87+
88+
let controller = ASAuthorizationController(authorizationRequests: [request])
89+
let authorization = try await controller.performRequests()
90+
91+
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
92+
debug("Invalid credential")
93+
return
94+
}
95+
96+
guard
97+
let identityToken = credential.identityToken.flatMap({ String(data: $0, encoding: .utf8) })
98+
else {
99+
debug("Invalid identity token")
100+
return
101+
}
102+
103+
try await supabase.auth.linkIdentityWithIdToken(
104+
credentials: OpenIDConnectCredentials(
105+
provider: .apple,
106+
idToken: identityToken
107+
)
108+
)
109+
}
77110
}
78111

79112
#Preview {
80113
UserIdentityList()
81114
}
115+
116+
extension ASAuthorizationController {
117+
@MainActor
118+
func performRequests() async throws -> ASAuthorization {
119+
let delegate = _Delegate()
120+
self.delegate = delegate
121+
return try await withCheckedThrowingContinuation { continuation in
122+
delegate.continuation = continuation
123+
124+
self.performRequests()
125+
}
126+
}
127+
128+
private final class _Delegate: NSObject, ASAuthorizationControllerDelegate {
129+
var continuation: CheckedContinuation<ASAuthorization, any Error>?
130+
131+
func authorizationController(
132+
controller: ASAuthorizationController,
133+
didCompleteWithAuthorization authorization: ASAuthorization
134+
) {
135+
continuation?.resume(returning: authorization)
136+
}
137+
138+
func authorizationController(
139+
controller: ASAuthorizationController,
140+
didCompleteWithError error: any Error
141+
) {
142+
continuation?.resume(throwing: error)
143+
}
144+
}
145+
}

Sources/Auth/AuthClient.swift

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,8 @@ public actor AuthClient {
578578

579579
if codeVerifier == nil {
580580
logger?.error(
581-
"code verifier not found, a code verifier should exist when calling this method.")
581+
"code verifier not found, a code verifier should exist when calling this method."
582+
)
582583
}
583584

584585
let session: Session = try await api.execute(
@@ -804,7 +805,8 @@ public actor AuthClient {
804805
case .implicit:
805806
guard isImplicitGrantFlow(params: params) else {
806807
throw AuthError.implicitGrantRedirect(
807-
message: "Not a valid implicit grant flow URL: \(url)")
808+
message: "Not a valid implicit grant flow URL: \(url)"
809+
)
808810
}
809811
return try await handleImplicitGrantFlow(params: params)
810812

@@ -821,7 +823,8 @@ public actor AuthClient {
821823

822824
if let errorDescription = params["error_description"] {
823825
throw AuthError.implicitGrantRedirect(
824-
message: errorDescription.replacingOccurrences(of: "+", with: " "))
826+
message: errorDescription.replacingOccurrences(of: "+", with: " ")
827+
)
825828
}
826829

827830
guard
@@ -1177,6 +1180,30 @@ public actor AuthClient {
11771180
try await user().identities ?? []
11781181
}
11791182

1183+
/// Link an identity to the current user using an ID token.
1184+
@discardableResult
1185+
public func linkIdentityWithIdToken(
1186+
credentials: OpenIDConnectCredentials
1187+
) async throws -> Session {
1188+
var credentials = credentials
1189+
credentials.linkIdentity = true
1190+
1191+
let session = try await api.execute(
1192+
.init(
1193+
url: configuration.url.appendingPathComponent("token"),
1194+
method: .post,
1195+
query: [URLQueryItem(name: "grant_type", value: "id_token")],
1196+
headers: [.authorization: "Bearer \(session.accessToken)"],
1197+
body: configuration.encoder.encode(credentials)
1198+
)
1199+
).decoded(as: Session.self, decoder: configuration.decoder)
1200+
1201+
await sessionManager.update(session)
1202+
eventEmitter.emit(.userUpdated, session: session)
1203+
1204+
return session
1205+
}
1206+
11801207
/// Links an OAuth identity to an existing user.
11811208
///
11821209
/// This method supports the PKCE flow.
@@ -1378,7 +1405,8 @@ public actor AuthClient {
13781405
) throws -> URL {
13791406
guard
13801407
var components = URLComponents(
1381-
url: url, resolvingAgainstBaseURL: false
1408+
url: url,
1409+
resolvingAgainstBaseURL: false
13821410
)
13831411
else {
13841412
throw URLError(.badURL)

Sources/Auth/Types.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,8 @@ public struct OpenIDConnectCredentials: Codable, Hashable, Sendable {
336336
/// Verification token received when the user completes the captcha on the site.
337337
public var gotrueMetaSecurity: AuthMetaSecurity?
338338

339+
var linkIdentity: Bool = false
340+
339341
public init(
340342
provider: Provider,
341343
idToken: String,

Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)