diff --git a/Examples/ProductSample/Data/AuthenticationRepository.swift b/Examples/ProductSample/Data/AuthenticationRepository.swift index c96369b2..5dcbd42a 100644 --- a/Examples/ProductSample/Data/AuthenticationRepository.swift +++ b/Examples/ProductSample/Data/AuthenticationRepository.swift @@ -28,8 +28,9 @@ struct AuthenticationRepositoryImpl: AuthenticationRepository { } func authStateListener() async -> AsyncStream { - await client.onAuthStateChange().compactMap { event in + await client.onAuthStateChange().compactMap { event, session in switch event { + case .initialSession: session != nil ? AuthenticationState.signedIn : .signedOut case .signedIn: AuthenticationState.signedIn case .signedOut: AuthenticationState.signedOut case .passwordRecovery, .tokenRefreshed, .userUpdated, .userDeleted, .mfaChallengeVerified: diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index 1d7b8537..ff42ab4c 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -8,9 +8,11 @@ public typealias AnyJSON = _Helpers.AnyJSON #endif public actor GoTrueClient { + /// FetchHandler is a type alias for asynchronous network request handling. public typealias FetchHandler = @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse) + /// Configuration struct represents the client configuration. public struct Configuration: Sendable { public let url: URL public var headers: [String: String] @@ -20,6 +22,17 @@ public actor GoTrueClient { public let decoder: JSONDecoder public let fetch: FetchHandler + /// Initializes a GoTrueClient Configuration with optional parameters. + /// + /// - Parameters: + /// - url: The base URL of the GoTrue server. + /// - headers: (Optional) Custom headers to be included in requests. + /// - flowType: (Optional) The authentication flow type. Default is `.implicit`. + /// - localStorage: (Optional) The storage mechanism for local data. Default is a + /// KeychainLocalStorage. + /// - encoder: (Optional) The JSON encoder to use for encoding requests. + /// - decoder: (Optional) The JSON decoder to use for decoding responses. + /// - fetch: (Optional) The asynchronous fetch handler for network requests. public init( url: URL, headers: [String: String] = [:], @@ -81,6 +94,17 @@ public actor GoTrueClient { /// Namespace for accessing multi-factor authentication API. public let mfa: GoTrueMFA + /// Initializes a GoTrueClient with optional parameters. + /// + /// - Parameters: + /// - url: The base URL of the GoTrue server. + /// - headers: (Optional) Custom headers to be included in requests. + /// - flowType: (Optional) The authentication flow type. Default is `.implicit`. + /// - localStorage: (Optional) The storage mechanism for local data. Default is a + /// KeychainLocalStorage. + /// - encoder: (Optional) The JSON encoder to use for encoding requests. + /// - decoder: (Optional) The JSON decoder to use for decoding responses. + /// - fetch: (Optional) The asynchronous fetch handler for network requests. public init( url: URL, headers: [String: String] = [:], @@ -103,6 +127,10 @@ public actor GoTrueClient { ) } + /// Initializes a GoTrueClient with a specific configuration. + /// + /// - Parameters: + /// - configuration: The client configuration. public init(configuration: Configuration) { let api = APIClient() @@ -144,12 +172,13 @@ public actor GoTrueClient { ) } - public func onAuthStateChange() async -> AsyncStream { + /// Listen for auth state changes. + /// + /// An `.initialSession` is always emitted when this method is called. + public func onAuthStateChange() async -> AsyncStream<(event: AuthChangeEvent, session: Session?)> { let (id, stream) = await eventEmitter.attachListener() Task { [id] in - _debug("emitInitialSessionTask start") - defer { _debug("emitInitialSessionTask end") } await emitInitialSession(forStreamWithID: id) } @@ -192,25 +221,6 @@ public actor GoTrueClient { ) } - private func prepareForPKCE() -> (codeChallenge: String?, codeChallengeMethod: String?) { - if configuration.flowType == .pkce { - let codeVerifier = PKCE.generateCodeVerifier() - - do { - try codeVerifierStorage.storeCodeVerifier(codeVerifier) - } catch { - _debug("Error storing code verifier: \(error)") - } - - let codeChallenge = PKCE.generateCodeChallenge(from: codeVerifier) - let codeChallengeMethod = codeVerifier == codeChallenge ? "plain" : "s256" - - return (codeChallenge, codeChallengeMethod) - } - - return (nil, nil) - } - /// Creates a new user. /// - Parameters: /// - phone: User's phone number with international prefix. @@ -248,7 +258,7 @@ public actor GoTrueClient { if let session = response.session { try await sessionManager.update(session) - await eventEmitter.emit(.signedIn) + await eventEmitter.emit(.signedIn, session: session) } return response @@ -308,7 +318,7 @@ public actor GoTrueClient { if session.user.emailConfirmedAt != nil || session.user.confirmedAt != nil { try await sessionManager.update(session) - await eventEmitter.emit(.signedIn) + await eventEmitter.emit(.signedIn, session: session) } return session @@ -411,7 +421,7 @@ public actor GoTrueClient { try codeVerifierStorage.deleteCodeVerifier() try await sessionManager.update(session) - await eventEmitter.emit(.signedIn) + await eventEmitter.emit(.signedIn, session: session) return session } catch { @@ -524,10 +534,10 @@ public actor GoTrueClient { ) try await sessionManager.update(session) - await eventEmitter.emit(.signedIn) + await eventEmitter.emit(.signedIn, session: session) if let type = params.first(where: { $0.name == "type" })?.value, type == "recovery" { - await eventEmitter.emit(.passwordRecovery) + await eventEmitter.emit(.passwordRecovery, session: session) } return session @@ -571,7 +581,7 @@ public actor GoTrueClient { } try await sessionManager.update(session) - await eventEmitter.emit(.signedIn) + await eventEmitter.emit(.signedIn, session: session) return session } @@ -586,9 +596,9 @@ public actor GoTrueClient { ) ) await sessionManager.remove() - await eventEmitter.emit(.signedOut) + await eventEmitter.emit(.signedOut, session: nil) } catch { - await eventEmitter.emit(.signedOut) + await eventEmitter.emit(.signedOut, session: nil) throw error } } @@ -662,7 +672,7 @@ public actor GoTrueClient { if let session = response.session { try await sessionManager.update(session) - await eventEmitter.emit(.signedIn) + await eventEmitter.emit(.signedIn, session: session) } return response @@ -702,7 +712,7 @@ public actor GoTrueClient { ).decoded(as: User.self, decoder: configuration.decoder) session.user = updatedUser try await sessionManager.update(session) - await eventEmitter.emit(.userUpdated) + await eventEmitter.emit(.userUpdated, session: session) return updatedUser } @@ -733,14 +743,24 @@ public actor GoTrueClient { ) } + /// Refresh and return a new session, regardless of expiry status. + /// - Parameter refreshToken: The optional refresh token to use for refreshing the session. If + /// none is provided then this method tries to load the refresh token from the current session. + /// - Returns: A new session. @discardableResult - public func refreshSession(refreshToken: String) async throws -> Session { + public func refreshSession(refreshToken: String? = nil) async throws -> Session { + var credentials = UserCredentials(refreshToken: refreshToken) + if credentials.refreshToken == nil { + credentials.refreshToken = try await sessionManager.session(shouldValidateExpiration: false) + .refreshToken + } + let session = try await api.execute( .init( path: "/token", method: "POST", query: [URLQueryItem(name: "grant_type", value: "refresh_token")], - body: configuration.encoder.encode(UserCredentials(refreshToken: refreshToken)) + body: configuration.encoder.encode(credentials) ) ).decoded(as: Session.self, decoder: configuration.decoder) @@ -749,33 +769,41 @@ public actor GoTrueClient { .user.confirmedAt != nil { try await sessionManager.update(session) - await eventEmitter.emit(.tokenRefreshed) + await eventEmitter.emit(.tokenRefreshed, session: session) } return session } - /// Refresh and return a new session, regardless of expiry status. - @discardableResult - public func refreshSession() async throws -> Session { - let refreshToken = try await session.refreshToken - return try await refreshSession(refreshToken: refreshToken) - } - private func emitInitialSession(forStreamWithID id: UUID) async { - _debug("start") - defer { _debug("end") } - let session = try? await session - await eventEmitter.emit(session != nil ? .signedIn : .signedOut, id) + await eventEmitter.emit(.initialSession, session, id) } - private func _debug( - _ message: String, - function: StaticString = #function, - line: UInt = #line - ) { - debugPrint("[GoTrueClient] \(function):\(line) \(message)") + private func prepareForPKCE() -> (codeChallenge: String?, codeChallengeMethod: String?) { + if configuration.flowType == .pkce { + let codeVerifier = PKCE.generateCodeVerifier() + + do { + try codeVerifierStorage.storeCodeVerifier(codeVerifier) + } catch { + assertionFailure( + """ + An error occurred while storing the code verifier, + PKCE flow may not work as expected. + + Error: \(error.localizedDescription) + """ + ) + } + + let codeChallenge = PKCE.generateCodeChallenge(from: codeVerifier) + let codeChallengeMethod = codeVerifier == codeChallenge ? "plain" : "s256" + + return (codeChallenge, codeChallengeMethod) + } + + return (nil, nil) } private func isImplicitGrantFlow(url: URL) -> Bool { @@ -793,6 +821,7 @@ public actor GoTrueClient { } extension GoTrueClient { + /// Notification posted when an auth state event is triggered. public static let didChangeAuthStateNotification = Notification.Name( "DID_CHANGE_AUTH_STATE_NOTIFICATION" ) diff --git a/Sources/GoTrue/GoTrueMFA.swift b/Sources/GoTrue/GoTrueMFA.swift index aa329464..7f224fdc 100644 --- a/Sources/GoTrue/GoTrueMFA.swift +++ b/Sources/GoTrue/GoTrueMFA.swift @@ -66,7 +66,7 @@ public actor GoTrueMFA { try await sessionManager.update(response) - await eventEmitter.emit(.mfaChallengeVerified) + await eventEmitter.emit(.mfaChallengeVerified, session: response) return response } diff --git a/Sources/GoTrue/Internal/EventEmitter.swift b/Sources/GoTrue/Internal/EventEmitter.swift index 629e6c20..6534fc9d 100644 --- a/Sources/GoTrue/Internal/EventEmitter.swift +++ b/Sources/GoTrue/Internal/EventEmitter.swift @@ -2,25 +2,25 @@ import Foundation @_spi(Internal) import _Helpers struct EventEmitter: Sendable { - var attachListener: @Sendable () async -> (id: UUID, stream: AsyncStream) - var emit: @Sendable (_ event: AuthChangeEvent, _ id: UUID?) async -> Void + var attachListener: @Sendable () async -> (id: UUID, stream: AsyncStream<(event: AuthChangeEvent, session: Session?)>) + var emit: @Sendable (_ event: AuthChangeEvent, _ session: Session?, _ id: UUID?) async -> Void } extension EventEmitter { - func emit(_ event: AuthChangeEvent) async { - await emit(event, nil) + func emit(_ event: AuthChangeEvent, session: Session?) async { + await emit(event, session, nil) } } extension EventEmitter { static var live: Self = { - let continuations = ActorIsolated([UUID: AsyncStream.Continuation]()) + let continuations = ActorIsolated([UUID: AsyncStream<(event: AuthChangeEvent, session: Session?)>.Continuation]()) return Self( attachListener: { let id = UUID() - let (stream, continuation) = AsyncStream.makeStream() + let (stream, continuation) = AsyncStream<(event: AuthChangeEvent, session: Session?)>.makeStream() continuation.onTermination = { [id] _ in continuations.withValue { @@ -34,12 +34,16 @@ extension EventEmitter { return (id, stream) }, - emit: { event, id in + emit: { event, session, id in + NotificationCenter.default.post( + name: GoTrueClient.didChangeAuthStateNotification, + object: nil + ) if let id { - continuations.value[id]?.yield(event) + continuations.value[id]?.yield((event, session)) } else { for continuation in continuations.value.values { - continuation.yield(event) + continuation.yield((event, session)) } } } diff --git a/Sources/GoTrue/Internal/SessionManager.swift b/Sources/GoTrue/Internal/SessionManager.swift index a8cf9bf3..06b1b090 100644 --- a/Sources/GoTrue/Internal/SessionManager.swift +++ b/Sources/GoTrue/Internal/SessionManager.swift @@ -7,16 +7,20 @@ struct SessionRefresher: Sendable { } struct SessionManager: Sendable { - var session: @Sendable () async throws -> Session + var session: @Sendable (_ shouldValidateExpiration: Bool) async throws -> Session var update: @Sendable (_ session: Session) async throws -> Void var remove: @Sendable () async -> Void + + func session(shouldValidateExpiration: Bool = true) async throws -> Session { + try await session(shouldValidateExpiration) + } } extension SessionManager { static var live: Self = { let manager = _LiveSessionManager() return Self( - session: { try await manager.session() }, + session: { try await manager.session(shouldValidateExpiration: $0) }, update: { try await manager.update($0) }, remove: { await manager.remove() } ) @@ -34,7 +38,7 @@ actor _LiveSessionManager { Dependencies.current.value!.sessionRefresher } - func session() async throws -> Session { + func session(shouldValidateExpiration: Bool) async throws -> Session { if let task { return try await task.value } @@ -43,7 +47,7 @@ actor _LiveSessionManager { throw GoTrueError.sessionNotFound } - if currentSession.isValid { + if currentSession.isValid || !shouldValidateExpiration { return currentSession.session } diff --git a/Sources/GoTrue/Types.swift b/Sources/GoTrue/Types.swift index 9f75aa1f..68d98b52 100644 --- a/Sources/GoTrue/Types.swift +++ b/Sources/GoTrue/Types.swift @@ -1,6 +1,7 @@ import Foundation public enum AuthChangeEvent: String, Sendable { + case initialSession = "INITIAL_SESSION" case passwordRecovery = "PASSWORD_RECOVERY" case signedIn = "SIGNED_IN" case signedOut = "SIGNED_OUT" diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 15cd9596..8017dcc5 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -61,6 +61,12 @@ public final class SupabaseClient { } /// Create a new client. + /// - Parameters: + /// - supabaseURL: The unique Supabase URL which is supplied when you create a new project in + /// your project dashboard. + /// - supabaseKey: The unique Supabase Key which is supplied when you create a new project in + /// your project dashboard. + /// - options: Custom options to configure client's behavior. public init( supabaseURL: URL, supabaseKey: String, @@ -85,7 +91,11 @@ public final class SupabaseClient { url: supabaseURL.appendingPathComponent("/auth/v1"), headers: defaultHeaders, flowType: options.auth.flowType, - localStorage: options.auth.storage + localStorage: options.auth.storage, + fetch: { + // DON'T use `fetchWithAuth` method within the GoTrueClient as it may cause a deadlock. + try await options.global.session.data(for: $0) + } ) listenForAuthEvents() @@ -118,8 +128,7 @@ public final class SupabaseClient { private func listenForAuthEvents() { listenForAuthEventsTask = Task { - for await event in await auth.onAuthStateChange() { - let session = try? await auth.session + for await (event, session) in await auth.onAuthStateChange() { handleTokenChanged(event: event, session: session) } } diff --git a/Sources/Supabase/Types.swift b/Sources/Supabase/Types.swift index ecafd48a..f38ef31f 100644 --- a/Sources/Supabase/Types.swift +++ b/Sources/Supabase/Types.swift @@ -31,7 +31,10 @@ public struct SupabaseClientOptions: Sendable { } public struct GlobalOptions: Sendable { + /// Optional headers for initializing the client, it will be passed down to all sub-clients. public let headers: [String: String] + + /// A session to use for making requests, defaults to `URLSession.shared`. public let session: URLSession public init(headers: [String: String] = [:], session: URLSession = .shared) { diff --git a/Tests/GoTrueTests/GoTrueClientTests.swift b/Tests/GoTrueTests/GoTrueClientTests.swift index c782524b..c1f6b7e3 100644 --- a/Tests/GoTrueTests/GoTrueClientTests.swift +++ b/Tests/GoTrueTests/GoTrueClientTests.swift @@ -22,12 +22,12 @@ final class GoTrueClientTests: XCTestCase { await withDependencies { $0.eventEmitter = .live - $0.sessionManager.session = { session } + $0.sessionManager.session = { @Sendable _ in session } } operation: { let authStateStream = await sut.onAuthStateChange() let streamTask = Task { - for await event in authStateStream { + for await (event, _) in authStateStream { events.withValue { $0.append(event) } @@ -38,7 +38,7 @@ final class GoTrueClientTests: XCTestCase { await fulfillment(of: [expectation]) - XCTAssertEqual(events.value, [.signedIn]) + XCTAssertEqual(events.value, [.initialSession]) streamTask.cancel() } diff --git a/Tests/GoTrueTests/Mocks/Mocks.swift b/Tests/GoTrueTests/Mocks/Mocks.swift index 4d6f52a6..05f3c073 100644 --- a/Tests/GoTrueTests/Mocks/Mocks.swift +++ b/Tests/GoTrueTests/Mocks/Mocks.swift @@ -37,7 +37,7 @@ extension EventEmitter { static let noop = Self( attachListener: { (UUID(), AsyncStream.makeStream().stream) }, - emit: { _, _ in } + emit: { _, _, _ in } ) } diff --git a/Tests/GoTrueTests/RequestsTests.swift b/Tests/GoTrueTests/RequestsTests.swift index 8e27eb27..a6c3389f 100644 --- a/Tests/GoTrueTests/RequestsTests.swift +++ b/Tests/GoTrueTests/RequestsTests.swift @@ -214,7 +214,7 @@ final class RequestsTests: XCTestCase { let sut = makeSUT() await withDependencies { - $0.sessionManager.session = { + $0.sessionManager.session = { @Sendable _ in .validSession } } operation: { @@ -241,7 +241,7 @@ final class RequestsTests: XCTestCase { func testSignOut() async { let sut = makeSUT() await withDependencies { - $0.sessionManager.session = { .validSession } + $0.sessionManager.session = { @Sendable _ in .validSession } $0.eventEmitter = .noop } operation: { await assert { @@ -289,7 +289,7 @@ final class RequestsTests: XCTestCase { let sut = makeSUT() await withDependencies { - $0.sessionManager.session = { + $0.sessionManager.session = { @Sendable _ in .validSession } } operation: {