From 1b0c295120be2e396e8f009631bcac83c49a0a3c Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Sun, 28 Sep 2025 16:07:09 +0200 Subject: [PATCH 1/7] Add `authStateChanges` async stream to `Auth` This commit introduces an `AsyncStream`-based API for observing authentication state changes, aligning the Firebase Auth SDK with modern Swift concurrency patterns. The new `authStateChanges` computed property on `Auth` returns an `AsyncStream` that emits the current user whenever the authentication state changes. This provides a more ergonomic alternative to the traditional closure-based `addStateDidChangeListener`. Key changes include: - The implementation of `authStateChanges` in a new `Auth+Async.swift` file. - Comprehensive unit tests in `AuthAsyncTests.swift` covering the stream's behavior for sign-in, sign-out, and cancellation scenarios to prevent resource leaks. - An entry in the `FirebaseAuth/CHANGELOG.md` for the new feature. - Detailed API documentation for `authStateChanges`, including a clear usage example, following Apple's documentation best practices. --- FirebaseAuth/CHANGELOG.md | 3 + .../Sources/Swift/Auth/Auth+Async.swift | 62 ++++++ FirebaseAuth/Tests/Unit/AuthAsyncTests.swift | 196 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift create mode 100644 FirebaseAuth/Tests/Unit/AuthAsyncTests.swift diff --git a/FirebaseAuth/CHANGELOG.md b/FirebaseAuth/CHANGELOG.md index 2b725886b45..9853baa602c 100644 --- a/FirebaseAuth/CHANGELOG.md +++ b/FirebaseAuth/CHANGELOG.md @@ -1,3 +1,6 @@ +# Unreleased +- [added] Added `authStateChanges` to `Auth`, an `AsyncStream` that emits the user's authentication state changes. + # 12.2.0 - [added] Added TOTP support for macOS. diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift new file mode 100644 index 00000000000..b1044a0f83f --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift @@ -0,0 +1,62 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +#if swift(>=5.5.2) + @available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) + public extension Auth { + /// An asynchronous stream of authentication state changes. + /// + /// This stream provides a modern, `async/await`-compatible way to monitor the authentication + /// state of the current user. It emits a new `User?` value whenever the user signs in or + /// out. + /// + /// The stream's underlying listener is automatically managed. It is added to the `Auth` + /// instance when you begin iterating over the stream and is removed when the iteration + /// is cancelled or terminates. + /// + /// - Important: The first value emitted by this stream is always the *current* authentication + /// state, which may be `nil` if no user is signed in. + /// + /// ### Example Usage + /// + /// You can use a `for await` loop to handle authentication changes: + /// + /// ```swift + /// func monitorAuthState() async { + /// for await user in Auth.auth().authStateChanges { + /// if let user = user { + /// print("User signed in: \(user.uid)") + /// // Update UI or perform actions for a signed-in user. + /// } else { + /// print("User signed out.") + /// // Update UI or perform actions for a signed-out state. + /// } + /// } + /// } + /// ``` + var authStateChanges: AsyncStream { + return AsyncStream { continuation in + let listenerHandle = addStateDidChangeListener { _, user in + continuation.yield(user) + } + + continuation.onTermination = { @Sendable _ in + self.removeStateDidChangeListener(listenerHandle) + } + } + } + } +#endif // swift(>=5.5.2) diff --git a/FirebaseAuth/Tests/Unit/AuthAsyncTests.swift b/FirebaseAuth/Tests/Unit/AuthAsyncTests.swift new file mode 100644 index 00000000000..e89460cdc9f --- /dev/null +++ b/FirebaseAuth/Tests/Unit/AuthAsyncTests.swift @@ -0,0 +1,196 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FirebaseAuth +import FirebaseCore +import XCTest + +@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) +class AuthAsyncTests: RPCBaseTests { + var auth: Auth! + static var testNum = 0 + + override func setUp() { + super.setUp() + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = "FAKE_API_KEY" + options.projectID = "myProjectID" + let name = "test-AuthAsyncTests\(AuthAsyncTests.testNum)" + AuthAsyncTests.testNum += 1 + + FirebaseApp.configure(name: name, options: options) + let app = FirebaseApp.app(name: name)! + + #if (os(macOS) && !FIREBASE_AUTH_TESTING_USE_MACOS_KEYCHAIN) || SWIFT_PACKAGE + let keychainStorageProvider = FakeAuthKeychainStorage() + #else + let keychainStorageProvider = AuthKeychainStorageReal.shared + #endif + + auth = Auth( + app: app, + keychainStorageProvider: keychainStorageProvider, + backend: authBackend + ) + + waitForAuthGlobalWorkQueueDrain() + } + + override func tearDown() { + auth = nil + FirebaseApp.resetApps() + super.tearDown() + } + + private func waitForAuthGlobalWorkQueueDrain() { + let workerSemaphore = DispatchSemaphore(value: 0) + kAuthGlobalWorkQueue.async { + workerSemaphore.signal() + } + _ = workerSemaphore.wait(timeout: DispatchTime.distantFuture) + } + + func testAuthStateChangesStreamYieldsUserOnSignIn() async throws { + // Given + let initialNilExpectation = expectation(description: "Stream should emit initial nil user") + let signInExpectation = expectation(description: "Stream should emit signed-in user") + try? auth.signOut() + + var iteration = 0 + let task = Task { + for await user in auth.authStateChanges { + if iteration == 0 { + XCTAssertNil(user, "The initial user should be nil") + initialNilExpectation.fulfill() + } else if iteration == 1 { + XCTAssertNotNil(user, "The stream should yield the new user") + XCTAssertEqual(user?.uid, kLocalID) + signInExpectation.fulfill() + } + iteration += 1 + } + } + + // Wait for the initial nil value to be emitted before proceeding. + await fulfillment(of: [initialNilExpectation], timeout: 1.0) + + // When + // A user is signed in. + setFakeGetAccountProviderAnonymous() + setFakeSecureTokenService() + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN", + "refreshToken": self.kRefreshToken, + "isNewUser": true]) + } + _ = try await auth.signInAnonymously() + + // Then + // The stream should emit the new, signed-in user. + await fulfillment(of: [signInExpectation], timeout: 2.0) + task.cancel() + } + + func testAuthStateChangesStreamIsCancelled() async throws { + // Given: An inverted expectation that will fail the test if it's fulfilled. + let streamCancelledExpectation = + expectation(description: "Stream should not emit a value after cancellation") + streamCancelledExpectation.isInverted = true + try? auth.signOut() + + var iteration = 0 + let task = Task { + for await _ in auth.authStateChanges { + if iteration > 0 { + // This line should not be reached. If it is, the inverted expectation will be + // fulfilled, and the test will fail as intended. + streamCancelledExpectation.fulfill() + } + iteration += 1 + } + } + + // Let the stream emit its initial `nil` value. + try await Task.sleep(nanoseconds: 200_000_000) + + // When: The listening task is cancelled. + task.cancel() + + // And an attempt is made to trigger another update. + setFakeGetAccountProviderAnonymous() + setFakeSecureTokenService() + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN", + "refreshToken": self.kRefreshToken, + "isNewUser": true]) + } + _ = try? await auth.signInAnonymously() + + // Then: Wait for a period to ensure the inverted expectation is not fulfilled. + await fulfillment(of: [streamCancelledExpectation], timeout: 1.0) + + // And explicitly check that the loop only ever ran once. + XCTAssertEqual(iteration, 1, "The stream should have only emitted its initial value.") + } + + func testAuthStateChangesStreamYieldsNilOnSignOut() async throws { + // Given + let initialNilExpectation = expectation(description: "Stream should emit initial nil user") + let signInExpectation = expectation(description: "Stream should emit signed-in user") + let signOutExpectation = expectation(description: "Stream should emit nil after sign-out") + try? auth.signOut() + + var iteration = 0 + let task = Task { + for await user in auth.authStateChanges { + switch iteration { + case 0: + XCTAssertNil(user, "The initial user should be nil") + initialNilExpectation.fulfill() + case 1: + XCTAssertNotNil(user, "The stream should yield the signed-in user") + signInExpectation.fulfill() + case 2: + XCTAssertNil(user, "The stream should yield nil after sign-out") + signOutExpectation.fulfill() + default: + XCTFail("The stream should not have emitted more than three values.") + } + iteration += 1 + } + } + + // Wait for the initial nil value. + await fulfillment(of: [initialNilExpectation], timeout: 1.0) + + // Sign in a user. + setFakeGetAccountProviderAnonymous() + setFakeSecureTokenService() + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN", + "refreshToken": self.kRefreshToken, + "isNewUser": true]) + } + _ = try await auth.signInAnonymously() + await fulfillment(of: [signInExpectation], timeout: 2.0) + + // When + try auth.signOut() + + // Then + await fulfillment(of: [signOutExpectation], timeout: 2.0) + task.cancel() + } +} From 69d2f30633669fa411794c185b9e080593c3b6de Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Tue, 30 Sep 2025 10:47:16 +0200 Subject: [PATCH 2/7] Add `idTokenChanges` async stream to `Auth` This commit introduces an `AsyncStream`-based API for observing ID token changes, aligning the Firebase Auth SDK with modern Swift concurrency patterns. The new `idTokenChanges` computed property on `Auth` returns an `AsyncStream` that emits the current user whenever the ID token changes. This provides a more ergonomic alternative to the traditional closure-based `addIDTokenDidChangeListener`. Key changes include: - The implementation of `idTokenChanges` in `Auth+Async.swift`. - Comprehensive unit tests in `IdTokenChangesAsyncTests.swift` covering the stream's behavior. - Renamed `AuthAsyncTests.swift` to `AuthStateChangesAsyncTests.swift` to better reflect its content. --- .../Sources/Swift/Auth/Auth+Async.swift | 43 +++- ...swift => AuthStateChangesAsyncTests.swift} | 8 +- .../Tests/Unit/IdTokenChangesAsyncTests.swift | 196 ++++++++++++++++++ 3 files changed, 242 insertions(+), 5 deletions(-) rename FirebaseAuth/Tests/Unit/{AuthAsyncTests.swift => AuthStateChangesAsyncTests.swift} (96%) create mode 100644 FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift index b1044a0f83f..6d0b898fd46 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -58,5 +58,46 @@ import Foundation } } } + + /// An asynchronous stream of ID token changes. + /// + /// This stream provides a modern, `async/await`-compatible way to monitor changes to the + /// current user's ID token. It emits a new `User?` value whenever the ID token changes. + /// + /// The stream's underlying listener is automatically managed. It is added to the `Auth` + /// instance when you begin iterating over the stream and is removed when the iteration + /// is cancelled or terminates. + /// + /// - Important: The first value emitted by this stream is always the *current* authentication + /// state, which may be `nil` if no user is signed in. + /// + /// ### Example Usage + /// + /// You can use a `for await` loop to handle ID token changes: + /// + /// ```swift + /// func monitorIDTokenChanges() async { + /// for await user in Auth.auth().idTokenChanges { + /// if let user = user { + /// print("ID token changed for user: \(user.uid)") + /// // Update UI or perform actions for a signed-in user. + /// } else { + /// print("User signed out.") + /// // Update UI or perform actions for a signed-out state. + /// } + /// } + /// } + /// ``` + var idTokenChanges: AsyncStream { + return AsyncStream { continuation in + let listenerHandle = addIDTokenDidChangeListener { _, user in + continuation.yield(user) + } + + continuation.onTermination = { @Sendable _ in + self.removeStateDidChangeListener(listenerHandle) + } + } + } } #endif // swift(>=5.5.2) diff --git a/FirebaseAuth/Tests/Unit/AuthAsyncTests.swift b/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift similarity index 96% rename from FirebaseAuth/Tests/Unit/AuthAsyncTests.swift rename to FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift index e89460cdc9f..2efa9a8169b 100644 --- a/FirebaseAuth/Tests/Unit/AuthAsyncTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ import FirebaseCore import XCTest @available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) -class AuthAsyncTests: RPCBaseTests { +class AuthStateChangesAsyncTests: RPCBaseTests { var auth: Auth! static var testNum = 0 @@ -27,8 +27,8 @@ class AuthAsyncTests: RPCBaseTests { gcmSenderID: "00000000000000000-00000000000-000000000") options.apiKey = "FAKE_API_KEY" options.projectID = "myProjectID" - let name = "test-AuthAsyncTests\(AuthAsyncTests.testNum)" - AuthAsyncTests.testNum += 1 + let name = "test-AuthStateChangesAsyncTests\(AuthStateChangesAsyncTests.testNum)" + AuthStateChangesAsyncTests.testNum += 1 FirebaseApp.configure(name: name, options: options) let app = FirebaseApp.app(name: name)! diff --git a/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift b/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift new file mode 100644 index 00000000000..5459d1ddac4 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift @@ -0,0 +1,196 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FirebaseAuth +import FirebaseCore +import XCTest + +@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) +class IdTokenChangesAsyncTests: RPCBaseTests { + var auth: Auth! + static var testNum = 0 + + override func setUp() { + super.setUp() + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = "FAKE_API_KEY" + options.projectID = "myProjectID" + let name = "test-IdTokenChangesAsyncTests\(IdTokenChangesAsyncTests.testNum)" + IdTokenChangesAsyncTests.testNum += 1 + + FirebaseApp.configure(name: name, options: options) + let app = FirebaseApp.app(name: name)! + + #if (os(macOS) && !FIREBASE_AUTH_TESTING_USE_MACOS_KEYCHAIN) || SWIFT_PACKAGE + let keychainStorageProvider = FakeAuthKeychainStorage() + #else + let keychainStorageProvider = AuthKeychainStorageReal.shared + #endif + + auth = Auth( + app: app, + keychainStorageProvider: keychainStorageProvider, + backend: authBackend + ) + + waitForAuthGlobalWorkQueueDrain() + } + + override func tearDown() { + auth = nil + FirebaseApp.resetApps() + super.tearDown() + } + + private func waitForAuthGlobalWorkQueueDrain() { + let workerSemaphore = DispatchSemaphore(value: 0) + kAuthGlobalWorkQueue.async { + workerSemaphore.signal() + } + _ = workerSemaphore.wait(timeout: DispatchTime.distantFuture) + } + + func testIdTokenChangesStreamYieldsUserOnSignIn() async throws { + // Given + let initialNilExpectation = expectation(description: "Stream should emit initial nil user") + let signInExpectation = expectation(description: "Stream should emit signed-in user") + try? auth.signOut() + + var iteration = 0 + let task = Task { + for await user in auth.idTokenChanges { + if iteration == 0 { + XCTAssertNil(user, "The initial user should be nil") + initialNilExpectation.fulfill() + } else if iteration == 1 { + XCTAssertNotNil(user, "The stream should yield the new user") + XCTAssertEqual(user?.uid, kLocalID) + signInExpectation.fulfill() + } + iteration += 1 + } + } + + // Wait for the initial nil value to be emitted before proceeding. + await fulfillment(of: [initialNilExpectation], timeout: 1.0) + + // When + // A user is signed in. + setFakeGetAccountProviderAnonymous() + setFakeSecureTokenService() + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN", + "refreshToken": self.kRefreshToken, + "isNewUser": true]) + } + _ = try await auth.signInAnonymously() + + // Then + // The stream should emit the new, signed-in user. + await fulfillment(of: [signInExpectation], timeout: 2.0) + task.cancel() + } + + func testIdTokenChangesStreamIsCancelled() async throws { + // Given: An inverted expectation that will fail the test if it's fulfilled. + let streamCancelledExpectation = + expectation(description: "Stream should not emit a value after cancellation") + streamCancelledExpectation.isInverted = true + try? auth.signOut() + + var iteration = 0 + let task = Task { + for await _ in auth.idTokenChanges { + if iteration > 0 { + // This line should not be reached. If it is, the inverted expectation will be + // fulfilled, and the test will fail as intended. + streamCancelledExpectation.fulfill() + } + iteration += 1 + } + } + + // Let the stream emit its initial `nil` value. + try await Task.sleep(nanoseconds: 200_000_000) + + // When: The listening task is cancelled. + task.cancel() + + // And an attempt is made to trigger another update. + setFakeGetAccountProviderAnonymous() + setFakeSecureTokenService() + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN", + "refreshToken": self.kRefreshToken, + "isNewUser": true]) + } + _ = try? await auth.signInAnonymously() + + // Then: Wait for a period to ensure the inverted expectation is not fulfilled. + await fulfillment(of: [streamCancelledExpectation], timeout: 1.0) + + // And explicitly check that the loop only ever ran once. + XCTAssertEqual(iteration, 1, "The stream should have only emitted its initial value.") + } + + func testIdTokenChangesStreamYieldsNilOnSignOut() async throws { + // Given + let initialNilExpectation = expectation(description: "Stream should emit initial nil user") + let signInExpectation = expectation(description: "Stream should emit signed-in user") + let signOutExpectation = expectation(description: "Stream should emit nil after sign-out") + try? auth.signOut() + + var iteration = 0 + let task = Task { + for await user in auth.idTokenChanges { + switch iteration { + case 0: + XCTAssertNil(user, "The initial user should be nil") + initialNilExpectation.fulfill() + case 1: + XCTAssertNotNil(user, "The stream should yield the signed-in user") + signInExpectation.fulfill() + case 2: + XCTAssertNil(user, "The stream should yield nil after sign-out") + signOutExpectation.fulfill() + default: + XCTFail("The stream should not have emitted more than three values.") + } + iteration += 1 + } + } + + // Wait for the initial nil value. + await fulfillment(of: [initialNilExpectation], timeout: 1.0) + + // Sign in a user. + setFakeGetAccountProviderAnonymous() + setFakeSecureTokenService() + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN", + "refreshToken": self.kRefreshToken, + "isNewUser": true]) + } + _ = try await auth.signInAnonymously() + await fulfillment(of: [signInExpectation], timeout: 2.0) + + // When + try auth.signOut() + + // Then + await fulfillment(of: [signOutExpectation], timeout: 2.0) + task.cancel() + } +} From f8f9386260fe7f6e0548e57253fbec5961f16409 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Tue, 30 Sep 2025 11:05:07 +0200 Subject: [PATCH 3/7] Remove explicit return statements --- FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift index 6d0b898fd46..7dc1d95feb6 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift @@ -48,7 +48,7 @@ import Foundation /// } /// ``` var authStateChanges: AsyncStream { - return AsyncStream { continuation in + AsyncStream { continuation in let listenerHandle = addStateDidChangeListener { _, user in continuation.yield(user) } @@ -89,7 +89,7 @@ import Foundation /// } /// ``` var idTokenChanges: AsyncStream { - return AsyncStream { continuation in + AsyncStream { continuation in let listenerHandle = addIDTokenDidChangeListener { _, user in continuation.yield(user) } From ac13e18968d30f7f1700cf394544b7a9a6c94e69 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Tue, 30 Sep 2025 11:05:13 +0200 Subject: [PATCH 4/7] Update changelog --- FirebaseAuth/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAuth/CHANGELOG.md b/FirebaseAuth/CHANGELOG.md index 9853baa602c..51e1baa4217 100644 --- a/FirebaseAuth/CHANGELOG.md +++ b/FirebaseAuth/CHANGELOG.md @@ -1,5 +1,5 @@ # Unreleased -- [added] Added `authStateChanges` to `Auth`, an `AsyncStream` that emits the user's authentication state changes. +- [added] Added `authStateChanges` and `idTokenChanges` to `Auth`, a pair of `AsyncStream`s that emit the user's authentication state and ID token changes. # 12.2.0 - [added] Added TOTP support for macOS. From 3fe64165cb072997d8906bbab6a231ffc87a9a3b Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Wed, 1 Oct 2025 06:02:09 +0200 Subject: [PATCH 5/7] Return stream as a sequence --- .../Sources/Swift/Auth/Auth+Async.swift | 33 +++++++++---------- .../Unit/AuthStateChangesAsyncTests.swift | 3 ++ .../Tests/Unit/IdTokenChangesAsyncTests.swift | 3 ++ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift index 7dc1d95feb6..aaf7e6f70ce 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift @@ -14,20 +14,18 @@ import Foundation -#if swift(>=5.5.2) - @available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) - public extension Auth { - /// An asynchronous stream of authentication state changes. +public extension Auth { + /// An asynchronous sequence of authentication state changes. /// - /// This stream provides a modern, `async/await`-compatible way to monitor the authentication + /// This sequence provides a modern, `async/await`-compatible way to monitor the authentication /// state of the current user. It emits a new `User?` value whenever the user signs in or /// out. /// - /// The stream's underlying listener is automatically managed. It is added to the `Auth` - /// instance when you begin iterating over the stream and is removed when the iteration + /// The sequence's underlying listener is automatically managed. It is added to the `Auth` + /// instance when you begin iterating over the sequence and is removed when the iteration /// is cancelled or terminates. /// - /// - Important: The first value emitted by this stream is always the *current* authentication + /// - Important: The first value emitted by this sequence is always the *current* authentication /// state, which may be `nil` if no user is signed in. /// /// ### Example Usage @@ -47,7 +45,8 @@ import Foundation /// } /// } /// ``` - var authStateChanges: AsyncStream { + @available(iOS 18.0, *) + var authStateChanges: some AsyncSequence { AsyncStream { continuation in let listenerHandle = addStateDidChangeListener { _, user in continuation.yield(user) @@ -59,16 +58,16 @@ import Foundation } } - /// An asynchronous stream of ID token changes. + /// An asynchronous sequence of ID token changes. /// - /// This stream provides a modern, `async/await`-compatible way to monitor changes to the + /// This sequence provides a modern, `async/await`-compatible way to monitor changes to the /// current user's ID token. It emits a new `User?` value whenever the ID token changes. /// - /// The stream's underlying listener is automatically managed. It is added to the `Auth` - /// instance when you begin iterating over the stream and is removed when the iteration + /// The sequence's underlying listener is automatically managed. It is added to the `Auth` + /// instance when you begin iterating over the sequence and is removed when the iteration /// is cancelled or terminates. /// - /// - Important: The first value emitted by this stream is always the *current* authentication + /// - Important: The first value emitted by this sequence is always the *current* authentication /// state, which may be `nil` if no user is signed in. /// /// ### Example Usage @@ -88,16 +87,16 @@ import Foundation /// } /// } /// ``` - var idTokenChanges: AsyncStream { + @available(iOS 18.0, *) + var idTokenChanges: some AsyncSequence { AsyncStream { continuation in let listenerHandle = addIDTokenDidChangeListener { _, user in continuation.yield(user) } continuation.onTermination = { @Sendable _ in - self.removeStateDidChangeListener(listenerHandle) + self.removeIDTokenDidChangeListener(listenerHandle) } } } } -#endif // swift(>=5.5.2) diff --git a/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift b/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift index 2efa9a8169b..e7291fae960 100644 --- a/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift @@ -62,6 +62,7 @@ class AuthStateChangesAsyncTests: RPCBaseTests { _ = workerSemaphore.wait(timeout: DispatchTime.distantFuture) } + @available(iOS 18.0, *) func testAuthStateChangesStreamYieldsUserOnSignIn() async throws { // Given let initialNilExpectation = expectation(description: "Stream should emit initial nil user") @@ -103,6 +104,7 @@ class AuthStateChangesAsyncTests: RPCBaseTests { task.cancel() } + @available(iOS 18.0, *) func testAuthStateChangesStreamIsCancelled() async throws { // Given: An inverted expectation that will fail the test if it's fulfilled. let streamCancelledExpectation = @@ -145,6 +147,7 @@ class AuthStateChangesAsyncTests: RPCBaseTests { XCTAssertEqual(iteration, 1, "The stream should have only emitted its initial value.") } + @available(iOS 18.0, *) func testAuthStateChangesStreamYieldsNilOnSignOut() async throws { // Given let initialNilExpectation = expectation(description: "Stream should emit initial nil user") diff --git a/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift b/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift index 5459d1ddac4..10ac6303faa 100644 --- a/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift +++ b/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift @@ -62,6 +62,7 @@ class IdTokenChangesAsyncTests: RPCBaseTests { _ = workerSemaphore.wait(timeout: DispatchTime.distantFuture) } + @available(iOS 18.0, *) func testIdTokenChangesStreamYieldsUserOnSignIn() async throws { // Given let initialNilExpectation = expectation(description: "Stream should emit initial nil user") @@ -103,6 +104,7 @@ class IdTokenChangesAsyncTests: RPCBaseTests { task.cancel() } + @available(iOS 18.0, *) func testIdTokenChangesStreamIsCancelled() async throws { // Given: An inverted expectation that will fail the test if it's fulfilled. let streamCancelledExpectation = @@ -145,6 +147,7 @@ class IdTokenChangesAsyncTests: RPCBaseTests { XCTAssertEqual(iteration, 1, "The stream should have only emitted its initial value.") } + @available(iOS 18.0, *) func testIdTokenChangesStreamYieldsNilOnSignOut() async throws { // Given let initialNilExpectation = expectation(description: "Stream should emit initial nil user") From 60c243270d9d13ba3d308c05504d2e425d87d8c3 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Wed, 1 Oct 2025 09:50:16 +0100 Subject: [PATCH 6/7] Fix formatting --- .../Sources/Swift/Auth/Auth+Async.swift | 146 +++++++++--------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift index aaf7e6f70ce..1f605f0632e 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift @@ -15,88 +15,88 @@ import Foundation public extension Auth { - /// An asynchronous sequence of authentication state changes. - /// - /// This sequence provides a modern, `async/await`-compatible way to monitor the authentication - /// state of the current user. It emits a new `User?` value whenever the user signs in or - /// out. - /// - /// The sequence's underlying listener is automatically managed. It is added to the `Auth` - /// instance when you begin iterating over the sequence and is removed when the iteration - /// is cancelled or terminates. - /// - /// - Important: The first value emitted by this sequence is always the *current* authentication - /// state, which may be `nil` if no user is signed in. - /// - /// ### Example Usage - /// - /// You can use a `for await` loop to handle authentication changes: - /// - /// ```swift - /// func monitorAuthState() async { - /// for await user in Auth.auth().authStateChanges { - /// if let user = user { - /// print("User signed in: \(user.uid)") - /// // Update UI or perform actions for a signed-in user. - /// } else { - /// print("User signed out.") - /// // Update UI or perform actions for a signed-out state. - /// } - /// } - /// } - /// ``` + /// An asynchronous sequence of authentication state changes. + /// + /// This sequence provides a modern, `async/await`-compatible way to monitor the authentication + /// state of the current user. It emits a new `User?` value whenever the user signs in or + /// out. + /// + /// The sequence's underlying listener is automatically managed. It is added to the `Auth` + /// instance when you begin iterating over the sequence and is removed when the iteration + /// is cancelled or terminates. + /// + /// - Important: The first value emitted by this sequence is always the *current* authentication + /// state, which may be `nil` if no user is signed in. + /// + /// ### Example Usage + /// + /// You can use a `for await` loop to handle authentication changes: + /// + /// ```swift + /// func monitorAuthState() async { + /// for await user in Auth.auth().authStateChanges { + /// if let user = user { + /// print("User signed in: \(user.uid)") + /// // Update UI or perform actions for a signed-in user. + /// } else { + /// print("User signed out.") + /// // Update UI or perform actions for a signed-out state. + /// } + /// } + /// } + /// ``` @available(iOS 18.0, *) var authStateChanges: some AsyncSequence { - AsyncStream { continuation in - let listenerHandle = addStateDidChangeListener { _, user in - continuation.yield(user) - } + AsyncStream { continuation in + let listenerHandle = addStateDidChangeListener { _, user in + continuation.yield(user) + } - continuation.onTermination = { @Sendable _ in - self.removeStateDidChangeListener(listenerHandle) - } + continuation.onTermination = { @Sendable _ in + self.removeStateDidChangeListener(listenerHandle) } } + } - /// An asynchronous sequence of ID token changes. - /// - /// This sequence provides a modern, `async/await`-compatible way to monitor changes to the - /// current user's ID token. It emits a new `User?` value whenever the ID token changes. - /// - /// The sequence's underlying listener is automatically managed. It is added to the `Auth` - /// instance when you begin iterating over the sequence and is removed when the iteration - /// is cancelled or terminates. - /// - /// - Important: The first value emitted by this sequence is always the *current* authentication - /// state, which may be `nil` if no user is signed in. - /// - /// ### Example Usage - /// - /// You can use a `for await` loop to handle ID token changes: - /// - /// ```swift - /// func monitorIDTokenChanges() async { - /// for await user in Auth.auth().idTokenChanges { - /// if let user = user { - /// print("ID token changed for user: \(user.uid)") - /// // Update UI or perform actions for a signed-in user. - /// } else { - /// print("User signed out.") - /// // Update UI or perform actions for a signed-out state. - /// } - /// } - /// } - /// ``` + /// An asynchronous sequence of ID token changes. + /// + /// This sequence provides a modern, `async/await`-compatible way to monitor changes to the + /// current user's ID token. It emits a new `User?` value whenever the ID token changes. + /// + /// The sequence's underlying listener is automatically managed. It is added to the `Auth` + /// instance when you begin iterating over the sequence and is removed when the iteration + /// is cancelled or terminates. + /// + /// - Important: The first value emitted by this sequence is always the *current* authentication + /// state, which may be `nil` if no user is signed in. + /// + /// ### Example Usage + /// + /// You can use a `for await` loop to handle ID token changes: + /// + /// ```swift + /// func monitorIDTokenChanges() async { + /// for await user in Auth.auth().idTokenChanges { + /// if let user = user { + /// print("ID token changed for user: \(user.uid)") + /// // Update UI or perform actions for a signed-in user. + /// } else { + /// print("User signed out.") + /// // Update UI or perform actions for a signed-out state. + /// } + /// } + /// } + /// ``` @available(iOS 18.0, *) var idTokenChanges: some AsyncSequence { - AsyncStream { continuation in - let listenerHandle = addIDTokenDidChangeListener { _, user in - continuation.yield(user) - } + AsyncStream { continuation in + let listenerHandle = addIDTokenDidChangeListener { _, user in + continuation.yield(user) + } - continuation.onTermination = { @Sendable _ in - self.removeIDTokenDidChangeListener(listenerHandle) - } + continuation.onTermination = { @Sendable _ in + self.removeIDTokenDidChangeListener(listenerHandle) } } } +} From 62742e98ad31e15c786dfaaf7a402208e7d5c074 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Thu, 2 Oct 2025 19:28:07 +0100 Subject: [PATCH 7/7] Address feedback from review --- .../Sources/Swift/Auth/Auth+Async.swift | 4 +-- .../Unit/AuthStateChangesAsyncTests.swift | 30 +++++++++---------- .../Tests/Unit/IdTokenChangesAsyncTests.swift | 30 +++++++++---------- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift index 1f605f0632e..fbd42f11d46 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift @@ -45,7 +45,7 @@ public extension Auth { /// } /// } /// ``` - @available(iOS 18.0, *) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) var authStateChanges: some AsyncSequence { AsyncStream { continuation in let listenerHandle = addStateDidChangeListener { _, user in @@ -87,7 +87,7 @@ public extension Auth { /// } /// } /// ``` - @available(iOS 18.0, *) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) var idTokenChanges: some AsyncSequence { AsyncStream { continuation in let listenerHandle = addIDTokenDidChangeListener { _, user in diff --git a/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift b/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift index e7291fae960..dabd34a2e8f 100644 --- a/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift @@ -14,12 +14,13 @@ @testable import FirebaseAuth import FirebaseCore +import FirebaseCoreInternal import XCTest -@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) class AuthStateChangesAsyncTests: RPCBaseTests { var auth: Auth! - static var testNum = 0 + static let testNum = UnfairLock(0) override func setUp() { super.setUp() @@ -27,8 +28,8 @@ class AuthStateChangesAsyncTests: RPCBaseTests { gcmSenderID: "00000000000000000-00000000000-000000000") options.apiKey = "FAKE_API_KEY" options.projectID = "myProjectID" - let name = "test-AuthStateChangesAsyncTests\(AuthStateChangesAsyncTests.testNum)" - AuthStateChangesAsyncTests.testNum += 1 + let name = "test-\(Self.self)\(Self.testNum.value())" + Self.testNum.withLock { $0 += 1 } FirebaseApp.configure(name: name, options: options) let app = FirebaseApp.app(name: name)! @@ -55,14 +56,9 @@ class AuthStateChangesAsyncTests: RPCBaseTests { } private func waitForAuthGlobalWorkQueueDrain() { - let workerSemaphore = DispatchSemaphore(value: 0) - kAuthGlobalWorkQueue.async { - workerSemaphore.signal() - } - _ = workerSemaphore.wait(timeout: DispatchTime.distantFuture) + kAuthGlobalWorkQueue.sync {} } - @available(iOS 18.0, *) func testAuthStateChangesStreamYieldsUserOnSignIn() async throws { // Given let initialNilExpectation = expectation(description: "Stream should emit initial nil user") @@ -104,9 +100,10 @@ class AuthStateChangesAsyncTests: RPCBaseTests { task.cancel() } - @available(iOS 18.0, *) func testAuthStateChangesStreamIsCancelled() async throws { - // Given: An inverted expectation that will fail the test if it's fulfilled. + // Given + let initialNilExpectation = + expectation(description: "Stream should emit initial nil user") let streamCancelledExpectation = expectation(description: "Stream should not emit a value after cancellation") streamCancelledExpectation.isInverted = true @@ -115,7 +112,9 @@ class AuthStateChangesAsyncTests: RPCBaseTests { var iteration = 0 let task = Task { for await _ in auth.authStateChanges { - if iteration > 0 { + if iteration == 0 { + initialNilExpectation.fulfill() + } else { // This line should not be reached. If it is, the inverted expectation will be // fulfilled, and the test will fail as intended. streamCancelledExpectation.fulfill() @@ -124,8 +123,8 @@ class AuthStateChangesAsyncTests: RPCBaseTests { } } - // Let the stream emit its initial `nil` value. - try await Task.sleep(nanoseconds: 200_000_000) + // Wait for the stream to emit its initial `nil` value. + await fulfillment(of: [initialNilExpectation], timeout: 1.0) // When: The listening task is cancelled. task.cancel() @@ -147,7 +146,6 @@ class AuthStateChangesAsyncTests: RPCBaseTests { XCTAssertEqual(iteration, 1, "The stream should have only emitted its initial value.") } - @available(iOS 18.0, *) func testAuthStateChangesStreamYieldsNilOnSignOut() async throws { // Given let initialNilExpectation = expectation(description: "Stream should emit initial nil user") diff --git a/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift b/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift index 10ac6303faa..fa2bd502ea6 100644 --- a/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift +++ b/FirebaseAuth/Tests/Unit/IdTokenChangesAsyncTests.swift @@ -14,12 +14,13 @@ @testable import FirebaseAuth import FirebaseCore +import FirebaseCoreInternal import XCTest -@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) class IdTokenChangesAsyncTests: RPCBaseTests { var auth: Auth! - static var testNum = 0 + static let testNum = UnfairLock(0) override func setUp() { super.setUp() @@ -27,8 +28,8 @@ class IdTokenChangesAsyncTests: RPCBaseTests { gcmSenderID: "00000000000000000-00000000000-000000000") options.apiKey = "FAKE_API_KEY" options.projectID = "myProjectID" - let name = "test-IdTokenChangesAsyncTests\(IdTokenChangesAsyncTests.testNum)" - IdTokenChangesAsyncTests.testNum += 1 + let name = "test-\(Self.self)\(Self.testNum.value())" + Self.testNum.withLock { $0 += 1 } FirebaseApp.configure(name: name, options: options) let app = FirebaseApp.app(name: name)! @@ -55,14 +56,9 @@ class IdTokenChangesAsyncTests: RPCBaseTests { } private func waitForAuthGlobalWorkQueueDrain() { - let workerSemaphore = DispatchSemaphore(value: 0) - kAuthGlobalWorkQueue.async { - workerSemaphore.signal() - } - _ = workerSemaphore.wait(timeout: DispatchTime.distantFuture) + kAuthGlobalWorkQueue.sync {} } - @available(iOS 18.0, *) func testIdTokenChangesStreamYieldsUserOnSignIn() async throws { // Given let initialNilExpectation = expectation(description: "Stream should emit initial nil user") @@ -104,9 +100,10 @@ class IdTokenChangesAsyncTests: RPCBaseTests { task.cancel() } - @available(iOS 18.0, *) func testIdTokenChangesStreamIsCancelled() async throws { - // Given: An inverted expectation that will fail the test if it's fulfilled. + // Given + let initialNilExpectation = + expectation(description: "Stream should emit initial nil user") let streamCancelledExpectation = expectation(description: "Stream should not emit a value after cancellation") streamCancelledExpectation.isInverted = true @@ -115,7 +112,9 @@ class IdTokenChangesAsyncTests: RPCBaseTests { var iteration = 0 let task = Task { for await _ in auth.idTokenChanges { - if iteration > 0 { + if iteration == 0 { + initialNilExpectation.fulfill() + } else { // This line should not be reached. If it is, the inverted expectation will be // fulfilled, and the test will fail as intended. streamCancelledExpectation.fulfill() @@ -124,8 +123,8 @@ class IdTokenChangesAsyncTests: RPCBaseTests { } } - // Let the stream emit its initial `nil` value. - try await Task.sleep(nanoseconds: 200_000_000) + // Wait for the stream to emit its initial `nil` value. + await fulfillment(of: [initialNilExpectation], timeout: 1.0) // When: The listening task is cancelled. task.cancel() @@ -147,7 +146,6 @@ class IdTokenChangesAsyncTests: RPCBaseTests { XCTAssertEqual(iteration, 1, "The stream should have only emitted its initial value.") } - @available(iOS 18.0, *) func testIdTokenChangesStreamYieldsNilOnSignOut() async throws { // Given let initialNilExpectation = expectation(description: "Stream should emit initial nil user")