From a5d0101ab594e87dd9733691f363c763fb15a996 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 14 Sep 2026 13:50:32 +0000 Subject: [PATCH 1/2] fix(auth, web): connect Auth emulator after reload when sessionStorage already has the origin Skip connectAuthEmulator only when this JS Auth instance is already on the requested origin. Matching sessionStorage is not enough after a full page reload, especially on 127.0.0.1 where the early reconnect used to be skipped. --- .../auth_emulator_web_e2e_test.dart | 55 ++++++++ ...uth_emulator_web_session_storage_stub.dart | 7 + ...auth_emulator_web_session_storage_web.dart | 21 +++ .../example/integration_test/e2e_test.dart | 2 + .../firebase_auth_web/CHANGELOG.md | 4 + .../lib/firebase_auth_web.dart | 65 +++++---- .../lib/src/auth_emulator.dart | 44 ++++++ .../lib/src/interop/auth.dart | 17 +++ .../lib/src/interop/auth_interop.dart | 11 ++ .../firebase_auth_web/pubspec.yaml | 2 +- .../test/auth_emulator_test.dart | 133 ++++++++++++++++++ 11 files changed, 327 insertions(+), 34 deletions(-) create mode 100644 packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_e2e_test.dart create mode 100644 packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_session_storage_stub.dart create mode 100644 packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_session_storage_web.dart create mode 100644 packages/firebase_auth/firebase_auth_web/lib/src/auth_emulator.dart create mode 100644 packages/firebase_auth/firebase_auth_web/test/auth_emulator_test.dart diff --git a/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_e2e_test.dart b/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_e2e_test.dart new file mode 100644 index 000000000000..068710f01dde --- /dev/null +++ b/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_e2e_test.dart @@ -0,0 +1,55 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_auth_example/firebase_options.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'auth_emulator_web_session_storage_stub.dart' + if (dart.library.js_interop) 'auth_emulator_web_session_storage_web.dart'; +import 'test_utils.dart'; + +void main() { + group( + 'useAuthEmulator sessionStorage (web)', + () { + test( + 'connects even when sessionStorage already has the emulator origin', + () async { + const appName = 'auth-emulator-session-storage'; + final origin = 'http://$testEmulatorHost:$testEmulatorPort'; + + final app = await Firebase.initializeApp( + name: appName, + options: DefaultFirebaseOptions.currentPlatform, + ); + addTearDown(() async { + try { + await FirebaseAuth.instanceFor(app: app).signOut(); + } catch (_) {} + clearAuthEmulatorOrigin(appName); + await app.delete(); + }); + + // Simulate a previous page load: the sticky note is present, but this + // JS Auth instance has not called connectAuthEmulator yet. + setAuthEmulatorOrigin(appName, origin); + + final auth = FirebaseAuth.instanceFor(app: app); + await auth.useAuthEmulator(testEmulatorHost, testEmulatorPort); + + final credential = await auth.signInWithEmailAndPassword( + email: testEmail, + password: testPassword, + ); + expect(credential.user, isNotNull); + expect(credential.user!.email, testEmail); + }, + ); + }, + skip: !kIsWeb, + ); +} diff --git a/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_session_storage_stub.dart b/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_session_storage_stub.dart new file mode 100644 index 000000000000..59425ca502e4 --- /dev/null +++ b/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_session_storage_stub.dart @@ -0,0 +1,7 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +void setAuthEmulatorOrigin(String appName, String origin) {} + +void clearAuthEmulatorOrigin(String appName) {} diff --git a/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_session_storage_web.dart b/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_session_storage_web.dart new file mode 100644 index 000000000000..347e30d907c0 --- /dev/null +++ b/packages/firebase_auth/firebase_auth/example/integration_test/auth_emulator_web_session_storage_web.dart @@ -0,0 +1,21 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:js_interop'; + +@JS('sessionStorage') +external _SessionStorage get _sessionStorage; + +extension type _SessionStorage._(JSObject _) implements JSObject { + external void setItem(String key, String value); + external void removeItem(String key); +} + +void setAuthEmulatorOrigin(String appName, String origin) { + _sessionStorage.setItem('$appName-firebaseEmulatorOrigin', origin); +} + +void clearAuthEmulatorOrigin(String appName) { + _sessionStorage.removeItem('$appName-firebaseEmulatorOrigin'); +} diff --git a/packages/firebase_auth/firebase_auth/example/integration_test/e2e_test.dart b/packages/firebase_auth/firebase_auth/example/integration_test/e2e_test.dart index 0cc35bf1870c..5b074e89f486 100644 --- a/packages/firebase_auth/firebase_auth/example/integration_test/e2e_test.dart +++ b/packages/firebase_auth/firebase_auth/example/integration_test/e2e_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:firebase_auth_example/firebase_options.dart'; +import 'auth_emulator_web_e2e_test.dart' as auth_emulator_web_tests; import 'firebase_auth_instance_e2e_test.dart' as instance_tests; import 'firebase_auth_multi_factor_e2e_test.dart' as multi_factor_tests; import 'firebase_auth_user_e2e_test.dart' as user_tests; @@ -81,5 +82,6 @@ void main() { instance_tests.main(); user_tests.main(); multi_factor_tests.main(); + auth_emulator_web_tests.main(); }); } diff --git a/packages/firebase_auth/firebase_auth_web/CHANGELOG.md b/packages/firebase_auth/firebase_auth_web/CHANGELOG.md index fa153fd5b53b..9a9da291a530 100644 --- a/packages/firebase_auth/firebase_auth_web/CHANGELOG.md +++ b/packages/firebase_auth/firebase_auth_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.3.1 + + - **FIX**(auth, web): connect the Auth emulator after a reload even when sessionStorage already has the origin ([#18689](https://github.com/firebase/flutterfire/issues/18689)). + ## 6.3.0 - **FIX**(auth,web): convert Auth errors that omit customData without throwing ([#18686](https://github.com/firebase/flutterfire/issues/18686)). ([5e518361](https://github.com/firebase/flutterfire/commit/5e518361ce7f94923437d8b0e41add7ca75083ef)) diff --git a/packages/firebase_auth/firebase_auth_web/lib/firebase_auth_web.dart b/packages/firebase_auth/firebase_auth_web/lib/firebase_auth_web.dart index 860e6c5cce0d..e864cde65f89 100644 --- a/packages/firebase_auth/firebase_auth_web/lib/firebase_auth_web.dart +++ b/packages/firebase_auth/firebase_auth_web/lib/firebase_auth_web.dart @@ -19,6 +19,7 @@ import 'package:web/web.dart' as web; import 'src/firebase_auth_version.dart'; +import 'src/auth_emulator.dart'; import 'src/firebase_auth_web_confirmation_result.dart'; import 'src/firebase_auth_web_recaptcha_verifier_factory.dart'; import 'src/firebase_auth_web_user.dart'; @@ -56,28 +57,30 @@ class FirebaseAuthWeb extends FirebaseAuthPlatform { 'auth', ensurePluginInitialized: (firebaseApp) async { final authDelegate = auth_interop.getAuthInstance(firebaseApp); - // if localhost, and emulator was previously set in localStorage, use it - if (web.window.location.hostname == 'localhost' && kDebugMode) { - final String? emulatorOrigin = web.window.sessionStorage - .getItem(getOriginName(firebaseApp.name)); - - if (emulatorOrigin != null) { - try { - authDelegate.useAuthEmulator(emulatorOrigin); + // Re-apply a persisted emulator origin before Auth restores the user. + // Must include 127.0.0.1 — Chrome often uses that instead of localhost. + final String? emulatorOrigin = web.window.sessionStorage + .getItem(authEmulatorOriginStorageKey(firebaseApp.name)); + if (shouldReusePersistedAuthEmulator( + hostname: web.window.location.hostname, + isDebugMode: kDebugMode, + storedOrigin: emulatorOrigin, + )) { + try { + authDelegate.useAuthEmulator(emulatorOrigin!); + // ignore: avoid_print + print( + 'Using previously configured Auth emulator at $emulatorOrigin for ${firebaseApp.name} \nTo switch back to production, restart your app with the emulator turned off.', + ); + } catch (e) { + if (e.toString().contains('sooner')) { + // Happens during hot reload when the emulator is already configured // ignore: avoid_print print( - 'Using previously configured Auth emulator at $emulatorOrigin for ${firebaseApp.name} \nTo switch back to production, restart your app with the emulator turned off.', + 'Auth emulator is already configured at $emulatorOrigin for ${firebaseApp.name} and kept across hot reload.\nTo switch back to production, restart your app with the emulator turned off.', ); - } catch (e) { - if (e.toString().contains('sooner')) { - // Happens during hot reload when the emulator is already configured - // ignore: avoid_print - print( - 'Auth emulator is already configured at $emulatorOrigin for ${firebaseApp.name} and kept across hot reload.\nTo switch back to production, restart your app with the emulator turned off.', - ); - } else { - rethrow; - } + } else { + rethrow; } } } @@ -541,18 +544,18 @@ class FirebaseAuthWeb extends FirebaseAuthPlatform { @override Future useAuthEmulator(String host, int port) async { try { - // Get current session storage value - final String? emulatorOrigin = - web.window.sessionStorage.getItem(getOriginName(delegate.app.name)); - // The generic platform interface is with host and port split to // centralize logic between android/ios native, but web takes the // origin as a single string - final String origin = 'http://$host:$port'; - - if (origin == emulatorOrigin) { - // If the origin is the same as the current one, do nothing - // The emulator was already started at the app start + final String origin = authEmulatorOrigin(host, port); + + // Skip only if THIS JS Auth instance is already on that origin. + // SessionStorage matching is not enough: after a full page reload the + // Auth instance is new and still points at production (#18689). + if (shouldSkipConnectAuthEmulator( + requestedOrigin: origin, + connectedEmulatorOrigin: delegate.emulatorOrigin, + )) { return; } @@ -561,7 +564,7 @@ class FirebaseAuthWeb extends FirebaseAuthPlatform { // only in debug mode if (kDebugMode) { web.window.sessionStorage - .setItem(getOriginName(delegate.app.name), origin); + .setItem(authEmulatorOriginStorageKey(delegate.app.name), origin); } } catch (e) { // Cannot be done with 3.2 constraints @@ -648,7 +651,3 @@ class FirebaseAuthWeb extends FirebaseAuthPlatform { ); } } - -String getOriginName(String appName) { - return '$appName-firebaseEmulatorOrigin'; -} diff --git a/packages/firebase_auth/firebase_auth_web/lib/src/auth_emulator.dart b/packages/firebase_auth/firebase_auth_web/lib/src/auth_emulator.dart new file mode 100644 index 000000000000..15d354b978bd --- /dev/null +++ b/packages/firebase_auth/firebase_auth_web/lib/src/auth_emulator.dart @@ -0,0 +1,44 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Loopback hosts where a persisted Auth emulator origin may be reapplied +/// during plugin initialization after a full page reload. +bool isAuthEmulatorDebugHost(String hostname) { + return hostname == 'localhost' || + hostname == '127.0.0.1' || + hostname == '[::1]'; +} + +/// Origin string passed to the JS SDK `connectAuthEmulator`. +String authEmulatorOrigin(String host, int port) => 'http://$host:$port'; + +/// SessionStorage key for a persisted emulator origin, per Firebase app. +String authEmulatorOriginStorageKey(String appName) => + '$appName-firebaseEmulatorOrigin'; + +/// Whether plugin init should call `connectAuthEmulator` from sessionStorage +/// before Auth finishes restoring a persisted user. +bool shouldReusePersistedAuthEmulator({ + required String hostname, + required bool isDebugMode, + required String? storedOrigin, +}) { + return isDebugMode && + storedOrigin != null && + isAuthEmulatorDebugHost(hostname); +} + +/// Whether `connectAuthEmulator` can be skipped because **this** JS Auth +/// instance is already using [requestedOrigin]. +/// +/// A matching sessionStorage value is not enough: after a full page reload +/// the Auth instance is new and still points at production until +/// `connectAuthEmulator` runs again. +bool shouldSkipConnectAuthEmulator({ + required String requestedOrigin, + required String? connectedEmulatorOrigin, +}) { + return connectedEmulatorOrigin != null && + connectedEmulatorOrigin == requestedOrigin; +} diff --git a/packages/firebase_auth/firebase_auth_web/lib/src/interop/auth.dart b/packages/firebase_auth/firebase_auth_web/lib/src/interop/auth.dart index 3002de1e878d..7e14c3d0981e 100644 --- a/packages/firebase_auth/firebase_auth_web/lib/src/interop/auth.dart +++ b/packages/firebase_auth/firebase_auth_web/lib/src/interop/auth.dart @@ -330,6 +330,23 @@ class Auth extends JsObjectWrapper { /// Currently signed-in [User]. User? get currentUser => User.getInstance(jsObject.currentUser); + /// Origin this Auth instance is connected to, or `null` when using production. + /// + /// Matches the string passed to [useAuthEmulator], e.g. `http://localhost:9099`. + String? get emulatorOrigin { + final config = jsObject.emulatorConfig; + if (config == null) { + return null; + } + final protocol = config.protocol.toDart; + final host = config.host.toDart; + final port = config.port; + if (port == null) { + return '$protocol://$host'; + } + return '$protocol://$host:${port.toDartInt}'; + } + // Returns the current tenantId for the instance. String? get tenantId { return jsObject.tenantId?.toDart; diff --git a/packages/firebase_auth/firebase_auth_web/lib/src/interop/auth_interop.dart b/packages/firebase_auth/firebase_auth_web/lib/src/interop/auth_interop.dart index db6539c1e776..74b472b7023b 100644 --- a/packages/firebase_auth/firebase_auth_web/lib/src/interop/auth_interop.dart +++ b/packages/firebase_auth/firebase_auth_web/lib/src/interop/auth_interop.dart @@ -270,9 +270,20 @@ external MultiFactorResolverJsImpl getMultiFactorResolver( @staticInterop abstract class AuthJsImpl {} +@JS() +@staticInterop +abstract class EmulatorConfigJsImpl {} + +extension EmulatorConfigJsImplExtension on EmulatorConfigJsImpl { + external JSString get protocol; + external JSString get host; + external JSNumber? get port; +} + extension AuthJsImplExtension on AuthJsImpl { external AppJsImpl get app; external UserJsImpl? get currentUser; + external EmulatorConfigJsImpl? get emulatorConfig; external JSString? get languageCode; external set languageCode(JSString? s); external AuthSettings get settings; diff --git a/packages/firebase_auth/firebase_auth_web/pubspec.yaml b/packages/firebase_auth/firebase_auth_web/pubspec.yaml index 4c1f21f5d007..5a867ed77f45 100644 --- a/packages/firebase_auth/firebase_auth_web/pubspec.yaml +++ b/packages/firebase_auth/firebase_auth_web/pubspec.yaml @@ -2,7 +2,7 @@ name: firebase_auth_web description: The web implementation of firebase_auth homepage: https://github.com/firebase/flutterfire/tree/main/packages/firebase_auth/firebase_auth_web repository: https://github.com/firebase/flutterfire/tree/main/packages/firebase_auth/firebase_auth_web -version: 6.3.0 +version: 6.3.1 resolution: workspace environment: diff --git a/packages/firebase_auth/firebase_auth_web/test/auth_emulator_test.dart b/packages/firebase_auth/firebase_auth_web/test/auth_emulator_test.dart new file mode 100644 index 000000000000..ed54f0f7afcf --- /dev/null +++ b/packages/firebase_auth/firebase_auth_web/test/auth_emulator_test.dart @@ -0,0 +1,133 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:firebase_auth_web/src/auth_emulator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('shouldSkipConnectAuthEmulator', () { + test('does not skip when this Auth instance is not on the emulator', () { + expect( + shouldSkipConnectAuthEmulator( + requestedOrigin: 'http://localhost:9099', + connectedEmulatorOrigin: null, + ), + isFalse, + ); + }); + + test( + 'does not skip when sessionStorage would match but this instance is on production', + () { + // This is the #18689 reload: leftover sessionStorage is not passed in + // here on purpose. Skip must key off the live JS Auth config only. + expect( + shouldSkipConnectAuthEmulator( + requestedOrigin: 'http://localhost:9099', + connectedEmulatorOrigin: null, + ), + isFalse, + ); + }, + ); + + test('skips when this Auth instance is already on the requested origin', + () { + expect( + shouldSkipConnectAuthEmulator( + requestedOrigin: 'http://localhost:9099', + connectedEmulatorOrigin: 'http://localhost:9099', + ), + isTrue, + ); + }); + + test('does not skip when the connected origin is different', () { + expect( + shouldSkipConnectAuthEmulator( + requestedOrigin: 'http://localhost:9099', + connectedEmulatorOrigin: 'http://127.0.0.1:9099', + ), + isFalse, + ); + }); + }); + + group('shouldReusePersistedAuthEmulator', () { + const stored = 'http://localhost:9099'; + + test('reuses stored origin on localhost in debug', () { + expect( + shouldReusePersistedAuthEmulator( + hostname: 'localhost', + isDebugMode: true, + storedOrigin: stored, + ), + isTrue, + ); + }); + + test('reuses stored origin on 127.0.0.1 in debug', () { + expect( + shouldReusePersistedAuthEmulator( + hostname: '127.0.0.1', + isDebugMode: true, + storedOrigin: stored, + ), + isTrue, + ); + }); + + test('reuses stored origin on [::1] in debug', () { + expect( + shouldReusePersistedAuthEmulator( + hostname: '[::1]', + isDebugMode: true, + storedOrigin: stored, + ), + isTrue, + ); + }); + + test('does not reuse when hostname is not loopback', () { + expect( + shouldReusePersistedAuthEmulator( + hostname: 'example.com', + isDebugMode: true, + storedOrigin: stored, + ), + isFalse, + ); + }); + + test('does not reuse when nothing is stored', () { + expect( + shouldReusePersistedAuthEmulator( + hostname: '127.0.0.1', + isDebugMode: true, + storedOrigin: null, + ), + isFalse, + ); + }); + + test('does not reuse outside debug', () { + expect( + shouldReusePersistedAuthEmulator( + hostname: 'localhost', + isDebugMode: false, + storedOrigin: stored, + ), + isFalse, + ); + }); + }); + + test('authEmulatorOriginStorageKey is per app', () { + expect( + authEmulatorOriginStorageKey('[DEFAULT]'), + '[DEFAULT]-firebaseEmulatorOrigin', + ); + }); +} From cadf57772ef188637738673235dcfff80a09c438 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 14 Sep 2026 14:41:30 +0000 Subject: [PATCH 2/2] chore(auth, web): drop changelog and version bump for the release process --- packages/firebase_auth/firebase_auth_web/CHANGELOG.md | 4 ---- packages/firebase_auth/firebase_auth_web/pubspec.yaml | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/firebase_auth/firebase_auth_web/CHANGELOG.md b/packages/firebase_auth/firebase_auth_web/CHANGELOG.md index 9a9da291a530..fa153fd5b53b 100644 --- a/packages/firebase_auth/firebase_auth_web/CHANGELOG.md +++ b/packages/firebase_auth/firebase_auth_web/CHANGELOG.md @@ -1,7 +1,3 @@ -## 6.3.1 - - - **FIX**(auth, web): connect the Auth emulator after a reload even when sessionStorage already has the origin ([#18689](https://github.com/firebase/flutterfire/issues/18689)). - ## 6.3.0 - **FIX**(auth,web): convert Auth errors that omit customData without throwing ([#18686](https://github.com/firebase/flutterfire/issues/18686)). ([5e518361](https://github.com/firebase/flutterfire/commit/5e518361ce7f94923437d8b0e41add7ca75083ef)) diff --git a/packages/firebase_auth/firebase_auth_web/pubspec.yaml b/packages/firebase_auth/firebase_auth_web/pubspec.yaml index 5a867ed77f45..4c1f21f5d007 100644 --- a/packages/firebase_auth/firebase_auth_web/pubspec.yaml +++ b/packages/firebase_auth/firebase_auth_web/pubspec.yaml @@ -2,7 +2,7 @@ name: firebase_auth_web description: The web implementation of firebase_auth homepage: https://github.com/firebase/flutterfire/tree/main/packages/firebase_auth/firebase_auth_web repository: https://github.com/firebase/flutterfire/tree/main/packages/firebase_auth/firebase_auth_web -version: 6.3.1 +version: 6.3.0 resolution: workspace environment: