From 862c40b4f0f4c72589ce24425b3c008cd2ac0bf9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 3 Dec 2024 18:08:12 -0300 Subject: [PATCH 1/2] fix(auth)!: serialize AuthClient to @MainActor --- Sources/Auth/AuthAdmin.swift | 3 +- Sources/Auth/AuthClient.swift | 9 +-- Sources/Auth/AuthMFA.swift | 4 +- Sources/Auth/Internal/EventEmitter.swift | 1 + Sources/Auth/Internal/SessionManager.swift | 2 +- Sources/Supabase/SupabaseClient.swift | 83 +++++++++++++--------- 6 files changed, 60 insertions(+), 42 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 1a31aee8..dc7169fb 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -9,7 +9,8 @@ import Foundation import Helpers import HTTPTypes -public struct AuthAdmin: Sendable { +@MainActor +public struct AuthAdmin { let clientID: AuthClientID var configuration: AuthClient.Configuration { Dependencies[clientID].configuration } diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 2ca227c1..c1ae22d5 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -16,7 +16,8 @@ import Helpers typealias AuthClientID = UUID -public final class AuthClient: Sendable { +@MainActor +public final class AuthClient { let clientID = AuthClientID() private var api: APIClient { Dependencies[clientID].api } @@ -79,11 +80,10 @@ public final class AuthClient: Sendable { sessionManager: .live(clientID: clientID) ) - Task { @MainActor in observeAppLifecycleChanges() } + observeAppLifecycleChanges() } #if canImport(ObjectiveC) - @MainActor private func observeAppLifecycleChanges() { #if canImport(UIKit) #if canImport(WatchKit) @@ -149,6 +149,7 @@ public final class AuthClient: Sendable { // no-op } #endif + /// Listen for auth state changes. /// - Parameter listener: Block that executes when a new event is emitted. /// - Returns: A handle that can be used to manually unsubscribe. @@ -1161,7 +1162,7 @@ public final class AuthClient: Sendable { queryParams: queryParams ) - await launchURL(response.url) + launchURL(response.url) } /// Links an OAuth identity to an existing user. diff --git a/Sources/Auth/AuthMFA.swift b/Sources/Auth/AuthMFA.swift index 1c329e4d..92f1386c 100644 --- a/Sources/Auth/AuthMFA.swift +++ b/Sources/Auth/AuthMFA.swift @@ -2,7 +2,8 @@ import Foundation import Helpers /// Contains the full multi-factor authentication API. -public struct AuthMFA: Sendable { +@MainActor +public struct AuthMFA { let clientID: AuthClientID var configuration: AuthClient.Configuration { Dependencies[clientID].configuration } @@ -64,7 +65,6 @@ public struct AuthMFA: Sendable { ).decoded(decoder: decoder) await sessionManager.update(response) - eventEmitter.emit(.mfaChallengeVerified, session: response, token: nil) return response diff --git a/Sources/Auth/Internal/EventEmitter.swift b/Sources/Auth/Internal/EventEmitter.swift index 53b66b76..0160b4c0 100644 --- a/Sources/Auth/Internal/EventEmitter.swift +++ b/Sources/Auth/Internal/EventEmitter.swift @@ -2,6 +2,7 @@ import ConcurrencyExtras import Foundation import Helpers +@MainActor struct AuthStateChangeEventEmitter { var emitter = EventEmitter<(AuthChangeEvent, Session?)?>(initialEvent: nil, emitsLastEventWhenAttaching: false) var logger: (any SupabaseLogger)? diff --git a/Sources/Auth/Internal/SessionManager.swift b/Sources/Auth/Internal/SessionManager.swift index 684bbce7..f3eb447a 100644 --- a/Sources/Auth/Internal/SessionManager.swift +++ b/Sources/Auth/Internal/SessionManager.swift @@ -94,7 +94,7 @@ private actor LiveSessionManager { .decoded(as: Session.self, decoder: configuration.decoder) update(session) - eventEmitter.emit(.tokenRefreshed, session: session) + await eventEmitter.emit(.tokenRefreshed, session: session) return session } catch { diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 28c75dbb..ff1ea353 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -2,12 +2,12 @@ import ConcurrencyExtras import Foundation @_exported import Functions +import HTTPTypes import Helpers import IssueReporting @_exported import PostgREST @_exported import Realtime @_exported import Storage -import HTTPTypes #if canImport(FoundationNetworking) import FoundationNetworking @@ -28,17 +28,25 @@ public final class SupabaseClient: Sendable { let databaseURL: URL let functionsURL: URL - private let _auth: AuthClient + @MainActor + private var _auth: AuthClient? /// Supabase Auth allows you to create and manage user sessions for access to data that is secured by access policies. + @MainActor public var auth: AuthClient { if options.auth.accessToken != nil { - reportIssue(""" - Supabase Client is configured with the auth.accessToken option, - accessing supabase.auth is not possible. - """) + reportIssue( + """ + Supabase Client is configured with the auth.accessToken option, + accessing supabase.auth is not possible. + """) + } + + if _auth == nil { + _auth = _initAuthClient() } - return _auth + + return _auth! } var rest: PostgrestClient { @@ -161,26 +169,6 @@ public final class SupabaseClient: Sendable { ]) .merging(with: HTTPFields(options.global.headers)) - // default storage key uses the supabase project ref as a namespace - let defaultStorageKey = "sb-\(supabaseURL.host!.split(separator: ".")[0])-auth-token" - - _auth = AuthClient( - url: supabaseURL.appendingPathComponent("/auth/v1"), - headers: _headers.dictionary, - flowType: options.auth.flowType, - redirectToURL: options.auth.redirectToURL, - storageKey: options.auth.storageKey ?? defaultStorageKey, - localStorage: options.auth.storage, - logger: options.global.logger, - encoder: options.auth.encoder, - decoder: options.auth.decoder, - fetch: { - // DON'T use `fetchWithAuth` method within the AuthClient as it may cause a deadlock. - try await options.global.session.data(for: $0) - }, - autoRefreshToken: options.auth.autoRefreshToken - ) - _realtime = UncheckedSendable( RealtimeClient( supabaseURL.appendingPathComponent("/realtime/v1").absoluteString, @@ -329,6 +317,7 @@ public final class SupabaseClient: Sendable { /// supabase.handle(url) /// } /// ``` + @MainActor public func handle(_ url: URL) { auth.handle(url) } @@ -351,11 +340,12 @@ public final class SupabaseClient: Sendable { } private func adapt(request: URLRequest) async -> URLRequest { - let token: String? = if let accessToken = options.auth.accessToken { - try? await accessToken() - } else { - try? await auth.session.accessToken - } + let token: String? = + if let accessToken = options.auth.accessToken { + try? await accessToken() + } else { + try? await auth.session.accessToken + } var request = request if let token { @@ -366,7 +356,7 @@ public final class SupabaseClient: Sendable { private func listenForAuthEvents() { let task = Task { - for await (event, session) in auth.authStateChanges { + for await (event, session) in await auth.authStateChanges { await handleTokenChanged(event: event, session: session) } } @@ -377,7 +367,9 @@ public final class SupabaseClient: Sendable { private func handleTokenChanged(event: AuthChangeEvent, session: Session?) async { let accessToken: String? = mutableState.withValue { - if [.initialSession, .signedIn, .tokenRefreshed].contains(event), $0.changedAccessToken != session?.accessToken { + if [.initialSession, .signedIn, .tokenRefreshed].contains(event), + $0.changedAccessToken != session?.accessToken + { $0.changedAccessToken = session?.accessToken return session?.accessToken ?? supabaseKey } @@ -393,4 +385,27 @@ public final class SupabaseClient: Sendable { realtime.setAuth(accessToken) await realtimeV2.setAuth(accessToken) } + + @MainActor + private func _initAuthClient() -> AuthClient { + // default storage key uses the supabase project ref as a namespace + let defaultStorageKey = "sb-\(supabaseURL.host!.split(separator: ".")[0])-auth-token" + + return AuthClient( + url: supabaseURL.appendingPathComponent("/auth/v1"), + headers: _headers.dictionary, + flowType: options.auth.flowType, + redirectToURL: options.auth.redirectToURL, + storageKey: options.auth.storageKey ?? defaultStorageKey, + localStorage: options.auth.storage, + logger: options.global.logger, + encoder: options.auth.encoder, + decoder: options.auth.decoder, + fetch: { [options] in + // DON'T use `fetchWithAuth` method within the AuthClient as it may cause a deadlock. + try await options.global.session.data(for: $0) + }, + autoRefreshToken: options.auth.autoRefreshToken + ) + } } From 56af12c992e82ae1ab2b7154bd273d344219fa04 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 3 Dec 2024 18:12:14 -0300 Subject: [PATCH 2/2] fix tests --- .../AuthClientMultipleInstancesTests.swift | 4 +++- Tests/AuthTests/AuthClientTests.swift | 1 + Tests/AuthTests/RequestsTests.swift | 16 ++++++++++------ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Tests/AuthTests/AuthClientMultipleInstancesTests.swift b/Tests/AuthTests/AuthClientMultipleInstancesTests.swift index 26998388..4c9aa37f 100644 --- a/Tests/AuthTests/AuthClientMultipleInstancesTests.swift +++ b/Tests/AuthTests/AuthClientMultipleInstancesTests.swift @@ -5,10 +5,12 @@ // Created by Guilherme Souza on 05/07/24. // -@testable import Auth import TestHelpers import XCTest +@testable import Auth + +@MainActor final class AuthClientMultipleInstancesTests: XCTestCase { func testMultipleAuthClientInstances() { let url = URL(string: "http://localhost:54321/auth")! diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 17905696..5a55b83f 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -18,6 +18,7 @@ import XCTest import FoundationNetworking #endif +@MainActor final class AuthClientTests: XCTestCase { var sessionManager: SessionManager! diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index a2b36c79..f6c46eec 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -5,19 +5,21 @@ // Created by Guilherme Souza on 07/10/23. // -@testable import Auth import Helpers import InlineSnapshotTesting import SnapshotTesting import TestHelpers import XCTest +@testable import Auth + #if canImport(FoundationNetworking) import FoundationNetworking #endif struct UnimplementedError: Error {} +@MainActor final class RequestsTests: XCTestCase { func testSignUpWithEmailAndPassword() async { let sut = makeSUT() @@ -126,7 +128,7 @@ final class RequestsTests: XCTestCase { url, URL( string: - "http://localhost:54321/auth/v1/authorize?provider=github&scopes=read,write&redirect_to=https://dummy-url.com/redirect&extra_key=extra_value" + "http://localhost:54321/auth/v1/authorize?provider=github&scopes=read,write&redirect_to=https://dummy-url.com/redirect&extra_key=extra_value" )! ) } @@ -152,7 +154,7 @@ final class RequestsTests: XCTestCase { let url = URL( string: - "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken&token_type=bearer" + "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken&token_type=bearer" )! let session = try await sut.session(from: url) @@ -173,7 +175,7 @@ final class RequestsTests: XCTestCase { let url = URL( string: - "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken" + "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken" )! do { @@ -430,7 +432,8 @@ final class RequestsTests: XCTestCase { Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { - _ = try await sut.mfa.enroll(params: MFAEnrollParams(issuer: "supabase.com", friendlyName: "test")) + _ = try await sut.mfa.enroll( + params: MFAEnrollParams(issuer: "supabase.com", friendlyName: "test")) } } @@ -480,7 +483,8 @@ final class RequestsTests: XCTestCase { Dependencies[sut.clientID].sessionStorage.store(.validSession) await assert { - _ = try await sut.mfa.verify(params: .init(factorId: "123", challengeId: "123", code: "123456")) + _ = try await sut.mfa.verify( + params: .init(factorId: "123", challengeId: "123", code: "123456")) } }