Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

.pnpm-store
node_modules
dist
dist-ssr
Expand Down
115 changes: 115 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,118 @@ const ui = initializeUI({

**Note:** If a merge conflict occurs and the linking fails (e.g., due to account linking restrictions), Firebase Auth will throw an error that you can handle in your error handling logic. The `onUpgrade` callback will only be called if the upgrade is successful.

---

## Handling sign-in provider mismatch: `fetchSignInMethodsForEmail` deprecation

### Background

A common UX pain point occurs when a user:

1. Signs in for the first time with an OAuth provider (e.g. Google)
2. Signs out and later returns to the app
3. Mistakenly tries to sign in with their email address and a password

Firebase Auth returns a generic `auth/invalid-credential` error (or the legacy `auth/wrong-password`). Without additional context, the user has no idea they already have an account linked to a different provider.

**In v6**, FirebaseUI worked around this by calling `fetchSignInMethodsForEmail()` behind the scenes. When a credential error occurred, it fetched the providers for that email and presented the user with the appropriate sign-in method.

**In v7**, `fetchSignInMethodsForEmail()` has been deprecated by Firebase and is no longer called. Google deprecated this method because returning which providers are associated with an email address is a potential privacy and security risk — it allows an unauthenticated caller to enumerate which accounts (and therefore which email addresses) exist in your project.
Comment thread
russellwheatley marked this conversation as resolved.
Outdated

### The problem with the deprecated approach

```ts
// ❌ Deprecated — do not use
import { fetchSignInMethodsForEmail } from "firebase/auth";

const methods = await fetchSignInMethodsForEmail(auth, email);
// e.g. ["google.com"] — tells an attacker that this email exists in your app
```

This API leaks the existence of accounts to anyone who can call your Firebase project, which is a security risk. Firebase has disabled it by default in new projects and it will eventually be removed entirely.

### The recommended approach: track providers yourself

Because `fetchSignInMethodsForEmail()` is gone, **you are responsible for tracking which sign-in provider a user has used** and surfacing that information when a credential error occurs.

The example screens `sign-in-with-provider-tracking` and `provider-hint` (included in both the React and Angular examples in this repository) demonstrate one way to implement this pattern.

#### How the demo works

1. **Track on sign-in** — When a user successfully authenticates via an OAuth button, the app stores their email and provider ID in `localStorage`:

```ts
function storeProvider(email: string, providerId: string): void {
const existing = JSON.parse(localStorage.getItem("fui_provider_hint") ?? "{}");
const providers: string[] = existing.email === email ? existing.providers : [];
Comment thread
russellwheatley marked this conversation as resolved.
Outdated
if (!providers.includes(providerId)) providers.push(providerId);
localStorage.setItem("fui_provider_hint", JSON.stringify({ email, providers }));
}
```

2. **Intercept credential errors** — On email + password sign-in failure, check the stored hint before showing a generic error:

```ts
try {
await signInWithEmailAndPassword(auth, email, password);
} catch (err) {
const code = (err as AuthError).code;
const isCredentialError =
code === "auth/invalid-credential" || code === "auth/wrong-password";
Comment thread
russellwheatley marked this conversation as resolved.
Outdated

if (isCredentialError) {
const knownProviders = getKnownProviders(email); // reads localStorage
if (knownProviders.length > 0) {
// Navigate to a screen that shows only the correct OAuth button
navigate("/provider-hint");
return;
}
}
// show generic error
}
```

3. **Show the correct provider** — The `provider-hint` screen reads the stored data and renders only the OAuth button(s) the user originally signed in with, along with a human-friendly explanation.

#### Why localStorage for this demo?

`localStorage` is used here purely for ease of demonstration. It requires no backend and makes the flow visible and debuggable.

#### A more secure production approach

`localStorage` is accessible to any JavaScript running on the page. If an XSS vulnerability exists, an attacker could read or overwrite the stored provider hint. For a production application, consider these alternatives:

**Option 1 — HttpOnly encrypted cookie (recommended for server-rendered apps)**

Store the provider hint in an `HttpOnly` cookie from your server after a successful sign-in. Because `HttpOnly` cookies are not accessible to JavaScript, they are immune to XSS attacks:

```
Set-Cookie: fui_provider_hint=<encrypted-payload>; HttpOnly; Secure; SameSite=Lax; Path=/
```

The encrypted payload should contain the email (or a hashed/obfuscated identifier) and the provider ID. Encrypt the value using a server-side key (e.g. AES-GCM) so that neither the email address nor the provider information is readable by the client even if the cookie value is somehow observed.

When a credential error occurs on the client, make a server-side request to look up the provider hint. Return only enough information to drive the UI (e.g. which button to show) — never return the raw email or provider list to an unauthenticated caller.

**Option 2 — Hashed identifier in localStorage**

If a purely client-side solution is required, avoid storing the plain email address. Instead store a hash:

```ts
async function hashEmail(email: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(email.toLowerCase().trim());
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
```

Store `{ emailHash, providers }` instead of `{ email, providers }`. When looking up the hint on sign-in failure, hash the email the user typed and compare against the stored hash. This way the stored data does not directly reveal which email address is associated with the provider.

**Option 3 — Derive from existing session data**

If your application has its own session management (e.g. a JWT issued by your backend after Firebase sign-in), you can embed the provider ID in the token claims. On subsequent visits, read the provider from the token rather than from `localStorage`.


17 changes: 17 additions & 0 deletions examples/angular/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@
path: "/screens/phone-auth-screen-w-oauth",
loadComponent: () => import("./screens/phone-auth-screen-w-oauth").then((m) => m.PhoneAuthScreenWithOAuthComponent),
},
{
name: "Sign In with provider tracking",
description:
"Demonstrates how to redirect users to their original OAuth provider when they mistakenly try to sign in with email + password.",
path: "/screens/sign-in-with-provider-tracking",
loadComponent: () =>
import("./screens/sign-in-with-provider-tracking").then(

Check failure on line 120 in examples/angular/src/app/routes.ts

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `⏎········(m)·=>·m.SignInWithProviderTrackingComponent,⏎······` with `(m)·=>·m.SignInWithProviderTrackingComponent`
(m) => m.SignInWithProviderTrackingComponent,
),
},
{
name: "Provider hint",
description:

Check failure on line 126 in examples/angular/src/app/routes.ts

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Delete `⏎·····`
"Shown when a user attempts email + password sign-in but has a known OAuth provider stored locally.",
path: "/screens/provider-hint",
loadComponent: () => import("./screens/provider-hint").then((m) => m.ProviderHintComponent),
},
] as const;

export const hiddenRoutes: RouteConfig[] = [
Expand Down
150 changes: 150 additions & 0 deletions examples/angular/src/app/screens/provider-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Component, inject, OnInit, signal } from "@angular/core";

Check failure on line 17 in examples/angular/src/app/screens/provider-hint.ts

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Imports "OnInit" are only used as type
import { CommonModule } from "@angular/common";
import { Router } from "@angular/router";
import {
AppleSignInButtonComponent,
FacebookSignInButtonComponent,
GitHubSignInButtonComponent,
GoogleSignInButtonComponent,
MicrosoftSignInButtonComponent,
TwitterSignInButtonComponent,
YahooSignInButtonComponent,
} from "@firebase-oss/ui-angular";
import { PROVIDER_HINT_STORAGE_KEY, type StoredProviderHint } from "./sign-in-with-provider-tracking";

const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
"google.com": "Google",
"apple.com": "Apple",
"facebook.com": "Facebook",
"github.com": "GitHub",
"microsoft.com": "Microsoft",
"twitter.com": "Twitter / X",
"yahoo.com": "Yahoo",
};

function getStoredHint(): StoredProviderHint | null {
try {
const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY);
return raw ? (JSON.parse(raw) as StoredProviderHint) : null;
} catch {
return null;
}
}

@Component({
selector: "app-provider-hint",
standalone: true,
imports: [
CommonModule,
GoogleSignInButtonComponent,
AppleSignInButtonComponent,
FacebookSignInButtonComponent,
GitHubSignInButtonComponent,
MicrosoftSignInButtonComponent,
TwitterSignInButtonComponent,
YahooSignInButtonComponent,
],
template: `
@if (hint() && hint()!.providers.length > 0) {
<div class="max-w-sm mx-auto space-y-6">
<div
class="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/40 dark:border-amber-800 p-4 space-y-2"
>
<p class="text-sm font-semibold text-amber-800 dark:text-amber-200">
Looks like you previously signed in with {{ providerNames() }}.
</p>
<p class="text-sm text-amber-700 dark:text-amber-300">
Use the button below to sign in with the provider you used before.
</p>
</div>

<div class="space-y-2">
@for (providerId of hint()!.providers; track providerId) {
@switch (providerId) {
@case ("google.com") {
<fui-google-sign-in-button (signIn)="onSignIn()" />
}
@case ("apple.com") {
<fui-apple-sign-in-button (signIn)="onSignIn()" />
}
@case ("facebook.com") {
<fui-facebook-sign-in-button (signIn)="onSignIn()" />
}
@case ("github.com") {
<fui-github-sign-in-button (signIn)="onSignIn()" />
}
@case ("microsoft.com") {
<fui-microsoft-sign-in-button (signIn)="onSignIn()" />
}
@case ("twitter.com") {
<fui-twitter-sign-in-button (signIn)="onSignIn()" />
}
@case ("yahoo.com") {
<fui-yahoo-sign-in-button (signIn)="onSignIn()" />
}
}
}
</div>

<button

Check failure on line 105 in examples/angular/src/app/screens/provider-hint.ts

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `⏎··········class="text-sm·underline·w-full·text-center·text-gray-500·dark:text-gray-400"⏎··········(click)="goBack()"⏎········` with `·class="text-sm·underline·w-full·text-center·text-gray-500·dark:text-gray-400"·(click)="goBack()"`
class="text-sm underline w-full text-center text-gray-500 dark:text-gray-400"
(click)="goBack()"
>
Back to sign in
</button>
</div>
} @else {
<div class="max-w-sm mx-auto space-y-4 text-center pt-12">
<p class="text-sm text-gray-500 dark:text-gray-400">

Check failure on line 114 in examples/angular/src/app/screens/provider-hint.ts

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `⏎··········No·provider·hint·found.·Please·sign·in·normally.⏎········` with `No·provider·hint·found.·Please·sign·in·normally.`
No provider hint found. Please sign in normally.
</p>
<button class="text-sm underline text-gray-600 dark:text-gray-300" (click)="goBack()">

Check failure on line 117 in examples/angular/src/app/screens/provider-hint.ts

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `⏎··········Back·to·sign·in⏎········` with `Back·to·sign·in`
Back to sign in
</button>
</div>
}
`,
styles: [],
})
export class ProviderHintComponent implements OnInit {
private router = inject(Router);

hint = signal<StoredProviderHint | null>(null);
providerNames = signal<string>("");

ngOnInit(): void {
const stored = getStoredHint();
this.hint.set(stored);
if (stored) {
this.providerNames.set(

Check failure on line 135 in examples/angular/src/app/screens/provider-hint.ts

View workflow job for this annotation

GitHub Actions / Lint and Format Check

Replace `⏎········stored.providers.map((id)·=>·PROVIDER_DISPLAY_NAMES[id]·??·id).join("·or·"),⏎······` with `stored.providers.map((id)·=>·PROVIDER_DISPLAY_NAMES[id]·??·id).join("·or·")`
stored.providers.map((id) => PROVIDER_DISPLAY_NAMES[id] ?? id).join(" or "),
);
}
}

onSignIn(): void {
this.router.navigate(["/"]);
}

goBack(): void {
this.router.navigate(["/screens/sign-in-with-provider-tracking"]);
}
}

export default ProviderHintComponent;
Loading
Loading