Skip to content

fix(auth): forward signed-out initial native callbacks - #18797

Open
JulianPscheid wants to merge 2 commits into
firebase:mainfrom
JulianPscheid:fix/auth-forward-initial-signed-out-callback
Open

JulianPscheid wants to merge 2 commits into
firebase:mainfrom
JulianPscheid:fix/auth-forward-initial-signed-out-callback

Conversation

@JulianPscheid

Copy link
Copy Markdown

Description

On Apple and Android, the auth-state and ID-token stream handlers drop the first native callback even when the SDK has already signed the user out. If that sign-out happens before the Dart streams subscribe (for example after a revoked refresh token on cold start), Dart keeps the cached user: currentUser and the auth streams stay signed in until the app restarts. See #18796.

This change forwards a first callback whose user is null and keeps dropping a first callback that carries a user, as before.

Forwarding alone would give a fresh signed-out app a second null event (the case #3628 and the "fires once on first initialization" test guard). So the Dart method-channel handlers now track signed-out state separately for the two native channels and skip a null that follows a null, including the synthetic initial one. The state is per channel because both handlers update the shared currentUser, their callbacks can arrive in either order, and signOut() clears currentUser before the native events arrive. A check on currentUser alone would swallow real sign-out events.

Tests:

  • Platform interface unit tests: null deduplication, sign-in, sign-out, repeated non-null callbacks, both callback orders, and callbacks after signOut() returns. The package's 201 tests pass.
  • A new e2e test in firebase_auth_instance_e2e_test.dart signs out a fresh named app through the native API before listening, then reads both native channels directly and expects a null first event. It goes through the Pigeon API on purpose, because the public streams prepend a synthetic value and can't show whether the native first callback was forwarded. It passes on macOS and Android (API 36.1 emulator, Auth emulator).
  • The existing "fires once on first initialization" test still passes on Android (it's skipped on macOS, iOS and Windows upstream).

Not covered: an automated revoked-refresh-token cold start, iOS runtime, and the full auth e2e suite. The cold-start timing is hard to reproduce in the emulator, so we verified that scenario by hand on macOS in our app.

Related Issues

Fixes #18796

Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • My PR includes unit or integration tests for all changed/updated/fixed behaviors (See Contributor Guide).
  • All existing and new tests are passing.
  • I updated/added relevant documentation (doc comments with ///).
  • The analyzer (melos run analyze) does not report any problems on my PR.
  • I read and followed the Flutter Style Guide.
  • I signed the CLA.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

  • Yes, this is a breaking change.
  • No, this is not a breaking change.

The Apple and Android auth-state and ID-token stream handlers dropped
the first native callback unconditionally. If the SDK had already signed
the user out before the Dart streams subscribed (for example after a
revoked refresh token on cold start), Dart kept the cached user.

Forward a first callback whose user is null, and keep dropping one that
carries a user. In the Dart method-channel handlers, track signed-out
state per native channel and skip a null that follows a null, so a fresh
signed-out app still gets a single initial event.

Fixes firebase#18796
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@SelaseKay SelaseKay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @JulianPscheid, thanks for the pull request.

The diagnosis is right, and forwarding a signed-out first callback fixes #18796. A few things before this can merge:

1. The new flags can swallow a real sign-out. _authStateChangesIsSignedOut and _idTokenChangesIsSignedOut only become false when a user event arrives from native, but the sign-in methods also set currentUser directly from the returned credential. If a sign-in finishes natively before the event channel listen call is handled (for example await FirebaseAuth.instance.signInAnonymously() as the first use of auth, which is fast against the emulator), this happens:

  1. The snapshot from Firebase.initializeApp has no user, so both flags start as true.
  2. The sign-in completes, and Dart sets currentUser to the new user. The flags stay true.
  3. The listener is registered while native already has a user. The first callback carries that user, so native still drops it.
  4. The app subscribes to authStateChanges() and gets the user from currentUser.
  5. signOut() makes native send null. The flag is still true, so Dart drops it, and the stream never emits null.

Before this change, step 5 emitted null. A small fix is to clear both flags whenever currentUser is set to a non-null user, for example in a setter, so they track what subscribers could have seen and not only what native has sent. Please add a unit test for this sequence. In the existing test harness, that means calling a sign-in method before injecting any events, then injecting null.

Missing test for the unchanged path. Please also cover that a first native callback carrying a user is still dropped, so a normal signed-in launch emits the user only once. This is the half of the existing behavior that the native change has to keep.

Clear both sign-out deduplication flags whenever currentUser receives a non-null user so a sign-in result cannot hide a later native sign-out. Keep null assignments from changing the flags.

Add a unit regression for sign-in before native events across authStateChanges, idTokenChanges, and userChanges. Add a native integration regression that checks the first signed-in callback stays suppressed and a later signed-out callback still arrives.

Match swift-format brace placement in both Apple auth stream handlers.
@JulianPscheid

Copy link
Copy Markdown
Author

Thanks @SelaseKay, good catch on the sign-in path. I've pushed a follow-up commit:

  • currentUser in MethodChannelFirebaseAuth now has a setter. Setting a non-null user clears both _authStateChangesIsSignedOut and _idTokenChangesIsSignedOut. Setting null leaves them alone, so the null handlers can still tell a real sign-out from a duplicate. I dropped the two explicit = false lines in the event handlers because the setter now covers them.
  • method_channel_auth_stream_test.dart has a test for your sequence: signInAnonymously before any native events, subscribe, inject null, expect ['test-user', null]. It runs for authStateChanges, idTokenChanges and userChanges. Without the setter all three fail because the null never arrives.
  • For the unchanged path I added an e2e test, native auth streams drop a signed-in initial callback. The drop happens in native code, so the test goes below Dart: it signs in on a fresh named app through the Pigeon API, then listens to each native EventChannel directly. It expects no event for 2 seconds, then signs out and expects that same first event to arrive with a null user. If native forwarded the signed-in first callback, the first check fails. I haven't run it on a device yet. It passes dart analyze.
  • Moved the opening brace in both Apple handlers to match swift-format.

All 204 platform-interface tests pass.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[firebase_auth]: authStateChanges() never emits null when the user is force-signed-out before the stream is listened to (iOS, macOS, Android)

2 participants