Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
);
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -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');
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -81,5 +82,6 @@ void main() {
instance_tests.main();
user_tests.main();
multi_factor_tests.main();
auth_emulator_web_tests.main();
});
}
65 changes: 32 additions & 33 deletions packages/firebase_auth/firebase_auth_web/lib/firebase_auth_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
}
}
Expand Down Expand Up @@ -541,18 +544,18 @@ class FirebaseAuthWeb extends FirebaseAuthPlatform {
@override
Future<void> 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;
}

Expand All @@ -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
Expand Down Expand Up @@ -648,7 +651,3 @@ class FirebaseAuthWeb extends FirebaseAuthPlatform {
);
}
}

String getOriginName(String appName) {
return '$appName-firebaseEmulatorOrigin';
}
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 17 additions & 0 deletions packages/firebase_auth/firebase_auth_web/lib/src/interop/auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,23 @@ class Auth extends JsObjectWrapper<auth_interop.AuthJsImpl> {
/// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading