From 0efc59a7cff096d874e5a328bb5108edc3db4e7f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 3 Nov 2025 07:07:35 -0300 Subject: [PATCH 1/2] fix(auth): replace trait with runtime configuration flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the EmitLocalSessionAsInitialSession trait with a runtime configuration flag to resolve compatibility issues with Xcode projects and align with Apple's trait guidelines. ## Changes - Added `emitLocalSessionAsInitialSession: Bool` property to `AuthClient.Configuration` - Defaults to `false` for backward compatibility - Will change to `true` in next major release - Replaced conditional compilation (`#if EmitLocalSessionAsInitialSession`) with runtime checks - Updated deprecation warning to reference the new configuration option - Added tests for both behaviors (old and new) - Removed `EmitLocalSessionAsInitialSession` trait from Package@swift-6.1.swift ## Benefits - Works in all project types (Xcode, SPM, CocoaPods) - No recompilation required to change behavior - Better discoverability through autocomplete and docs - Complies with Apple's trait guidelines (traits must be strictly additive) ## Migration Users who want the new behavior can now set: ```swift AuthClient( // ... other config emitLocalSessionAsInitialSession: true ) ``` Resolves compatibility issues where Xcode projects cannot enable package traits. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Package@swift-6.1.swift | 215 --------------------- Sources/Auth/AuthClient.swift | 10 +- Sources/Auth/AuthClientConfiguration.swift | 22 ++- Tests/AuthTests/AuthClientTests.swift | 83 ++++++-- 4 files changed, 95 insertions(+), 235 deletions(-) delete mode 100644 Package@swift-6.1.swift 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/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) From 4520ce1b551e46292ec15f87ed270abc5a2f97e1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 3 Nov 2025 07:11:27 -0300 Subject: [PATCH 2/2] fix(auth): add emitLocalSessionAsInitialSession to SupabaseClientOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for the emitLocalSessionAsInitialSession flag in SupabaseClientOptions.AuthOptions so users can configure this behavior when creating a SupabaseClient. - Added emitLocalSessionAsInitialSession property to SupabaseClientOptions.AuthOptions - Updated both initializers to include the new parameter - Pass the flag through to AuthClient initialization in SupabaseClient This ensures users can configure the flag whether they create an AuthClient directly or use SupabaseClient. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/Supabase/SupabaseClient.swift | 3 ++- Sources/Supabase/Types.swift | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 ) }