Skip to content

Commit 69d2f30

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

File tree

3 files changed

+242
-5
lines changed

3 files changed

+242
-5
lines changed

FirebaseAuth/Sources/Swift/Auth/Auth+Async.swift

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2024 Google LLC
1+
// Copyright 2025 Google LLC
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -58,5 +58,46 @@ import Foundation
5858
}
5959
}
6060
}
61+
62+
/// An asynchronous stream of ID token changes.
63+
///
64+
/// This stream provides a modern, `async/await`-compatible way to monitor changes to the
65+
/// current user's ID token. It emits a new `User?` value whenever the ID token changes.
66+
///
67+
/// The stream's underlying listener is automatically managed. It is added to the `Auth`
68+
/// instance when you begin iterating over the stream and is removed when the iteration
69+
/// is cancelled or terminates.
70+
///
71+
/// - Important: The first value emitted by this stream is always the *current* authentication
72+
/// state, which may be `nil` if no user is signed in.
73+
///
74+
/// ### Example Usage
75+
///
76+
/// You can use a `for await` loop to handle ID token changes:
77+
///
78+
/// ```swift
79+
/// func monitorIDTokenChanges() async {
80+
/// for await user in Auth.auth().idTokenChanges {
81+
/// if let user = user {
82+
/// print("ID token changed for user: \(user.uid)")
83+
/// // Update UI or perform actions for a signed-in user.
84+
/// } else {
85+
/// print("User signed out.")
86+
/// // Update UI or perform actions for a signed-out state.
87+
/// }
88+
/// }
89+
/// }
90+
/// ```
91+
var idTokenChanges: AsyncStream<User?> {
92+
return AsyncStream { continuation in
93+
let listenerHandle = addIDTokenDidChangeListener { _, user in
94+
continuation.yield(user)
95+
}
96+
97+
continuation.onTermination = { @Sendable _ in
98+
self.removeStateDidChangeListener(listenerHandle)
99+
}
100+
}
101+
}
61102
}
62103
#endif // swift(>=5.5.2)

FirebaseAuth/Tests/Unit/AuthAsyncTests.swift renamed to FirebaseAuth/Tests/Unit/AuthStateChangesAsyncTests.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2024 Google LLC
1+
// Copyright 2025 Google LLC
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -17,7 +17,7 @@ import FirebaseCore
1717
import XCTest
1818

1919
@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *)
20-
class AuthAsyncTests: RPCBaseTests {
20+
class AuthStateChangesAsyncTests: RPCBaseTests {
2121
var auth: Auth!
2222
static var testNum = 0
2323

@@ -27,8 +27,8 @@ class AuthAsyncTests: RPCBaseTests {
2727
gcmSenderID: "00000000000000000-00000000000-000000000")
2828
options.apiKey = "FAKE_API_KEY"
2929
options.projectID = "myProjectID"
30-
let name = "test-AuthAsyncTests\(AuthAsyncTests.testNum)"
31-
AuthAsyncTests.testNum += 1
30+
let name = "test-AuthStateChangesAsyncTests\(AuthStateChangesAsyncTests.testNum)"
31+
AuthStateChangesAsyncTests.testNum += 1
3232

3333
FirebaseApp.configure(name: name, options: options)
3434
let app = FirebaseApp.app(name: name)!
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright 2025 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 IdTokenChangesAsyncTests: 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-IdTokenChangesAsyncTests\(IdTokenChangesAsyncTests.testNum)"
31+
IdTokenChangesAsyncTests.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 testIdTokenChangesStreamYieldsUserOnSignIn() 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.idTokenChanges {
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 testIdTokenChangesStreamIsCancelled() 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.idTokenChanges {
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 testIdTokenChangesStreamYieldsNilOnSignOut() 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.idTokenChanges {
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)