firebase / flutterfire

🔥 A collection of Firebase plugins for Flutter apps.
https://firebase.google.com/docs/flutter/setup
BSD 3-Clause "New" or "Revised" License
8.73k stars 3.98k forks source link

[firebase_database]: Listener throws `permission denied` after sign out despite `awaiting` the `StreamSubscription` to be `canceled` before #16726

Open Tom3652 opened 1 week ago

Tom3652 commented 1 week ago

Is there an existing issue for this?

Which plugins are affected?

Database

Which platforms are affected?

Android, iOS

Description

When you cancel the StreamSubscription listener of a Firebase and sign out right after, there is a chance to get a race condition where FirebaseDatabase throws a permission denied error when the listener should already be cancelled.

Actual results : In my real app, i am cancelling all the listeners i have to my FirebaseDatabase instance, then i sign out the user when he clicks on Sign out. That works fine most of the time, but among my few thousands active users, i have few hundreds of "Permission denied" logs in Crashlytics.

Moreover, i have managed to reproduce it on my local machine, this happens often enough on both Android and iOS to be noticeable.

Expected results : When calling await _listener?.cancel() on a FirebaseDatabase.instance.ref().onValue.listen((d) {}) StreamSubscription, i would like the stream to be canceled at 100% when the await completes, which would avoid all these scenarios with permission denied errors.

Reproducing the issue

  1. Run the sample code
  2. Write a read rule to auth.uid != null in the Firebase console on the testing node
  3. Play around with the sign in / sign out but always in the correct order until you saw the 0
  4. See the race condition after some time depending on the "bug"
import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:flutter/material.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(const TestApp());
}

class TestApp extends StatefulWidget {
  const TestApp({super.key});

  @override
  State<TestApp> createState() => _TestAppState();
}

class _TestAppState extends State<TestApp> {
  StreamSubscription? _listener;

  Future<void> register() async {

    _listener =
        FirebaseDatabase.instance.ref().child("test").onValue.listen((d) {
      print(d.snapshot.value);
    });
  }

  Future<void> signIn() async {
    print("Sign in");
    await FirebaseAuth.instance.signInWithEmailAndPassword(
        email: "<your_email>", password: "<your_password>");
    register();
  }

  Future<void> signOut() async {
    print("Sign out !");
    await _listener?.cancel();
    FirebaseAuth.instance.signOut();
  }

  @override
  void initState() {

    signIn();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              ElevatedButton(onPressed: () {
                signIn();
              }, child: Text("Sign in")),
              SizedBox(
                height: 30,
              ),
              ElevatedButton(onPressed: () {
                signOut();
              }, child: Text("Sign out"))
            ],
          ),
        ),
      ),
    );
  }
}

Firebase Core version

3.8.0

Flutter Version

3.24.5

Relevant Log Output

flutter: Sign in
flutter: 0
flutter: Sign out !
11.4.0 - [FirebaseDatabase][I-RDB038012] Listener at /test failed: permission_denied
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: [firebase_database/permission-denied] Client doesn't have permission to access the desired data.
#0      EventChannelExtension.receiveGuardedBroadcastStream (package:_flutterfire_internals/src/exception.dart:67:43)
#1      MethodChannelQuery.observe (package:firebase_database_platform_interface/src/method_channel/method_channel_query.dart:57:39)
<asynchronous suspension>
#2      _ForwardingStreamSubscription._handleData (dart:async/stream_pipe.dart:152:3)
<asynchronous suspension>

Flutter dependencies

Expand Flutter dependencies snippet
```yaml Dart SDK 3.5.4 Flutter SDK 3.24.5 test_database 1.0.0+1 dependencies: - firebase_auth 5.3.3 [firebase_auth_platform_interface firebase_auth_web firebase_core firebase_core_platform_interface flutter meta] - firebase_core 3.8.0 [firebase_core_platform_interface firebase_core_web flutter meta] - firebase_database 11.1.6 [firebase_core firebase_core_platform_interface firebase_database_platform_interface firebase_database_web flutter] - flutter 0.0.0 [characters collection material_color_utilities meta vector_math sky_engine] dev dependencies: - flutter_lints 4.0.0 [lints] - flutter_test 0.0.0 [flutter test_api matcher path fake_async clock stack_trace vector_math leak_tracker_flutter_testing async boolean_selector characters collection leak_tracker leak_tracker_testing material_color_utilities meta source_span stream_channel string_scanner term_glyph vm_service] transitive dependencies: - _flutterfire_internals 1.3.46 [collection firebase_core firebase_core_platform_interface flutter meta] - async 2.11.0 [collection meta] - boolean_selector 2.1.1 [source_span string_scanner] - characters 1.3.0 - clock 1.1.1 - collection 1.18.0 - fake_async 1.3.1 [clock collection] - firebase_auth_platform_interface 7.4.9 [_flutterfire_internals collection firebase_core flutter meta plugin_platform_interface] - firebase_auth_web 5.13.4 [firebase_auth_platform_interface firebase_core firebase_core_web flutter flutter_web_plugins http_parser meta web] - firebase_core_platform_interface 5.3.0 [collection flutter flutter_test meta plugin_platform_interface] - firebase_core_web 2.18.1 [firebase_core_platform_interface flutter flutter_web_plugins meta web] - firebase_database_platform_interface 0.2.5+46 [_flutterfire_internals collection firebase_core flutter meta plugin_platform_interface] - firebase_database_web 0.2.6+4 [collection firebase_core firebase_core_web firebase_database_platform_interface flutter flutter_web_plugins] - flutter_web_plugins 0.0.0 [flutter characters collection material_color_utilities meta vector_math] - http_parser 4.0.2 [collection source_span string_scanner typed_data] - leak_tracker 10.0.5 [clock collection meta path vm_service] - leak_tracker_flutter_testing 3.0.5 [flutter leak_tracker leak_tracker_testing matcher meta] - leak_tracker_testing 3.0.1 [leak_tracker matcher meta] - lints 4.0.0 - matcher 0.12.16+1 [async meta stack_trace term_glyph test_api] - material_color_utilities 0.11.1 [collection] - meta 1.15.0 - path 1.9.0 - plugin_platform_interface 2.1.8 [meta] - sky_engine 0.0.99 - source_span 1.10.0 [collection path term_glyph] - stack_trace 1.11.1 [path] - stream_channel 2.1.2 [async] - string_scanner 1.2.0 [source_span] - term_glyph 1.2.1 - test_api 0.7.2 [async boolean_selector collection meta source_span stack_trace stream_channel string_scanner term_glyph] - typed_data 1.4.0 [collection] - vector_math 2.1.4 - vm_service 14.2.5 - web 1.1.0 ```

Additional context and comments

No response

SelaseKay commented 6 days ago

Hi @Tom3652, thanks for the report. I'm able to reproduce this issue.

Reproducible code

Reproducible code ```dart // Copyright 2021 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:async'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_database/firebase_database.dart'; import 'package:firebase_database/ui/firebase_animated_list.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'firebase_options.dart'; // Change to false to use live database instance. const USE_DATABASE_EMULATOR = false; // The port we've set the Firebase Database emulator to run on via the // `firebase.json` configuration file. const emulatorPort = 9000; // Android device emulators consider localhost of the host machine as 10.0.2.2 // so let's use that if running on Android. final emulatorHost = (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) ? '10.0.2.2' : 'localhost'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); if (USE_DATABASE_EMULATOR) { FirebaseDatabase.instance.useDatabaseEmulator(emulatorHost, emulatorPort); } runApp( const MaterialApp( title: 'Flutter Database Example', home: TestApp(), ), ); } class TestApp extends StatefulWidget { const TestApp({super.key}); @override State createState() => _TestAppState(); } class _TestAppState extends State { StreamSubscription? _listener; Future register() async { _listener = FirebaseDatabase.instance.ref().child("test_path").onValue.listen((d) { print("Listner value: ${d.snapshot.value}"); }); } Future signIn() async { print("Sign in"); await FirebaseAuth.instance.signInWithEmailAndPassword( email: "your-email", password: "your-password"); register(); } Future signOut() async { print("Sign out !"); await _listener?.cancel(); await FirebaseAuth.instance.signOut(); } @override void initState() { signIn(); super.initState(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ ElevatedButton(onPressed: () { signIn(); }, child: Text("Sign in")), SizedBox( height: 30, ), ElevatedButton(onPressed: () { signOut(); }, child: Text("Sign out")) ], ), ), ), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({Key? key}) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State { int _counter = 0; late DatabaseReference _counterRef; late DatabaseReference _messagesRef; late StreamSubscription _counterSubscription; late StreamSubscription _messagesSubscription; bool _anchorToBottom = false; String _kTestKey = 'Hello'; String _kTestValue = 'world!'; FirebaseException? _error; bool initialized = false; @override void initState() { init(); super.initState(); } Future init() async { _counterRef = FirebaseDatabase.instance.ref('counter'); final database = FirebaseDatabase.instance; _messagesRef = database.ref('messages'); database.setLoggingEnabled(false); if (!kIsWeb) { database.setPersistenceEnabled(true); database.setPersistenceCacheSizeBytes(10000000); } if (!kIsWeb) { await _counterRef.keepSynced(true); } setState(() { initialized = true; }); try { final counterSnapshot = await _counterRef.get(); print( 'Connected to directly configured database and read ' '${counterSnapshot.value}', ); } catch (err) { print(err); } _counterSubscription = _counterRef.onValue.listen( (DatabaseEvent event) { setState(() { _error = null; _counter = (event.snapshot.value ?? 0) as int; }); }, onError: (Object o) { final error = o as FirebaseException; setState(() { _error = error; }); }, ); final messagesQuery = _messagesRef.limitToLast(10); _messagesSubscription = messagesQuery.onChildAdded.listen( (DatabaseEvent event) { print('Child added: ${event.snapshot.value}'); }, onError: (Object o) { final error = o as FirebaseException; print('Error: ${error.code} ${error.message}'); }, ); } @override void dispose() { super.dispose(); _messagesSubscription.cancel(); _counterSubscription.cancel(); } Future _increment() async { await _counterRef.set(ServerValue.increment(1)); await _messagesRef .push() .set({_kTestKey: '$_kTestValue $_counter'}); } Future _incrementAsTransaction() async { try { final transactionResult = await _counterRef.runTransaction((mutableData) { return Transaction.success((mutableData as int? ?? 0) + 1); }); if (transactionResult.committed) { final newMessageRef = _messagesRef.push(); await newMessageRef.set({ _kTestKey: '$_kTestValue ${transactionResult.snapshot.value}', }); } } on FirebaseException catch (e) { print(e.message); } } Future _deleteMessage(DataSnapshot snapshot) async { final messageRef = _messagesRef.child(snapshot.key!); await messageRef.remove(); } void _setAnchorToBottom(bool? value) { if (value == null) { return; } setState(() { _anchorToBottom = value; }); } @override Widget build(BuildContext context) { if (!initialized) return Container(); return Scaffold( appBar: AppBar( title: const Text('Flutter Database Example'), ), body: Column( children: [ Flexible( child: Center( child: _error == null ? Text( 'Button tapped $_counter time${_counter == 1 ? '' : 's'}.\n\n' 'This includes all devices, ever.', ) : Text( 'Error retrieving button tap count:\n${_error!.message}', ), ), ), ElevatedButton( onPressed: _incrementAsTransaction, child: const Text('Increment as transaction'), ), ListTile( leading: Checkbox( onChanged: _setAnchorToBottom, value: _anchorToBottom, ), title: const Text('Anchor to bottom'), ), Flexible( child: FirebaseAnimatedList( key: ValueKey(_anchorToBottom), query: _messagesRef, reverse: _anchorToBottom, itemBuilder: (context, snapshot, animation, index) { return SizeTransition( sizeFactor: animation, child: ListTile( trailing: IconButton( onPressed: () => _deleteMessage(snapshot), icon: const Icon(Icons.delete), ), title: Text('$index: ${snapshot.value}'), ), ); }, ), ), ], ), floatingActionButton: FloatingActionButton( onPressed: _increment, tooltip: 'Increment', child: const Icon(Icons.add), ), ); } } ```