From f7beb2cdf5c4746e10f0baf78dc29e3fcd48cfe0 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 10 Mar 2026 14:05:32 +0000 Subject: [PATCH 01/10] chore: ignore .pnpm-store in .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6d5695608..80a073e53 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +.pnpm-store node_modules dist dist-ssr From 8159936bc9942a542b092774b4331bc7f4e8ea2b Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 11 Mar 2026 13:30:04 +0000 Subject: [PATCH 02/10] chore: update migration guide and React example app on workaround fetchSignInMethodsForEmail() deprecation --- MIGRATION.md | 115 +++++++++ examples/react/src/firebase/firebase.ts | 2 +- examples/react/src/routes.ts | 15 ++ examples/react/src/screens/provider-hint.tsx | 125 ++++++++++ .../sign-in-with-provider-tracking.tsx | 224 ++++++++++++++++++ 5 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 examples/react/src/screens/provider-hint.tsx create mode 100644 examples/react/src/screens/sign-in-with-provider-tracking.tsx diff --git a/MIGRATION.md b/MIGRATION.md index 021ad9065..38605205d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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. + +### 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 : []; + 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"; + + 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=; 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 { + 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`. + + diff --git a/examples/react/src/firebase/firebase.ts b/examples/react/src/firebase/firebase.ts index e0132cda3..2833f261e 100644 --- a/examples/react/src/firebase/firebase.ts +++ b/examples/react/src/firebase/firebase.ts @@ -42,5 +42,5 @@ export const ui = initializeUI({ }); if (import.meta.env.MODE === "development") { - connectAuthEmulator(auth, "http://localhost:9099"); + // connectAuthEmulator(auth, "http://localhost:9099"); } diff --git a/examples/react/src/routes.ts b/examples/react/src/routes.ts index f46f72903..55f6fe785 100644 --- a/examples/react/src/routes.ts +++ b/examples/react/src/routes.ts @@ -12,6 +12,8 @@ import CustomAuthScreenPage from "./screens/custom-auth-screen"; import PhoneAuthScreenPage from "./screens/phone-auth-screen"; import PhoneAuthScreenWithOAuthPage from "./screens/phone-auth-screen-w-oauth"; import MultiFactorAuthEnrollmentScreenPage from "./screens/mfa-enrollment-screen"; +import SignInWithProviderTrackingPage from "./screens/sign-in-with-provider-tracking"; +import ProviderHintPage from "./screens/provider-hint"; export const routes = [ { @@ -92,6 +94,19 @@ export const routes = [ path: "/screens/custom-auth", component: CustomAuthScreenPage, }, + { + 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", + component: SignInWithProviderTrackingPage, + }, + { + name: "Provider hint", + description: "Shown when a user attempts email + password sign-in but has a known OAuth provider stored locally.", + path: "/screens/provider-hint", + component: ProviderHintPage, + }, ] as const; export const hiddenRoutes = [ diff --git a/examples/react/src/screens/provider-hint.tsx b/examples/react/src/screens/provider-hint.tsx new file mode 100644 index 000000000..d228d1526 --- /dev/null +++ b/examples/react/src/screens/provider-hint.tsx @@ -0,0 +1,125 @@ +/** + * 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 type { UserCredential } from "firebase/auth"; +import { useNavigate } from "react-router"; +import { + AppleSignInButton, + FacebookSignInButton, + GitHubSignInButton, + GoogleSignInButton, + MicrosoftSignInButton, + TwitterSignInButton, + YahooSignInButton, +} from "@firebase-oss/ui-react"; +import { PROVIDER_HINT_STORAGE_KEY, type StoredProviderHint } from "./sign-in-with-provider-tracking"; + +function getStoredHint(): StoredProviderHint | null { + try { + const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); + return raw ? (JSON.parse(raw) as StoredProviderHint) : null; + } catch { + return null; + } +} + +const PROVIDER_DISPLAY_NAMES: Record = { + "google.com": "Google", + "apple.com": "Apple", + "facebook.com": "Facebook", + "github.com": "GitHub", + "microsoft.com": "Microsoft", + "twitter.com": "Twitter / X", + "yahoo.com": "Yahoo", +}; + +function ProviderButton({ + providerId, + onSignIn, +}: { + providerId: string; + onSignIn: (credential: UserCredential) => void; +}) { + switch (providerId) { + case "google.com": + return ; + case "apple.com": + return ; + case "facebook.com": + return ; + case "github.com": + return ; + case "microsoft.com": + return ; + case "twitter.com": + return ; + case "yahoo.com": + return ; + default: + return null; + } +} + +export default function ProviderHintPage() { + const navigate = useNavigate(); + const hint = getStoredHint(); + + function handleSignIn() { + navigate("/"); + } + + if (!hint || hint.providers.length === 0) { + return ( +
+

No provider hint found. Please sign in normally.

+ +
+ ); + } + + const providerNames = hint.providers.map((id) => PROVIDER_DISPLAY_NAMES[id] ?? id).join(" or "); + + return ( +
+
+

+ Looks like you previously signed in with {providerNames}. +

+

+ Use the button below to sign in with the provider you used before. +

+
+ +
+ {hint.providers.map((providerId) => ( + + ))} +
+ + +
+ ); +} diff --git a/examples/react/src/screens/sign-in-with-provider-tracking.tsx b/examples/react/src/screens/sign-in-with-provider-tracking.tsx new file mode 100644 index 000000000..119ae458a --- /dev/null +++ b/examples/react/src/screens/sign-in-with-provider-tracking.tsx @@ -0,0 +1,224 @@ +/** + * 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. + */ + +/** + * This screen demonstrates how to handle the scenario where a user previously signed in + * with an OAuth provider (e.g. Google) but later attempts to sign in with email + password. + * + * Because `fetchSignInMethodsForEmail()` is deprecated in Firebase Auth, applications must + * implement their own provider-tracking solution. This example uses localStorage to record + * which OAuth provider a user signed in with, then redirects them to the correct provider + * button when a credential error is detected. + * + * NOTE: localStorage is used here for demonstration purposes only. + * In a production application, prefer storing this information server-side or in an + * HttpOnly encrypted cookie so that provider metadata is not exposed to client-side scripts. + */ + +"use client"; + +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { signInWithEmailAndPassword, type AuthError, type UserCredential } from "firebase/auth"; +import { + AppleSignInButton, + FacebookSignInButton, + GitHubSignInButton, + GoogleSignInButton, + MicrosoftSignInButton, + TwitterSignInButton, + YahooSignInButton, +} from "@firebase-oss/ui-react"; +import { auth } from "../firebase/firebase"; + +/** localStorage key used to persist the most recent sign-in provider hint. */ +export const PROVIDER_HINT_STORAGE_KEY = "fui_provider_hint"; + +/** Shape of the data stored under PROVIDER_HINT_STORAGE_KEY. */ +export interface StoredProviderHint { + /** The email address associated with the known providers. */ + email: string; + /** Firebase provider IDs (e.g. "google.com", "github.com") the user has signed in with. */ + providers: string[]; +} + +function storeProvider(email: string, providerId: string): void { + try { + const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); + const existing: StoredProviderHint = raw + ? (JSON.parse(raw) as StoredProviderHint) + : { email: "", providers: [] }; + + const providers = existing.email === email ? [...existing.providers] : []; + if (!providers.includes(providerId)) { + providers.push(providerId); + } + localStorage.setItem(PROVIDER_HINT_STORAGE_KEY, JSON.stringify({ email, providers })); + } catch { + // Silently ignore storage errors. + } +} + +function getKnownProviders(email: string): string[] { + try { + const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); + if (!raw) return []; + const data = JSON.parse(raw) as StoredProviderHint; + return data.email === email ? data.providers : []; + } catch { + return []; + } +} + +function getErrorMessage(code: string): string { + switch (code) { + case "auth/user-not-found": + return "No account found with that email address."; + case "auth/too-many-requests": + return "Too many failed attempts. Please try again later."; + default: + return "Incorrect email or password."; + } +} + +export default function SignInWithProviderTrackingPage() { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + await signInWithEmailAndPassword(auth, email, password); + navigate("/"); + } catch (err) { + const authError = err as AuthError; + + // auth/wrong-password (Firebase Auth v9 legacy) and auth/invalid-credential (v10+) + // both indicate bad credentials. Check if the user has a known OAuth provider for + // this email and redirect them to the provider hint screen if so. + const isCredentialError = + authError.code === "auth/wrong-password" || + authError.code === "auth/invalid-credential" || + authError.code === "auth/invalid-password"; + + if (isCredentialError) { + const knownProviders = getKnownProviders(email); + if (knownProviders.length > 0) { + navigate("/screens/provider-hint"); + return; + } + } + + setError(getErrorMessage(authError.code)); + } finally { + setLoading(false); + } + } + + function handleOAuthSignIn(credential: UserCredential): void { + const userEmail = credential.user.email ?? ""; + const providerId = credential.user.providerData[0]?.providerId ?? ""; + if (userEmail && providerId) { + storeProvider(userEmail, providerId); + } + navigate("/"); + } + + return ( +
+
+

+ Demo +

+

+ Sign in with an OAuth provider first, then sign out. Return here and try + signing in with email + password to see the provider hint flow. +

+
+ +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + className="w-full border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 text-sm bg-transparent focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + className="w-full border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 text-sm bg-transparent focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ +
+
+
+
+
+ or continue with +
+
+ +
+ + + + + + + +
+
+ ); +} From 20de54ff710129b89a40d0758e846790ecd0d14d Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 11 Mar 2026 13:30:13 +0000 Subject: [PATCH 03/10] chore: update angular app --- examples/angular/src/app/routes.ts | 17 ++ .../angular/src/app/screens/provider-hint.ts | 150 +++++++++++ .../screens/sign-in-with-provider-tracking.ts | 248 ++++++++++++++++++ 3 files changed, 415 insertions(+) create mode 100644 examples/angular/src/app/screens/provider-hint.ts create mode 100644 examples/angular/src/app/screens/sign-in-with-provider-tracking.ts diff --git a/examples/angular/src/app/routes.ts b/examples/angular/src/app/routes.ts index fcafffd4a..198096f8b 100644 --- a/examples/angular/src/app/routes.ts +++ b/examples/angular/src/app/routes.ts @@ -111,6 +111,23 @@ export const routes: RouteConfig[] = [ 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( + (m) => m.SignInWithProviderTrackingComponent, + ), + }, + { + name: "Provider hint", + description: + "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[] = [ diff --git a/examples/angular/src/app/screens/provider-hint.ts b/examples/angular/src/app/screens/provider-hint.ts new file mode 100644 index 000000000..a1713227d --- /dev/null +++ b/examples/angular/src/app/screens/provider-hint.ts @@ -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"; +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 = { + "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) { +
+
+

+ Looks like you previously signed in with {{ providerNames() }}. +

+

+ Use the button below to sign in with the provider you used before. +

+
+ +
+ @for (providerId of hint()!.providers; track providerId) { + @switch (providerId) { + @case ("google.com") { + + } + @case ("apple.com") { + + } + @case ("facebook.com") { + + } + @case ("github.com") { + + } + @case ("microsoft.com") { + + } + @case ("twitter.com") { + + } + @case ("yahoo.com") { + + } + } + } +
+ + +
+ } @else { +
+

+ No provider hint found. Please sign in normally. +

+ +
+ } + `, + styles: [], +}) +export class ProviderHintComponent implements OnInit { + private router = inject(Router); + + hint = signal(null); + providerNames = signal(""); + + ngOnInit(): void { + const stored = getStoredHint(); + this.hint.set(stored); + if (stored) { + this.providerNames.set( + 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; diff --git a/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts b/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts new file mode 100644 index 000000000..f1aeb5398 --- /dev/null +++ b/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts @@ -0,0 +1,248 @@ +/** + * 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. + */ + +/** + * This screen demonstrates how to handle the scenario where a user previously signed in + * with an OAuth provider (e.g. Google) but later attempts to sign in with email + password. + * + * Because `fetchSignInMethodsForEmail()` is deprecated in Firebase Auth, applications must + * implement their own provider-tracking solution. This example uses localStorage to record + * which OAuth provider a user signed in with, then redirects them to the correct provider + * button when a credential error is detected. + * + * NOTE: localStorage is used here for demonstration purposes only. + * In a production application, prefer storing this information server-side or in an + * HttpOnly encrypted cookie so that provider metadata is not exposed to client-side scripts. + */ + +import { Component, inject, signal } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; +import { + Auth, + signInWithEmailAndPassword, + type AuthError, + type UserCredential, +} from "@angular/fire/auth"; +import { + AppleSignInButtonComponent, + FacebookSignInButtonComponent, + GitHubSignInButtonComponent, + GoogleSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + YahooSignInButtonComponent, +} from "@firebase-oss/ui-angular"; + +/** localStorage key used to persist the most recent sign-in provider hint. */ +export const PROVIDER_HINT_STORAGE_KEY = "fui_provider_hint"; + +/** Shape of the data stored under PROVIDER_HINT_STORAGE_KEY. */ +export interface StoredProviderHint { + /** The email address associated with the known providers. */ + email: string; + /** Firebase provider IDs (e.g. "google.com", "github.com") the user has signed in with. */ + providers: string[]; +} + +function storeProvider(email: string, providerId: string): void { + try { + const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); + const existing: StoredProviderHint = raw + ? (JSON.parse(raw) as StoredProviderHint) + : { email: "", providers: [] }; + + const providers = existing.email === email ? [...existing.providers] : []; + if (!providers.includes(providerId)) { + providers.push(providerId); + } + localStorage.setItem(PROVIDER_HINT_STORAGE_KEY, JSON.stringify({ email, providers })); + } catch { + // Silently ignore storage errors. + } +} + +function getKnownProviders(email: string): string[] { + try { + const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); + if (!raw) return []; + const data = JSON.parse(raw) as StoredProviderHint; + return data.email === email ? data.providers : []; + } catch { + return []; + } +} + +function getErrorMessage(code: string): string { + switch (code) { + case "auth/user-not-found": + return "No account found with that email address."; + case "auth/too-many-requests": + return "Too many failed attempts. Please try again later."; + default: + return "Incorrect email or password."; + } +} + +@Component({ + selector: "app-sign-in-with-provider-tracking", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + GoogleSignInButtonComponent, + AppleSignInButtonComponent, + FacebookSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + YahooSignInButtonComponent, + ], + template: ` +
+
+

+ Demo +

+

+ Sign in with an OAuth provider first, then sign out. Return here and try signing in with + email + password to see the provider hint flow. +

+
+ +
+
+ + +
+ +
+ + +
+ + @if (error()) { + + } + + +
+ +
+
+
+
+
+ or continue with +
+
+ +
+ + + + + + + +
+
+ `, + styles: [], +}) +export class SignInWithProviderTrackingComponent { + private router = inject(Router); + private auth = inject(Auth); + private fb = inject(FormBuilder); + + form = this.fb.group({ + email: ["", [Validators.required, Validators.email]], + password: ["", Validators.required], + }); + + error = signal(null); + loading = signal(false); + + async handleSubmit(): Promise { + if (this.form.invalid) return; + + this.error.set(null); + this.loading.set(true); + + const { email, password } = this.form.value; + + try { + await signInWithEmailAndPassword(this.auth, email!, password!); + this.router.navigate(["/"]); + } catch (err) { + const authError = err as AuthError; + + // auth/wrong-password (Firebase Auth v9 legacy) and auth/invalid-credential (v10+) + // both indicate bad credentials. Check if the user has a known OAuth provider for + // this email and redirect them to the provider hint screen if so. + const isCredentialError = + authError.code === "auth/wrong-password" || + authError.code === "auth/invalid-credential" || + authError.code === "auth/invalid-password"; + + if (isCredentialError) { + const knownProviders = getKnownProviders(email!); + if (knownProviders.length > 0) { + this.router.navigate(["/screens/provider-hint"]); + return; + } + } + + this.error.set(getErrorMessage(authError.code)); + } finally { + this.loading.set(false); + } + } + + handleOAuthSignIn(credential: UserCredential): void { + const email = credential.user.email ?? ""; + const providerId = credential.user.providerData[0]?.providerId ?? ""; + if (email && providerId) { + storeProvider(email, providerId); + } + this.router.navigate(["/"]); + } +} + +export default SignInWithProviderTrackingComponent; From a508e99d8afda3771050023f58760770b4e3f941 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 11 Mar 2026 15:48:23 +0000 Subject: [PATCH 04/10] chore: revert back auth emulator --- examples/react/src/firebase/firebase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/react/src/firebase/firebase.ts b/examples/react/src/firebase/firebase.ts index 2833f261e..e0132cda3 100644 --- a/examples/react/src/firebase/firebase.ts +++ b/examples/react/src/firebase/firebase.ts @@ -42,5 +42,5 @@ export const ui = initializeUI({ }); if (import.meta.env.MODE === "development") { - // connectAuthEmulator(auth, "http://localhost:9099"); + connectAuthEmulator(auth, "http://localhost:9099"); } From 676d14fb515bf1e1093be12298f0953a48c4a76c Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 11 Mar 2026 15:53:24 +0000 Subject: [PATCH 05/10] fix: angular example not working as intended. now fixed --- examples/angular/src/app/app.routes.server.ts | 9 +++++ .../screens/sign-in-with-provider-tracking.ts | 22 ++++++++---- .../sign-in-with-provider-tracking.tsx | 34 +++++++++++-------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/examples/angular/src/app/app.routes.server.ts b/examples/angular/src/app/app.routes.server.ts index 4d2d69b8a..79de2ec1e 100644 --- a/examples/angular/src/app/app.routes.server.ts +++ b/examples/angular/src/app/app.routes.server.ts @@ -81,6 +81,15 @@ export const serverRoutes: ServerRoute[] = [ path: "screens/mfa-enrollment-screen", renderMode: RenderMode.Client, }, + /** Provider tracking screens require browser localStorage — must be client-only */ + { + path: "screens/sign-in-with-provider-tracking", + renderMode: RenderMode.Client, + }, + { + path: "screens/provider-hint", + renderMode: RenderMode.Client, + }, /** All other routes will be rendered on the server (SSR) */ { path: "**", diff --git a/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts b/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts index f1aeb5398..fbc4e5b57 100644 --- a/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts +++ b/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts @@ -59,18 +59,23 @@ export interface StoredProviderHint { providers: string[]; } +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + function storeProvider(email: string, providerId: string): void { try { + const normalized = normalizeEmail(email); const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); const existing: StoredProviderHint = raw ? (JSON.parse(raw) as StoredProviderHint) : { email: "", providers: [] }; - const providers = existing.email === email ? [...existing.providers] : []; + const providers = existing.email === normalized ? [...existing.providers] : []; if (!providers.includes(providerId)) { providers.push(providerId); } - localStorage.setItem(PROVIDER_HINT_STORAGE_KEY, JSON.stringify({ email, providers })); + localStorage.setItem(PROVIDER_HINT_STORAGE_KEY, JSON.stringify({ email: normalized, providers })); } catch { // Silently ignore storage errors. } @@ -78,10 +83,11 @@ function storeProvider(email: string, providerId: string): void { function getKnownProviders(email: string): string[] { try { + const normalized = normalizeEmail(email); const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); if (!raw) return []; const data = JSON.parse(raw) as StoredProviderHint; - return data.email === email ? data.providers : []; + return data.email === normalized ? data.providers : []; } catch { return []; } @@ -213,12 +219,16 @@ export class SignInWithProviderTrackingComponent { } catch (err) { const authError = err as AuthError; - // auth/wrong-password (Firebase Auth v9 legacy) and auth/invalid-credential (v10+) - // both indicate bad credentials. Check if the user has a known OAuth provider for - // this email and redirect them to the provider hint screen if so. + // Firebase Auth uses different error codes across SDK versions and project configurations: + // auth/wrong-password — Firebase Auth v9 legacy + // auth/invalid-credential — Firebase Auth v10+ (email+password bad credentials) + // auth/invalid-login-credentials — some Identity Platform configurations + // auth/invalid-password — used in some emulator / admin SDK contexts + // All of these indicate bad credentials, so treat them the same. const isCredentialError = authError.code === "auth/wrong-password" || authError.code === "auth/invalid-credential" || + authError.code === "auth/invalid-login-credentials" || authError.code === "auth/invalid-password"; if (isCredentialError) { diff --git a/examples/react/src/screens/sign-in-with-provider-tracking.tsx b/examples/react/src/screens/sign-in-with-provider-tracking.tsx index 119ae458a..db7bef9ba 100644 --- a/examples/react/src/screens/sign-in-with-provider-tracking.tsx +++ b/examples/react/src/screens/sign-in-with-provider-tracking.tsx @@ -55,18 +55,21 @@ export interface StoredProviderHint { providers: string[]; } +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + function storeProvider(email: string, providerId: string): void { try { + const normalized = normalizeEmail(email); const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); - const existing: StoredProviderHint = raw - ? (JSON.parse(raw) as StoredProviderHint) - : { email: "", providers: [] }; + const existing: StoredProviderHint = raw ? (JSON.parse(raw) as StoredProviderHint) : { email: "", providers: [] }; - const providers = existing.email === email ? [...existing.providers] : []; + const providers = existing.email === normalized ? [...existing.providers] : []; if (!providers.includes(providerId)) { providers.push(providerId); } - localStorage.setItem(PROVIDER_HINT_STORAGE_KEY, JSON.stringify({ email, providers })); + localStorage.setItem(PROVIDER_HINT_STORAGE_KEY, JSON.stringify({ email: normalized, providers })); } catch { // Silently ignore storage errors. } @@ -74,10 +77,11 @@ function storeProvider(email: string, providerId: string): void { function getKnownProviders(email: string): string[] { try { + const normalized = normalizeEmail(email); const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); if (!raw) return []; const data = JSON.parse(raw) as StoredProviderHint; - return data.email === email ? data.providers : []; + return data.email === normalized ? data.providers : []; } catch { return []; } @@ -112,12 +116,16 @@ export default function SignInWithProviderTrackingPage() { } catch (err) { const authError = err as AuthError; - // auth/wrong-password (Firebase Auth v9 legacy) and auth/invalid-credential (v10+) - // both indicate bad credentials. Check if the user has a known OAuth provider for - // this email and redirect them to the provider hint screen if so. + // Firebase Auth uses different error codes across SDK versions and project configurations: + // auth/wrong-password — Firebase Auth v9 legacy + // auth/invalid-credential — Firebase Auth v10+ (email+password bad credentials) + // auth/invalid-login-credentials — some Identity Platform configurations + // auth/invalid-password — used in some emulator / admin SDK contexts + // All of these indicate bad credentials, so treat them the same. const isCredentialError = authError.code === "auth/wrong-password" || authError.code === "auth/invalid-credential" || + authError.code === "auth/invalid-login-credentials" || authError.code === "auth/invalid-password"; if (isCredentialError) { @@ -146,12 +154,10 @@ export default function SignInWithProviderTrackingPage() { return (
-

- Demo -

+

Demo

- Sign in with an OAuth provider first, then sign out. Return here and try - signing in with email + password to see the provider hint flow. + Sign in with an OAuth provider first, then sign out. Return here and try signing in with email + password to + see the provider hint flow.

From 379dd6004e987e962fd930fa30cd6f7491eafba8 Mon Sep 17 00:00:00 2001 From: Russell Wheatley Date: Wed, 11 Mar 2026 15:54:40 +0000 Subject: [PATCH 06/10] Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- MIGRATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 38605205d..be155a36f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -418,7 +418,7 @@ The example screens `sign-in-with-provider-tracking` and `provider-hint` (includ ```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 : []; + const providers: string[] = existing.email === email ? [...existing.providers] : []; if (!providers.includes(providerId)) providers.push(providerId); localStorage.setItem("fui_provider_hint", JSON.stringify({ email, providers })); } @@ -432,7 +432,7 @@ try { } catch (err) { const code = (err as AuthError).code; const isCredentialError = - code === "auth/invalid-credential" || code === "auth/wrong-password"; + code === "auth/invalid-credential" || code === "auth/wrong-password" || code === "auth/invalid-password"; if (isCredentialError) { const knownProviders = getKnownProviders(email); // reads localStorage From fb739267070ac61314558cedb139c82b57ee3251 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Wed, 11 Mar 2026 15:58:30 +0000 Subject: [PATCH 07/10] chore: format --- examples/angular/src/app/routes.ts | 7 ++---- .../angular/src/app/screens/provider-hint.ts | 19 ++++----------- .../screens/sign-in-with-provider-tracking.ts | 23 +++++-------------- 3 files changed, 13 insertions(+), 36 deletions(-) diff --git a/examples/angular/src/app/routes.ts b/examples/angular/src/app/routes.ts index 198096f8b..3f8ccdcd6 100644 --- a/examples/angular/src/app/routes.ts +++ b/examples/angular/src/app/routes.ts @@ -117,14 +117,11 @@ export const routes: RouteConfig[] = [ "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( - (m) => m.SignInWithProviderTrackingComponent, - ), + import("./screens/sign-in-with-provider-tracking").then((m) => m.SignInWithProviderTrackingComponent), }, { name: "Provider hint", - description: - "Shown when a user attempts email + password sign-in but has a known OAuth provider stored locally.", + description: "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), }, diff --git a/examples/angular/src/app/screens/provider-hint.ts b/examples/angular/src/app/screens/provider-hint.ts index a1713227d..5188c0261 100644 --- a/examples/angular/src/app/screens/provider-hint.ts +++ b/examples/angular/src/app/screens/provider-hint.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, inject, OnInit, signal } from "@angular/core"; +import { Component, inject, type OnInit, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { Router } from "@angular/router"; import { @@ -102,21 +102,14 @@ function getStoredHint(): StoredProviderHint | null { }
-
} @else {
-

- No provider hint found. Please sign in normally. -

- +

No provider hint found. Please sign in normally.

+
} `, @@ -132,9 +125,7 @@ export class ProviderHintComponent implements OnInit { const stored = getStoredHint(); this.hint.set(stored); if (stored) { - this.providerNames.set( - stored.providers.map((id) => PROVIDER_DISPLAY_NAMES[id] ?? id).join(" or "), - ); + this.providerNames.set(stored.providers.map((id) => PROVIDER_DISPLAY_NAMES[id] ?? id).join(" or ")); } } diff --git a/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts b/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts index fbc4e5b57..bf0bb469b 100644 --- a/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts +++ b/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts @@ -32,12 +32,7 @@ import { Component, inject, signal } from "@angular/core"; import { CommonModule } from "@angular/common"; import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { - Auth, - signInWithEmailAndPassword, - type AuthError, - type UserCredential, -} from "@angular/fire/auth"; +import { Auth, signInWithEmailAndPassword, type AuthError, type UserCredential } from "@angular/fire/auth"; import { AppleSignInButtonComponent, FacebookSignInButtonComponent, @@ -67,9 +62,7 @@ function storeProvider(email: string, providerId: string): void { try { const normalized = normalizeEmail(email); const raw = localStorage.getItem(PROVIDER_HINT_STORAGE_KEY); - const existing: StoredProviderHint = raw - ? (JSON.parse(raw) as StoredProviderHint) - : { email: "", providers: [] }; + const existing: StoredProviderHint = raw ? (JSON.parse(raw) as StoredProviderHint) : { email: "", providers: [] }; const providers = existing.email === normalized ? [...existing.providers] : []; if (!providers.includes(providerId)) { @@ -120,15 +113,11 @@ function getErrorMessage(code: string): string { ], template: `
-
-

- Demo -

+
+

Demo

- Sign in with an OAuth provider first, then sign out. Return here and try signing in with - email + password to see the provider hint flow. + Sign in with an OAuth provider first, then sign out. Return here and try signing in with email + password to + see the provider hint flow.

From aa41e6f30ea0d01037bac676bad50f09b28de252 Mon Sep 17 00:00:00 2001 From: Russell Wheatley Date: Tue, 7 Apr 2026 11:05:08 +0100 Subject: [PATCH 08/10] Update MIGRATION.md to show this is needed when email enumeration protection is enabled Co-authored-by: Jeff <3759507+jhuleatt@users.noreply.github.com> --- MIGRATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index be155a36f..3d4d1a2d0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -391,7 +391,7 @@ Firebase Auth returns a generic `auth/invalid-credential` error (or the legacy ` **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. +**In v7**, `fetchSignInMethodsForEmail()` is no longer called because Firebase projects now have [email enumeration protection](https://docs.cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) enabled by default, and calls to `fetchSignInMethodsForEmail()` fail when email enumeration protection is enabled. ### The problem with the deprecated approach From bfec2aead9221781430e4ec3fdc4f1a83257a0fe Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 7 Apr 2026 11:08:51 +0100 Subject: [PATCH 09/10] chore: remove edge case documentation --- .../angular/src/app/screens/sign-in-with-provider-tracking.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts b/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts index bf0bb469b..c0fc187d1 100644 --- a/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts +++ b/examples/angular/src/app/screens/sign-in-with-provider-tracking.ts @@ -209,8 +209,6 @@ export class SignInWithProviderTrackingComponent { const authError = err as AuthError; // Firebase Auth uses different error codes across SDK versions and project configurations: - // auth/wrong-password — Firebase Auth v9 legacy - // auth/invalid-credential — Firebase Auth v10+ (email+password bad credentials) // auth/invalid-login-credentials — some Identity Platform configurations // auth/invalid-password — used in some emulator / admin SDK contexts // All of these indicate bad credentials, so treat them the same. From c2953c8e304bdb4081672332b190d9081b88b32f Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 7 Apr 2026 11:13:49 +0100 Subject: [PATCH 10/10] docs: point to legacyFetchSignInWithEmail behavior --- MIGRATION.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 3d4d1a2d0..8834296b1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -391,12 +391,14 @@ Firebase Auth returns a generic `auth/invalid-credential` error (or the legacy ` **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()` is no longer called because Firebase projects now have [email enumeration protection](https://docs.cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) enabled by default, and calls to `fetchSignInMethodsForEmail()` fail when email enumeration protection is enabled. +**In v7**, `fetchSignInMethodsForEmail()` is no longer called when [email enumeration protection](https://docs.cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) is enabled. Firebase projects now have email enumeration enabled by default, which causes calls to `fetchSignInMethodsForEmail()` to fail. + +It is still possible to switch email enumeration protection off, and we are working on a feature for allowing `fetchSignInMethodsForEmail()` via `legacyFetchSignInWithEmail()` behavior which you can [track here](https://github.com/firebase/firebaseui-web/pull/1343). ### The problem with the deprecated approach ```ts -// ❌ Deprecated — do not use +// ❌ Does not work when email enumeration protection is enabled import { fetchSignInMethodsForEmail } from "firebase/auth"; const methods = await fetchSignInMethodsForEmail(auth, email);