Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
117 changes: 117 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,120 @@ 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()` 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
// ❌ Does not work when email enumeration protection is enabled
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" || code === "auth/invalid-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=<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`.


9 changes: 9 additions & 0 deletions examples/angular/src/app/app.routes.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "**",
Expand Down
14 changes: 14 additions & 0 deletions examples/angular/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ 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[] = [
Expand Down
141 changes: 141 additions & 0 deletions examples/angular/src/app/screens/provider-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* 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, type 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<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 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">No provider hint found. Please sign in normally.</p>
<button class="text-sm underline text-gray-600 dark:text-gray-300" (click)="goBack()">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(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