Skip to content

Commit 1b0c295

Browse files
committed
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<User?>` 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.
1 parent 6bcf978 commit 1b0c295

File tree

3 files changed

+261
-0
lines changed

3 files changed

+261
-0
lines changed

FirebaseAuth/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Unreleased
2+
- [added] Added `authStateChanges` to `Auth`, an `AsyncStream` that emits the user's authentication state changes.
3+
14
# 12.2.0
25
- [added] Added TOTP support for macOS.
36

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2024 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
#if swift(>=5.5.2)
18+
@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *)
19+
public extension Auth {
20+
/// An asynchronous stream of authentication state changes.
21+
///
22+
/// This stream provides a modern, `async/await`-compatible way to monitor the authentication
23+
/// state of the current user. It emits a new `User?` value whenever the user signs in or
24+
/// out.
25+
///
26+
/// The stream's underlying listener is automatically managed. It is added to the `Auth`
27+
/// instance when you begin iterating over the stream and is removed when the iteration
28+
/// is cancelled or terminates.
29+
///
30+
/// - Important: The first value emitted by this stream is always the *current* authentication
31+
/// state, which may be `nil` if no user is signed in.
32+
///
33+
/// ### Example Usage
34+
///
35+
/// You can use a `for await` loop to handle authentication changes:
36+
///
37+
/// ```swift
38+
/// func monitorAuthState() async {
39+
/// for await user in Auth.auth().authStateChanges {
40+
/// if let user = user {
41+
/// print("User signed in: \(user.uid)")
42+
/// // Update UI or perform actions for a signed-in user.
43+
/// } else {
44+
/// print("User signed out.")
45+
/// // Update UI or perform actions for a signed-out state.
46+
/// }
47+
/// }
48+
/// }
49+
/// ```
50+
var authStateChanges: AsyncStream<User?> {
51+
return AsyncStream { continuation in
52+
let listenerHandle = addStateDidChangeListener { _, user in
53+
continuation.yield(user)
54+
}
55+
56+
continuation.onTermination = { @Sendable _ in
57+
self.removeStateDidChangeListener(listenerHandle)
58+
}
59+
}
60+
}
61+
}
62+
#endif // swift(>=5.5.2)
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright 2024 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@testable import FirebaseAuth
16+
import FirebaseCore
17+
import XCTest
18+
19+
@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *)
20+
class AuthAsyncTests: RPCBaseTests {
21+
var auth: Auth!
22+
static var testNum = 0
23+
24+
override func setUp() {
25+
super.setUp()
26+
let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000",
27+
gcmSenderID: "00000000000000000-00000000000-000000000")
28+
options.apiKey = "FAKE_API_KEY"
29+
options.projectID = "myProjectID"
30+
let name = "test-AuthAsyncTests\(AuthAsyncTests.testNum)"
31+
AuthAsyncTests.testNum += 1
32+
33+
FirebaseApp.configure(name: name, options: options)
34+
let app = FirebaseApp.app(name: name)!
35+
36+
#if (os(macOS) && !FIREBASE_AUTH_TESTING_USE_MACOS_KEYCHAIN) || SWIFT_PACKAGE
37+
let keychainStorageProvider = FakeAuthKeychainStorage()
38+
#else
39+
let keychainStorageProvider = AuthKeychainStorageReal.shared
40+
#endif
41+
42+
auth = Auth(
43+
app: app,
44+
keychainStorageProvider: keychainStorageProvider,
45+
backend: authBackend
46+
)
47+
48+
waitForAuthGlobalWorkQueueDrain()
49+
}
50+
51+
override func tearDown() {
52+
auth = nil
53+
FirebaseApp.resetApps()
54+
super.tearDown()
55+
}
56+
57+
private func waitForAuthGlobalWorkQueueDrain() {
58+
let workerSemaphore = DispatchSemaphore(value: 0)
59+
kAuthGlobalWorkQueue.async {
60+
workerSemaphore.signal()
61+
}
62+
_ = workerSemaphore.wait(timeout: DispatchTime.distantFuture)
63+
}
64+
65+
func testAuthStateChangesStreamYieldsUserOnSignIn() async throws {
66+
// Given
67+
let initialNilExpectation = expectation(description: "Stream should emit initial nil user")
68+
let signInExpectation = expectation(description: "Stream should emit signed-in user")
69+
try? auth.signOut()
70+
71+
var iteration = 0
72+
let task = Task {
73+
for await user in auth.authStateChanges {
74+
if iteration == 0 {
75+
XCTAssertNil(user, "The initial user should be nil")
76+
initialNilExpectation.fulfill()
77+
} else if iteration == 1 {
78+
XCTAssertNotNil(user, "The stream should yield the new user")
79+
XCTAssertEqual(user?.uid, kLocalID)
80+
signInExpectation.fulfill()
81+
}
82+
iteration += 1
83+
}
84+
}
85+
86+
// Wait for the initial nil value to be emitted before proceeding.
87+
await fulfillment(of: [initialNilExpectation], timeout: 1.0)
88+
89+
// When
90+
// A user is signed in.
91+
setFakeGetAccountProviderAnonymous()
92+
setFakeSecureTokenService()
93+
rpcIssuer.respondBlock = {
94+
try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN",
95+
"refreshToken": self.kRefreshToken,
96+
"isNewUser": true])
97+
}
98+
_ = try await auth.signInAnonymously()
99+
100+
// Then
101+
// The stream should emit the new, signed-in user.
102+
await fulfillment(of: [signInExpectation], timeout: 2.0)
103+
task.cancel()
104+
}
105+
106+
func testAuthStateChangesStreamIsCancelled() async throws {
107+
// Given: An inverted expectation that will fail the test if it's fulfilled.
108+
let streamCancelledExpectation =
109+
expectation(description: "Stream should not emit a value after cancellation")
110+
streamCancelledExpectation.isInverted = true
111+
try? auth.signOut()
112+
113+
var iteration = 0
114+
let task = Task {
115+
for await _ in auth.authStateChanges {
116+
if iteration > 0 {
117+
// This line should not be reached. If it is, the inverted expectation will be
118+
// fulfilled, and the test will fail as intended.
119+
streamCancelledExpectation.fulfill()
120+
}
121+
iteration += 1
122+
}
123+
}
124+
125+
// Let the stream emit its initial `nil` value.
126+
try await Task.sleep(nanoseconds: 200_000_000)
127+
128+
// When: The listening task is cancelled.
129+
task.cancel()
130+
131+
// And an attempt is made to trigger another update.
132+
setFakeGetAccountProviderAnonymous()
133+
setFakeSecureTokenService()
134+
rpcIssuer.respondBlock = {
135+
try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN",
136+
"refreshToken": self.kRefreshToken,
137+
"isNewUser": true])
138+
}
139+
_ = try? await auth.signInAnonymously()
140+
141+
// Then: Wait for a period to ensure the inverted expectation is not fulfilled.
142+
await fulfillment(of: [streamCancelledExpectation], timeout: 1.0)
143+
144+
// And explicitly check that the loop only ever ran once.
145+
XCTAssertEqual(iteration, 1, "The stream should have only emitted its initial value.")
146+
}
147+
148+
func testAuthStateChangesStreamYieldsNilOnSignOut() async throws {
149+
// Given
150+
let initialNilExpectation = expectation(description: "Stream should emit initial nil user")
151+
let signInExpectation = expectation(description: "Stream should emit signed-in user")
152+
let signOutExpectation = expectation(description: "Stream should emit nil after sign-out")
153+
try? auth.signOut()
154+
155+
var iteration = 0
156+
let task = Task {
157+
for await user in auth.authStateChanges {
158+
switch iteration {
159+
case 0:
160+
XCTAssertNil(user, "The initial user should be nil")
161+
initialNilExpectation.fulfill()
162+
case 1:
163+
XCTAssertNotNil(user, "The stream should yield the signed-in user")
164+
signInExpectation.fulfill()
165+
case 2:
166+
XCTAssertNil(user, "The stream should yield nil after sign-out")
167+
signOutExpectation.fulfill()
168+
default:
169+
XCTFail("The stream should not have emitted more than three values.")
170+
}
171+
iteration += 1
172+
}
173+
}
174+
175+
// Wait for the initial nil value.
176+
await fulfillment(of: [initialNilExpectation], timeout: 1.0)
177+
178+
// Sign in a user.
179+
setFakeGetAccountProviderAnonymous()
180+
setFakeSecureTokenService()
181+
rpcIssuer.respondBlock = {
182+
try self.rpcIssuer.respond(withJSON: ["idToken": "TEST_ACCESS_TOKEN",
183+
"refreshToken": self.kRefreshToken,
184+
"isNewUser": true])
185+
}
186+
_ = try await auth.signInAnonymously()
187+
await fulfillment(of: [signInExpectation], timeout: 2.0)
188+
189+
// When
190+
try auth.signOut()
191+
192+
// Then
193+
await fulfillment(of: [signOutExpectation], timeout: 2.0)
194+
task.cancel()
195+
}
196+
}

0 commit comments

Comments
 (0)