Skip to content

[Bug] Desktop Auth: LinkWithCredential (and Reload) never persist the updated user; user is restored as the pre-link anonymous user and signed out after restart #1935

Open

Description

@suxoikorm

[REQUIRED] Please fill in the following fields:

  • Pre-built SDK from the website or open-source from this repo: Pre-built (firebase_cpp_sdk_windows_13.5.0.zip, downloaded by FlutterFire's firebase_core 4.10.0 Windows plugin; firebase_auth plugin 6.5.2). The relevant code is unchanged on current main (0fe9d97).
  • Firebase C++ SDK version: 13.5.0
  • Problematic Firebase Component: Auth
  • Other Firebase Components in use: App, Firestore (not involved)
  • Platform you are using the C++ SDK on: Windows
  • Platform you are targeting: desktop (Windows)

[REQUIRED] Please describe the issue here:

On desktop, linking a credential to the signed-in user (same uid) updates the in-memory user but never writes it to the persisted user data. After the next process start, the SDK restores the stale pre-link state: the user is still anonymous, has no linked provider, and has the old tokens. For an email/password link, the restored session is then rejected by the backend and the SDK signs the user out. So an anonymous user who upgrades their account by linking is signed out on the next launch. We see this in production on Windows.

Why it happens (current main, 0fe9d97)

  1. UserDataPersist is registered only as an AuthStateListener and saves only from OnAuthStateChanged:

    • void InitializeUserDataPersist(AuthData* auth_data) {
      auto auth_impl = static_cast<AuthImpl*>(auth_data->auth_impl);
      auth_impl->user_data_persist = std::make_unique<UserDataPersist>(
      internal::CreateAppIdentifierFromOptions(auth_data->app->options())
      .c_str());
      auth_data->auth->AddAuthStateListener(auth_impl->user_data_persist.get());
      auth_impl->user_data_persist->LoadUserData(auth_data);
      }
      auth_data->auth->AddAuthStateListener(auth_impl->user_data_persist.get());
    • void UserDataPersist::OnAuthStateChanged(Auth* auth) { // NOLINT
      if (auth->current_user().is_valid()) {
      SaveUserData(auth->auth_data_);
      } else {
      DeleteUserData(auth->auth_data_);
      }
      }
  2. AuthenticationResult::SetAsCurrentUser notifies auth-state listeners only when the uid changes. A link keeps the uid, so the persister is never called:

    • if (previous_user.uid != uid()) {
      NotifyAuthStateListeners(auth_data);
      }
      if (previous_user.id_token != id_token()) {
      NotifyIdTokenListeners(auth_data);
      }
      if (previous_user.uid != uid()) {
        NotifyAuthStateListeners(auth_data);
      }
      if (previous_user.id_token != id_token()) {
        NotifyIdTokenListeners(auth_data);
      }
  3. The only other save path, TriggerSaveUserFlow, is called only from PerformSetAccountInfoFlow (UpdateUserProfile, UpdatePassword, UpdateEmail, ...) and from PerformReauthFlow:

    • void TriggerSaveUserFlow(AuthData* const auth_data) {
      auto auth_impl = static_cast<AuthImpl*>(auth_data->auth_impl);
      if (auth_impl != nullptr) {
      auth_impl->user_data_persist->SaveUserData(auth_data);
      }
      }
    • if (account_info.IsValid()) {
      User* api_user_to_return =
      account_info.MergeToCurrentUser(handle->auth_data);
      TriggerSaveUserFlow(handle->auth_data);
      CompleteSetAccountInfoPromise(&handle->promise, api_user_to_return);
    • if (authentication_result.uid() == current_uid) {
      const AuthResult auth_result =
      authentication_result.SetAsCurrentUser(handle->auth_data);
      TriggerSaveUserFlow(handle->auth_data);
      CompletePromise(&handle->promise, auth_result);
  4. Neither link path calls it:

    • Email/password: DoLinkWithEmailAndPassword → PerformSignUpFlow. That function even calls NotifyIdTokenListeners explicitly "since this is used to upgrade anonymous accounts", but the persister is not an ID-token listener:
      template <typename ResponseT, typename ResultT>
      void PerformSignUpFlow(AuthDataHandle<ResultT, SignUpRequest>* const handle) {
      FIREBASE_ASSERT_RETURN_VOID(handle && handle->request);
      const auto response = GetResponse<ResponseT>(*handle->request);
      const AuthenticationResult auth_response =
      CompleteSignInFlow(handle->auth_data, response);
      if (auth_response.IsValid()) {
      const AuthResult auth_result =
      auth_response.SetAsCurrentUser(handle->auth_data);
      // The usual SignIn flow doesn't trigger this, but since this is used
      // to upgrade anonymous accounts, it is needed for SignUp
      NotifyIdTokenListeners(handle->auth_data);
      CompletePromise(&handle->promise, auth_result);
      } else {
      FailPromise(&handle->promise, auth_response.error());
      }
      }
    • Other providers: the verifyAssertion lambda in DoLinkCredential:
      return CallAsyncWithFreshToken(
      auth_data, promise,
      CreateVerifyAssertionRequest(*auth_data, raw_credential),
      [](AuthDataHandle<ResultT, VerifyAssertionRequest>* const handle) {
      FIREBASE_ASSERT_RETURN_VOID(handle && handle->request);
      const auto response =
      GetResponse<VerifyAssertionResponse>(*handle->request);
      const AuthenticationResult authentication_result =
      CompleteSignInFlow(handle->auth_data, response);
      if (authentication_result.IsValid()) {
      const AuthResult auth_result =
      authentication_result.SetAsCurrentUser(handle->auth_data);
      CompletePromise(&handle->promise, auth_result);
      } else {
      SignOutIfUserNoLongerValid(handle->auth_data->auth,
      authentication_result.error());
      FailPromise(&handle->promise, authentication_result.error());
      }
      });
  5. User::Reload() (the callback comment says "No listeners will be notified") and token refresh in EnsureFreshToken don't save either:

    • const auto callback = [](AuthDataHandle<void, RequestT>* const handle) {
      const GetAccountInfoResult account_info = GetAccountInfo(*handle->request);
      // No listeners will be notified: UID couldn't have changed, because we
      // are reloading the same user. Token couldn't have changed, because
      // GetAccountInfoResponse doesn't contain any tokens.
      if (account_info.IsValid()) {
      account_info.MergeToCurrentUser(handle->auth_data);
      handle->promise.Complete();
      } else {
      SignOutIfUserNoLongerValid(handle->auth_data->auth, account_info.error());
      FailPromise(&handle->promise, account_info.error());
      }
      };
    • bool has_token_changed = false;
      const auto token_update = TokenUpdate(response);
      if (token_update.HasUpdate()) {
      UserView::Writer writer = UserView::GetWriter(auth_data);
      if (writer.IsValid()) {
      has_token_changed =
      UpdateUserTokensIfChanged(writer, TokenUpdate(response));
      } else {
      return GetTokenResult(kAuthErrorNoSignedInUser);
      }
      }
      if (has_token_changed && notify_listener) {
      NotifyIdTokenListeners(auth_data);
      }

So after an anonymous user links email/password, the persisted data still says is_anonymous = true, has no email/password provider, and holds the pre-link ID and refresh tokens. As far as we can observe, setting a password on the account invalidates the previously issued tokens on the backend. After a restart:

  • the first Reload() fails with kAuthErrorUserTokenExpired, and SignOutIfUserNoLongerValid signs the user out
    (
    void SignOutIfUserNoLongerValid(Auth* const auth, const AuthError error_code) {
    FIREBASE_ASSERT_RETURN_VOID(auth);
    if (error_code == kAuthErrorUserNotFound ||
    error_code == kAuthErrorUserTokenExpired ||
    error_code == kAuthErrorUserDisabled) {
    auth->SignOut();
    }
    }
    );
  • without a Reload(), the same sign-out happens once the cached ID token expires and EnsureFreshToken tries the stale refresh token (L140-L146).

For an OAuth link, the restored user is reported as anonymous with no linked provider until something reloads it.

Steps to reproduce:

Have you been able to reproduce this issue with just the Firebase C++ quickstarts ?
Not with the quickstart. We hit it through the FlutterFire Windows plugin, which calls User::LinkWithCredential directly. The snippet below is the same sequence of calls in plain C++.

What's the issue repro rate? (eg 100%, 1/5 etc)
100% for the stale persisted state (it follows from the code paths above). The sign-out after an email/password link happens on every launch we have looked at.

What happened? How can we make the problem occur?

  1. SignInAnonymously(). The user is persisted (the uid changed, so the persister ran).
  2. current_user().LinkWithCredential(EmailAuthProvider::GetCredential(email, password)). This succeeds; in memory the user is no longer anonymous and has the password provider.
  3. Restart the process (or destroy Auth/App and create them again) and wait for the persisted user to load.
  4. current_user() has the same uid but is_anonymous() == true, an empty email() and no password in provider_data(). Reload() fails with kAuthErrorUserTokenExpired and the user is signed out.

Expected: a successful link (and arguably Reload() and a token refresh) persists the updated user, the same way sign-in, UpdateUserProfile and reauthentication do. After the restart the user is the linked, non-anonymous account and stays signed in.

Suggested fix: call TriggerSaveUserFlow(handle->auth_data) after a successful SetAsCurrentUser in PerformSignUpFlow (when used for linking) and in the verifyAssertion link lambda in DoLinkCredential. The same probably applies to Unlink, to Reload() after MergeToCurrentUser, and to EnsureFreshToken when has_token_changed. Another option is to have UserDataPersist also save on ID-token changes.

Workaround: after a successful link, call UpdateUserProfile with an unchanged display name. It goes through PerformSetAccountInfoFlow → TriggerSaveUserFlow and writes the current user to disk.

Relevant Code:

#include "firebase/app.h"
#include "firebase/auth.h"

using firebase::auth::Auth;
using firebase::auth::EmailAuthProvider;

// Run 1
firebase::App* app = firebase::App::Create(options);
Auth* auth = Auth::GetAuth(app);

auto anon = auth->SignInAnonymously();
// ... wait for completion; the anonymous user is persisted ...

auto link = auth->current_user().LinkWithCredential(
    EmailAuthProvider::GetCredential("user@example.com", "password123"));
// ... wait for completion: error() == kAuthErrorNone,
//     current_user().is_anonymous() == false, provider "password" present.
// Nothing is written to the persisted user data here.

// Run 2 (new process)
firebase::App* app2 = firebase::App::Create(options);
Auth* auth2 = Auth::GetAuth(app2);
// ... wait for the first AuthStateListener callback (persisted user loaded) ...
firebase::auth::User user = auth2->current_user();
// user.is_anonymous() == true, user.email() == "", no "password" provider
// (the pre-link state).

auto reload = user.Reload();
// ... wait: reload.error() == kAuthErrorUserTokenExpired,
//     and auth2->current_user().is_valid() == false (signed out).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions