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/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/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', + ); + }); +}