diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift deleted file mode 100644 index 7a2612388..000000000 --- a/Package@swift-6.1.swift +++ /dev/null @@ -1,215 +0,0 @@ -// swift-tools-version:6.1 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import Foundation -import PackageDescription - -let package = Package( - name: "Supabase", - platforms: [ - .iOS(.v13), - .macCatalyst(.v13), - .macOS(.v10_15), - .watchOS(.v6), - .tvOS(.v13), - ], - products: [ - .library(name: "Auth", targets: ["Auth"]), - .library(name: "Functions", targets: ["Functions"]), - .library(name: "PostgREST", targets: ["PostgREST"]), - .library(name: "Realtime", targets: ["Realtime"]), - .library(name: "Storage", targets: ["Storage"]), - .library( - name: "Supabase", - targets: ["Supabase", "Functions", "PostgREST", "Auth", "Realtime", "Storage"] - ), - ], - traits: [ - .init( - name: "EmitLocalSessionAsInitialSession", - description: "Emits the local stored session as the initial session.", - enabledTraits: [] - ) - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"5.0.0"), - .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), - .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), - .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), - .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), - .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), - .package(url: "https://github.com/WeTransfer/Mocker", from: "3.0.0"), - ], - targets: [ - .target( - name: "Helpers", - dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "HTTPTypes", package: "swift-http-types"), - .product(name: "Clocks", package: "swift-clocks"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - ] - ), - .testTarget( - name: "HelpersTests", - dependencies: [ - .product(name: "CustomDump", package: "swift-custom-dump"), - "Helpers", - ] - ), - .target( - name: "Auth", - dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "Crypto", package: "swift-crypto"), - "Helpers", - ] - ), - .testTarget( - name: "AuthTests", - dependencies: [ - .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - "Auth", - "Helpers", - "TestHelpers", - ], - exclude: [ - "__Snapshots__" - ], - resources: [.process("Resources")] - ), - .target( - name: "Functions", - dependencies: [ - "Helpers" - ] - ), - .testTarget( - name: "FunctionsTests", - dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - "Functions", - "Mocker", - "TestHelpers", - ] - ), - .testTarget( - name: "IntegrationTests", - dependencies: [ - .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - "Helpers", - "Supabase", - "TestHelpers", - ], - resources: [ - .process("Fixtures"), - .process("supabase"), - ] - ), - .target( - name: "PostgREST", - dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - "Helpers", - ] - ), - .testTarget( - name: "PostgRESTTests", - dependencies: [ - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), - "Helpers", - "Mocker", - "PostgREST", - "TestHelpers", - ] - ), - .target( - name: "Realtime", - dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - "Helpers", - ] - ), - .testTarget( - name: "RealtimeTests", - dependencies: [ - .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - "PostgREST", - "Realtime", - "TestHelpers", - ] - ), - .target( - name: "Storage", - dependencies: [ - "Helpers" - ] - ), - .testTarget( - name: "StorageTests", - dependencies: [ - .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - "Mocker", - "TestHelpers", - "Storage", - ], - resources: [ - .copy("sadcat.jpg"), - .process("Fixtures"), - ] - ), - .target( - name: "Supabase", - dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), - "Auth", - "Functions", - "PostgREST", - "Realtime", - "Storage", - ] - ), - .testTarget( - name: "SupabaseTests", - dependencies: [ - .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - "Supabase", - ] - ), - .target( - name: "TestHelpers", - dependencies: [ - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - "Auth", - "Mocker", - ] - ), - ], - swiftLanguageModes: [.v5] -) - -for target in package.targets where !target.isTest { - target.swiftSettings = [ - .enableUpcomingFeature("ExistentialAny"), - .enableExperimentalFeature("StrictConcurrency"), - ] -} diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 5d8f80249..de3343386 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1393,7 +1393,7 @@ public actor AuthClient { } private func emitInitialSession(forToken token: ObservationToken) async { - #if EmitLocalSessionAsInitialSession + if configuration.emitLocalSessionAsInitialSession { guard let currentSession else { eventEmitter.emit(.initialSession, session: nil, token: token) return @@ -1407,7 +1407,7 @@ public actor AuthClient { // No need to emit `tokenRefreshed` nor `signOut` event since the `refreshSession` does it already. } } - #else + } else { let session = try? await session eventEmitter.emit(.initialSession, session: session, token: token) @@ -1417,8 +1417,8 @@ public actor AuthClient { reportIssue( """ Initial session emitted after attempting to refresh the local stored session. - This is incorrect behavior and will be fixed in the next major release since it’s a breaking change. - For now, if you want to opt-in to the new behavior, add the trait `EmitLocalSessionAsInitialSession` to your Package.swift file when importing the Supabase dependency. + This is incorrect behavior and will be fixed in the next major release since it's a breaking change. + To opt-in to the new behavior now, set `emitLocalSessionAsInitialSession: true` in your AuthClient configuration. The new behavior ensures that the locally stored session is always emitted, regardless of its validity or expiration. If you rely on the initial session to opt users in, you need to add an additional check for `session.isExpired` in the session. @@ -1426,7 +1426,7 @@ public actor AuthClient { """ ) } - #endif + } } nonisolated private func prepareForPKCE() -> ( diff --git a/Sources/Auth/AuthClientConfiguration.swift b/Sources/Auth/AuthClientConfiguration.swift index a9a0dc38f..455c92872 100644 --- a/Sources/Auth/AuthClientConfiguration.swift +++ b/Sources/Auth/AuthClientConfiguration.swift @@ -46,6 +46,16 @@ extension AuthClient { /// Set to `true` if you want to automatically refresh the token before expiring. public let autoRefreshToken: Bool + /// When `true`, emits the locally stored session immediately as the initial session, + /// regardless of its validity or expiration. When `false`, emits the initial session + /// after attempting to refresh the local stored session (legacy behavior). + /// + /// Default is `false` for backward compatibility. This will change to `true` in the next major release. + /// + /// - Note: If you rely on the initial session to opt users in, you need to add an additional + /// check for `session.isExpired` when this is set to `true`. + public let emitLocalSessionAsInitialSession: Bool + /// Initializes a AuthClient Configuration with optional parameters. /// /// - Parameters: @@ -60,6 +70,7 @@ extension AuthClient { /// - decoder: The JSON decoder to use for decoding responses. /// - fetch: The asynchronous fetch handler for network requests. /// - autoRefreshToken: Set to `true` if you want to automatically refresh the token before expiring. + /// - emitLocalSessionAsInitialSession: When `true`, emits the locally stored session immediately as the initial session. public init( url: URL? = nil, headers: [String: String] = [:], @@ -71,7 +82,8 @@ extension AuthClient { encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder, fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, - autoRefreshToken: Bool = AuthClient.Configuration.defaultAutoRefreshToken + autoRefreshToken: Bool = AuthClient.Configuration.defaultAutoRefreshToken, + emitLocalSessionAsInitialSession: Bool = false ) { let headers = headers.merging(Configuration.defaultHeaders) { l, _ in l } @@ -86,6 +98,7 @@ extension AuthClient { self.decoder = decoder self.fetch = fetch self.autoRefreshToken = autoRefreshToken + self.emitLocalSessionAsInitialSession = emitLocalSessionAsInitialSession } } @@ -103,6 +116,7 @@ extension AuthClient { /// - decoder: The JSON decoder to use for decoding responses. /// - fetch: The asynchronous fetch handler for network requests. /// - autoRefreshToken: Set to `true` if you want to automatically refresh the token before expiring. + /// - emitLocalSessionAsInitialSession: When `true`, emits the locally stored session immediately as the initial session. public init( url: URL? = nil, headers: [String: String] = [:], @@ -114,7 +128,8 @@ extension AuthClient { encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder, fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, - autoRefreshToken: Bool = AuthClient.Configuration.defaultAutoRefreshToken + autoRefreshToken: Bool = AuthClient.Configuration.defaultAutoRefreshToken, + emitLocalSessionAsInitialSession: Bool = false ) { self.init( configuration: Configuration( @@ -128,7 +143,8 @@ extension AuthClient { encoder: encoder, decoder: decoder, fetch: fetch, - autoRefreshToken: autoRefreshToken + autoRefreshToken: autoRefreshToken, + emitLocalSessionAsInitialSession: emitLocalSessionAsInitialSession ) ) } diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index b419a94e8..07029ab1f 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -181,7 +181,8 @@ public final class SupabaseClient: Sendable { // 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 + autoRefreshToken: options.auth.autoRefreshToken, + emitLocalSessionAsInitialSession: options.auth.emitLocalSessionAsInitialSession ) _realtime = UncheckedSendable( diff --git a/Sources/Supabase/Types.swift b/Sources/Supabase/Types.swift index b567d7d34..eb0d6f69e 100644 --- a/Sources/Supabase/Types.swift +++ b/Sources/Supabase/Types.swift @@ -57,6 +57,13 @@ public struct SupabaseClientOptions: Sendable { /// Set to `true` if you want to automatically refresh the token before expiring. public let autoRefreshToken: Bool + /// When `true`, emits the locally stored session immediately as the initial session, + /// regardless of its validity or expiration. When `false`, emits the initial session + /// after attempting to refresh the local stored session (legacy behavior). + /// + /// Default is `false` for backward compatibility. This will change to `true` in the next major release. + public let emitLocalSessionAsInitialSession: Bool + /// Optional function for using a third-party authentication system with Supabase. The function should return an access token or ID token (JWT) by obtaining it from the third-party auth client library. /// Note that this function may be called concurrently and many times. Use memoization and locking techniques if this is not supported by the client libraries. /// When set, the `auth` namespace of the Supabase client cannot be used. @@ -71,6 +78,7 @@ public struct SupabaseClientOptions: Sendable { encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder, autoRefreshToken: Bool = AuthClient.Configuration.defaultAutoRefreshToken, + emitLocalSessionAsInitialSession: Bool = false, accessToken: (@Sendable () async throws -> String?)? = nil ) { self.storage = storage @@ -80,6 +88,7 @@ public struct SupabaseClientOptions: Sendable { self.encoder = encoder self.decoder = decoder self.autoRefreshToken = autoRefreshToken + self.emitLocalSessionAsInitialSession = emitLocalSessionAsInitialSession self.accessToken = accessToken } } @@ -173,6 +182,7 @@ extension SupabaseClientOptions.AuthOptions { encoder: JSONEncoder = AuthClient.Configuration.jsonEncoder, decoder: JSONDecoder = AuthClient.Configuration.jsonDecoder, autoRefreshToken: Bool = AuthClient.Configuration.defaultAutoRefreshToken, + emitLocalSessionAsInitialSession: Bool = false, accessToken: (@Sendable () async throws -> String?)? = nil ) { self.init( @@ -183,6 +193,7 @@ extension SupabaseClientOptions.AuthOptions { encoder: encoder, decoder: decoder, autoRefreshToken: autoRefreshToken, + emitLocalSessionAsInitialSession: emitLocalSessionAsInitialSession, accessToken: accessToken ) } diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 99d27a0b5..c9e0b6a77 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -2211,11 +2211,48 @@ final class AuthClientTests: XCTestCase { Dependencies[sut.clientID].sessionStorage.store(.expiredSession) - #if EmitLocalSessionAsInitialSession - let expectedEvents = [AuthChangeEvent.initialSession, .signedOut] - #else - let expectedEvents = [AuthChangeEvent.signedOut, .initialSession] - #endif + let expectedEvents = [AuthChangeEvent.signedOut, .initialSession] + + try await assertAuthStateChanges( + sut: sut, + action: { + do { + _ = try await sut.session + XCTFail("Expected failure") + } catch { + XCTAssertEqual(error as? AuthError, .sessionMissing) + } + }, + expectedEvents: expectedEvents + ) + + XCTAssertNil(Dependencies[sut.clientID].sessionStorage.get()) + } + + func testRemoveSessionAndSignoutIfRefreshTokenNotFoundErrorReturned_withEmitLocalSessionAsInitialSession() async throws { + let sut = makeSUT(emitLocalSessionAsInitialSession: true) + + Mock( + url: clientURL.appendingPathComponent("token").appendingQueryItems([ + URLQueryItem(name: "grant_type", value: "refresh_token") + ]), + statusCode: 403, + data: [ + .post: Data( + """ + { + "error_code": "refresh_token_not_found", + "message": "Invalid Refresh Token: Refresh Token Not Found" + } + """.utf8 + ) + ] + ) + .register() + + Dependencies[sut.clientID].sessionStorage.store(.expiredSession) + + let expectedEvents = [AuthChangeEvent.initialSession, .signedOut] try await assertAuthStateChanges( sut: sut, @@ -2247,11 +2284,32 @@ final class AuthClientTests: XCTestCase { Dependencies[sut.clientID].sessionStorage.store(.expiredSession) - #if EmitLocalSessionAsInitialSession - let expectedEvents = [AuthChangeEvent.initialSession, .tokenRefreshed] - #else - let expectedEvents = [AuthChangeEvent.tokenRefreshed, .initialSession] - #endif + let expectedEvents = [AuthChangeEvent.tokenRefreshed, .initialSession] + + try await assertAuthStateChanges( + sut: sut, + action: { + _ = try await sut.session + }, + expectedEvents: expectedEvents + ) + } + + func testRefreshToken_withEmitLocalSessionAsInitialSession() async throws { + let sut = makeSUT(emitLocalSessionAsInitialSession: true) + + Mock( + url: clientURL.appendingPathComponent("token").appendingQueryItems([ + URLQueryItem(name: "grant_type", value: "refresh_token") + ]), + statusCode: 200, + data: [.post: try AuthClient.Configuration.jsonEncoder.encode(Session.validSession)] + ) + .register() + + Dependencies[sut.clientID].sessionStorage.store(.expiredSession) + + let expectedEvents = [AuthChangeEvent.initialSession, .tokenRefreshed] try await assertAuthStateChanges( sut: sut, @@ -2618,7 +2676,7 @@ final class AuthClientTests: XCTestCase { XCTAssertNotNil(result.claims.aud) } - private func makeSUT(flowType: AuthFlowType = .pkce) -> AuthClient { + private func makeSUT(flowType: AuthFlowType = .pkce, emitLocalSessionAsInitialSession: Bool = false) -> AuthClient { let sessionConfiguration = URLSessionConfiguration.default sessionConfiguration.protocolClasses = [MockingURLProtocol.self] let session = URLSession(configuration: sessionConfiguration) @@ -2638,7 +2696,8 @@ final class AuthClientTests: XCTestCase { encoder: encoder, fetch: { request in try await session.data(for: request) - } + }, + emitLocalSessionAsInitialSession: emitLocalSessionAsInitialSession ) let sut = AuthClient(configuration: configuration)