diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift new file mode 100644 index 00000000..7a261238 --- /dev/null +++ b/Package@swift-6.1.swift @@ -0,0 +1,215 @@ +// 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 0d605063..bc467eed 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -218,9 +218,8 @@ public actor AuthClient { /// - Parameter listener: Block that executes when a new event is emitted. /// - Returns: A handle that can be used to manually unsubscribe. /// - /// - Note: This method blocks execution until the ``AuthChangeEvent/initialSession`` event is - /// emitted. Although this operation is usually fast, in case of the current stored session being - /// invalid, a call to the endpoint is necessary for refreshing the session. + /// - Note: The session emitted in the ``AuthChangeEvent/initialSession`` event may have been expired + /// since last launch, consider checking for ``Session/isExpired``. If this is the case, then expect a ``AuthChangeEvent/tokenRefreshed`` after. @discardableResult public func onAuthStateChange( _ listener: @escaping AuthStateChangeListener @@ -1393,8 +1392,36 @@ public actor AuthClient { } private func emitInitialSession(forToken token: ObservationToken) async { - let session = try? await session - eventEmitter.emit(.initialSession, session: session, token: token) + #if EmitLocalSessionAsInitialSession + guard let currentSession else { + eventEmitter.emit(.initialSession, session: nil, token: token) + return + } + + eventEmitter.emit(.initialSession, session: currentSession, token: token) + + Task { + if currentSession.isExpired { + _ = try? await sessionManager.refreshSession(currentSession.refreshToken) + // No need to emit `tokenRefreshed` nor `signOut` event since the `refreshSession` does it already. + } + } + #else + let session = try? await session + eventEmitter.emit(.initialSession, session: session, token: token) + + logger?.warning( + """ + 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. + 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. + + Check https://github.com/supabase/supabase-swift/pull/822 for more information. + """ + ) + #endif } nonisolated private func prepareForPKCE() -> ( diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 4bb919ac..99d27a0b 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -86,6 +86,8 @@ final class AuthClientTests: XCTestCase { } func testSignOut() async throws { + let sut = makeSUT() + Mock( url: clientURL.appendingPathComponent("logout"), ignoreQuery: true, @@ -107,8 +109,6 @@ final class AuthClientTests: XCTestCase { } .register() - sut = makeSUT() - Dependencies[sut.clientID].sessionStorage.store(.validSession) try await assertAuthStateChanges( @@ -2211,6 +2211,12 @@ final class AuthClientTests: XCTestCase { Dependencies[sut.clientID].sessionStorage.store(.expiredSession) + #if EmitLocalSessionAsInitialSession + let expectedEvents = [AuthChangeEvent.initialSession, .signedOut] + #else + let expectedEvents = [AuthChangeEvent.signedOut, .initialSession] + #endif + try await assertAuthStateChanges( sut: sut, action: { @@ -2221,17 +2227,47 @@ final class AuthClientTests: XCTestCase { XCTAssertEqual(error as? AuthError, .sessionMissing) } }, - expectedEvents: [.signedOut] + expectedEvents: expectedEvents ) XCTAssertNil(Dependencies[sut.clientID].sessionStorage.get()) } + func testRefreshToken() async throws { + let sut = makeSUT() + + 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) + + #if EmitLocalSessionAsInitialSession + let expectedEvents = [AuthChangeEvent.initialSession, .tokenRefreshed] + #else + let expectedEvents = [AuthChangeEvent.tokenRefreshed, .initialSession] + #endif + + try await assertAuthStateChanges( + sut: sut, + action: { + _ = try await sut.session + }, + expectedEvents: expectedEvents + ) + } + // MARK: - getClaims Tests func testGetClaims_withHS256JWT_shouldFallbackAndReturnClaims() async throws { // HS256 JWT (symmetric algorithm) - will use server-side verification - let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" + let jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" let user = User(fromMockNamed: "user") @@ -2249,7 +2285,7 @@ final class AuthClientTests: XCTestCase { XCTAssertEqual(result.claims.sub, "1234567890") XCTAssertEqual(result.claims.iss, "http://localhost:54321/auth/v1") - if case let .string(aud) = result.claims.aud { + if case .string(let aud) = result.claims.aud { XCTAssertEqual(aud, "authenticated") } else { XCTFail("Expected string audience") @@ -2261,7 +2297,8 @@ final class AuthClientTests: XCTestCase { func testGetClaims_withoutJWT_shouldUseSessionAccessToken() async throws { // HS256 JWT from session - let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" + let jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" var session = Session.validSession session.accessToken = jwt @@ -2287,7 +2324,8 @@ final class AuthClientTests: XCTestCase { func testGetClaims_withProvidedJWKS_shouldStillFallbackForES256() async throws { // ES256 is not yet supported client-side, so it will fallback to server even with JWKS - let jwt = "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" + let jwt = + "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" // JWK is Codable, no custom init needed let jwkDict: [String: Any] = [ @@ -2296,7 +2334,7 @@ final class AuthClientTests: XCTestCase { "alg": "ES256", "crv": "P-256", "x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", - "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM" + "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", ] let jwkData = try JSONSerialization.data(withJSONObject: jwkDict) @@ -2323,7 +2361,8 @@ final class AuthClientTests: XCTestCase { func testGetClaims_withES256JWT_shouldFallbackToServerVerification() async throws { // ES256 JWT without kid - will fallback to server - let jwt = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" + let jwt = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" let user = User(fromMockNamed: "user") @@ -2343,9 +2382,11 @@ final class AuthClientTests: XCTestCase { XCTAssertEqual(result.claims.role, "authenticated") } - func testGetClaims_withRS256JWT_whenJWKNotFound_shouldFallbackToServerVerification() async throws { + func testGetClaims_withRS256JWT_whenJWKNotFound_shouldFallbackToServerVerification() async throws + { // RS256 JWT with kid but key not in JWKS - will try to fetch JWKS, not find it, then fallback to server - let jwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" + let jwt = + "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" // Mock JWKS endpoint with different kid let jwkDict: [String: Any] = [ @@ -2353,7 +2394,7 @@ final class AuthClientTests: XCTestCase { "kid": "different-kid", "alg": "RS256", "n": "modulus", - "e": "AQAB" + "e": "AQAB", ] let jwkData = try JSONSerialization.data(withJSONObject: jwkDict) let jwk = try AuthClient.Configuration.jsonDecoder.decode(JWK.self, from: jwkData) @@ -2387,7 +2428,8 @@ final class AuthClientTests: XCTestCase { func testGetClaims_withNoKidInHeader_shouldFallbackToServerVerification() async throws { // JWT without kid - cannot look up in JWKS - let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjU0MzIxL2F1dGgvdjEiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.YT0NvH-jYKCiN-wrAVcMmTIxZkQ3OtqTVFjJAqGcRuw" + let jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjU0MzIxL2F1dGgvdjEiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.YT0NvH-jYKCiN-wrAVcMmTIxZkQ3OtqTVFjJAqGcRuw" let user = User(fromMockNamed: "user") @@ -2444,7 +2486,8 @@ final class AuthClientTests: XCTestCase { func testGetClaims_withExpiredJWT_shouldThrowJWTVerificationFailed() async throws { // JWT with exp in the past - let expiredJWT = "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6MTUxNjIzOTAyMiwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.MEYCIQDmtLy0PF_lR7rJQHyKLmJKp1xFKECfVvGTBcXiVnz0jAIhAOoXZJ3kHSA2MqL1XhcUy8dWOZCr6zWCN_FXsP8qKfPR" + let expiredJWT = + "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6MTUxNjIzOTAyMiwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.MEYCIQDmtLy0PF_lR7rJQHyKLmJKp1xFKECfVvGTBcXiVnz0jAIhAOoXZJ3kHSA2MqL1XhcUy8dWOZCr6zWCN_FXsP8qKfPR" let sut = makeSUT() @@ -2464,7 +2507,8 @@ final class AuthClientTests: XCTestCase { func testGetClaims_withExpiredJWTAndAllowExpired_shouldReturnClaims() async throws { // JWT with exp in the past but allowExpired option - falls back to server - let expiredJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6MTUxNjIzOTAyMiwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.aN0HLYHkp7nKZp4xWvBaDqSrCFBxk2tq0KZc4BXGqYs" + let expiredJWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6MTUxNjIzOTAyMiwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.aN0HLYHkp7nKZp4xWvBaDqSrCFBxk2tq0KZc4BXGqYs" let user = User(fromMockNamed: "user") @@ -2478,25 +2522,31 @@ final class AuthClientTests: XCTestCase { let sut = makeSUT() - let result = try await sut.getClaims(jwt: expiredJWT, options: GetClaimsOptions(allowExpired: true)) + let result = try await sut.getClaims( + jwt: expiredJWT, + options: GetClaimsOptions(allowExpired: true) + ) XCTAssertEqual(result.claims.sub, "1234567890") - XCTAssertEqual(result.claims.exp, 1516239022) + XCTAssertEqual(result.claims.exp, 1_516_239_022) } func testGetClaims_whenServerRejectsJWT_shouldThrowError() async throws { // HS256 JWT that will be verified server-side - let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" + let jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" Mock( url: clientURL.appendingPathComponent("user"), ignoreQuery: true, contentType: .json, statusCode: 401, - data: [.get: try! AuthClient.Configuration.jsonEncoder.encode([ - "error": "invalid_token", - "error_description": "Invalid JWT" - ])] + data: [ + .get: try! AuthClient.Configuration.jsonEncoder.encode([ + "error": "invalid_token", + "error_description": "Invalid JWT", + ]) + ] ).register() let sut = makeSUT() @@ -2512,7 +2562,8 @@ final class AuthClientTests: XCTestCase { func testGetClaims_withComplexClaims_shouldDecodeAllFields() async throws { // JWT with multiple claim fields // HS256 so it falls back to server verification - let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJuYmYiOjE1MTYyMzkwMjIsImp0aSI6InRlc3QtanRpIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJwaG9uZSI6IisxMjM0NTY3ODkwIn0.dBYm1Y-TfRjPsxw_gXqHB5zGHSH9hXS0OeFN_wL8HbA" + let jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJuYmYiOjE1MTYyMzkwMjIsImp0aSI6InRlc3QtanRpIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJwaG9uZSI6IisxMjM0NTY3ODkwIn0.dBYm1Y-TfRjPsxw_gXqHB5zGHSH9hXS0OeFN_wL8HbA" let user = User(fromMockNamed: "user") @@ -2530,14 +2581,14 @@ final class AuthClientTests: XCTestCase { XCTAssertEqual(result.claims.sub, "1234567890") XCTAssertEqual(result.claims.iss, "http://localhost:54321/auth/v1") - if case let .string(aud) = result.claims.aud { + if case .string(let aud) = result.claims.aud { XCTAssertEqual(aud, "authenticated") } else { XCTFail("Expected string audience") } - XCTAssertEqual(result.claims.exp, 9999999999) - XCTAssertEqual(result.claims.iat, 1516239022) - XCTAssertEqual(result.claims.nbf, 1516239022) + XCTAssertEqual(result.claims.exp, 9_999_999_999) + XCTAssertEqual(result.claims.iat, 1_516_239_022) + XCTAssertEqual(result.claims.nbf, 1_516_239_022) XCTAssertEqual(result.claims.jti, "test-jti") XCTAssertEqual(result.claims.role, "authenticated") XCTAssertEqual(result.claims.email, "test@example.com") @@ -2546,7 +2597,8 @@ final class AuthClientTests: XCTestCase { func testGetClaims_withArrayAudience_shouldDecodeCorrectly() async throws { // JWT with audience as array - let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjpbImF1dGhlbnRpY2F0ZWQiLCJzZXJ2aWNlLXJvbGUiXSwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.Jz-lHQoR2VsQ_vX8wKyN7mPxT4aU9cF1bYsHqGdWlIk" + let jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjpbImF1dGhlbnRpY2F0ZWQiLCJzZXJ2aWNlLXJvbGUiXSwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.Jz-lHQoR2VsQ_vX8wKyN7mPxT4aU9cF1bYsHqGdWlIk" let user = User(fromMockNamed: "user") @@ -2619,22 +2671,33 @@ final class AuthClientTests: XCTestCase { line: UInt = #line, column: UInt = #column ) async throws -> T { - let eventsTask = Task { - await sut.authStateChanges.prefix(expectedEvents.count).collect() + let receivedEvents = LockIsolated([(event: AuthChangeEvent, session: Session?)]()) + let finished = LockIsolated(false) + + Task { + for await change in sut.authStateChanges { + if finished.value { + XCTFail("Received event '\(change.event)' after it finished.", file: filePath, line: line) + } + receivedEvents.withValue { $0.append(change) } + } } await Task.megaYield() let result = try await action() - let authStateChanges = try await withTimeout(interval: timeout) { - await eventsTask.value + try await withTimeout(interval: timeout) { + defer { finished.setValue(true) } + while receivedEvents.count < expectedEvents.count { + await Task.yield() + } } - let events = authStateChanges.map(\.event) - let sessions = authStateChanges.map(\.session) + + await Task.megaYield() expectNoDifference( - events, + receivedEvents.value.map(\.event), expectedEvents, fileID: fileID, filePath: filePath, @@ -2644,7 +2707,7 @@ final class AuthClientTests: XCTestCase { if let expectedSessions = expectedSessions { expectNoDifference( - sessions, + receivedEvents.value.map(\.session), expectedSessions, fileID: fileID, filePath: filePath,