Skip to content

Commit c715071

Browse files
Merge pull request #1343 from firebase/fetchsigninwithemail
feat(auth): implement legacyFetchSignInWithEmail behaviour for Angular and React
2 parents 58f5031 + f935f34 commit c715071

44 files changed

Lines changed: 3259 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,61 @@ const ui = initializeUI({
395395
});
396396
```
397397

398+
#### `legacyFetchSignInWithEmail`
399+
400+
The `legacyFetchSignInWithEmail` behavior augments OAuth `auth/account-exists-with-different-credential` flows by calling `fetchSignInMethodsForEmail(auth, email)` and storing the returned methods on the UI instance. In the packaged React and Angular screen components, this recovery state can be rendered as a modal on `SignInAuthScreen` and `OAuthScreen` via the `showLegacySignInRecovery` prop/input, which defaults to `false`. Registering the behavior alone does not show any UI; opt in explicitly (`showLegacySignInRecovery={true}` / `[showLegacySignInRecovery]="true"`) on the screens where you want the built-in recovery modal.
401+
402+
The original pending credential is still preserved, so after the user signs in with the correct method, Firebase UI can continue the existing linking flow.
403+
404+
> **⚠️ Security note:** This behavior has important limitations and security trade-offs. The `fetchSignInMethodsForEmail()` API only works for Firebase projects that have [Email Enumeration Protection disabled](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection). Projects created after September 15, 2023 have this protection enabled by default; on those projects, `fetchSignInMethodsForEmail()` returns an empty array and this behavior becomes a no-op. Additionally, when enabled, this behavior will call `fetchSignInMethodsForEmail()` not only for OAuth conflicts (`auth/account-exists-with-different-credential`), but also for plain password sign-in failures (`auth/wrong-password`, `auth/invalid-credential`, `auth/invalid-login-credentials`). This means enabling this behavior causes the app to actively call an enumeration-capable API and surface which sign-in methods exist for an email address on every failed password attempt—directly opposing the enumeration protection that Firebase's generic error codes are otherwise designed to provide. Enable this behavior only if you explicitly understand and accept this UX-vs-security trade-off.
405+
406+
During this recovery flow, a pending OAuth credential is temporarily stored in **plaintext** in the browser's `sessionStorage` so it can be reapplied after the user signs in with the correct method. It is consumed and removed immediately once sign-in succeeds. This is a deliberate, same-origin-scoped, pre-existing trade-off, not an oversight.
407+
408+
```ts
409+
import { legacyFetchSignInWithEmail } from '@firebase-oss/ui-core';
410+
411+
const ui = initializeUI({
412+
app,
413+
behaviors: [legacyFetchSignInWithEmail()],
414+
});
415+
```
416+
417+
If you want full control over the UI, hide the built-in recovery component on the screen and read the recovery state directly with `useLegacySignInRecovery()`:
418+
419+
```tsx
420+
import { GitHubSignInButton, GoogleSignInButton, SignInAuthScreen, useLegacySignInRecovery } from '@firebase-oss/ui-react';
421+
422+
function WrongProviderRecovery() {
423+
const { recovery, clearRecovery } = useLegacySignInRecovery();
424+
425+
if (!recovery) {
426+
return null;
427+
}
428+
429+
return (
430+
<div>
431+
<p>You have previously signed in with a different method for {recovery.email}.</p>
432+
{recovery.signInMethods.includes('google.com') && (
433+
<GoogleSignInButton onSignIn={clearRecovery} />
434+
)}
435+
{recovery.signInMethods.includes('github.com') && (
436+
<GitHubSignInButton onSignIn={clearRecovery} />
437+
)}
438+
</div>
439+
);
440+
}
441+
442+
export function CustomSignInScreen() {
443+
return (
444+
<SignInAuthScreen showLegacySignInRecovery={false}>
445+
<WrongProviderRecovery />
446+
</SignInAuthScreen>
447+
);
448+
}
449+
```
450+
451+
Angular apps can hide the built-in recovery UI with `showLegacySignInRecovery="false"` and read the same state with `injectLegacySignInRecovery()` / `injectClearLegacySignInRecovery()`.
452+
398453
#### `oneTapSignIn`
399454

400455
The `oneTapSignIn` behavior triggers the [Google One Tap](https://developers.google.com/identity/gsi/web/guides/features) experience to render.
@@ -1061,6 +1116,7 @@ By default, any missing translations will fallback to English if not specified.
10611116
| onSignIn | `(user: User) => void?` | Callback when sign-in succeeds |
10621117
| onForgotPasswordClick | `() => void?` | Callback when forgot password link is clicked |
10631118
| onSignUpClick | `() => void?` | Callback when sign-up link is clicked |
1119+
| showLegacySignInRecovery | `boolean?` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |
10641120

10651121
**`SignUpAuthScreen`**
10661122

@@ -1113,6 +1169,7 @@ By default, any missing translations will fallback to English if not specified.
11131169
|------|:----:|-------------|
11141170
| onSignIn | `(user: User) => void?` | Callback when sign-in succeeds |
11151171
| children | `React.ReactNode?` | Child components |
1172+
| showLegacySignInRecovery | `boolean?` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |
11161173

11171174
**`OAuthButton`**
11181175

@@ -1189,6 +1246,10 @@ By default, any missing translations will fallback to English if not specified.
11891246
| asChild | `boolean?` | Render as child component using Slot |
11901247
| ...props | `ComponentProps<"button">` | Standard button HTML attributes |
11911248

1249+
**`LegacySignInRecovery`**
1250+
1251+
Default component for displaying suggested previous sign-in methods from `legacyFetchSignInWithEmail`.
1252+
11921253
**`Card`**
11931254

11941255
Card container component.
@@ -1251,6 +1312,12 @@ By default, any missing translations will fallback to English if not specified.
12511312

12521313
Returns `string | undefined`.
12531314

1315+
**`useLegacySignInRecovery`**
1316+
1317+
Gets the legacy sign-in recovery state populated by `legacyFetchSignInWithEmail`.
1318+
1319+
Returns `{ recovery: LegacySignInRecovery | undefined; clearRecovery: () => void }`.
1320+
12541321
**`useSignInAuthFormSchema`**
12551322

12561323
Creates a Zod schema for sign-in form validation.
@@ -1700,6 +1767,10 @@ By default, any missing translations will fallback to English if not specified.
17001767

17011768
Screen component for email/password sign-in.
17021769

1770+
| Input | Type | Description |
1771+
|-------|:----:|-------------|
1772+
| showLegacySignInRecovery | `boolean` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |
1773+
17031774
| Output | Type | Description |
17041775
|--------|:----:|-------------|
17051776
| signIn | `EventEmitter<User>` | Emitted when sign-in succeeds |
@@ -1754,6 +1825,10 @@ By default, any missing translations will fallback to English if not specified.
17541825

17551826
Screen component for OAuth provider sign-in.
17561827

1828+
| Input | Type | Description |
1829+
|-------|:----:|-------------|
1830+
| showLegacySignInRecovery | `boolean` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |
1831+
17571832
| Output | Type | Description |
17581833
|--------|:----:|-------------|
17591834
| onSignIn | `EventEmitter<User>` | Emitted when OAuth sign-in succeeds |
@@ -1904,6 +1979,12 @@ By default, any missing translations will fallback to English if not specified.
19041979

19051980
Component that displays redirect errors from Firebase UI authentication flow.
19061981

1982+
**`LegacySignInRecoveryComponent`**
1983+
1984+
Selector: `fui-legacy-sign-in-recovery`
1985+
1986+
Default component for displaying suggested previous sign-in methods from `legacyFetchSignInWithEmail`.
1987+
19071988
**`ContentComponent`**
19081989

19091990
Selector: `fui-content`
@@ -1922,6 +2003,18 @@ By default, any missing translations will fallback to English if not specified.
19222003

19232004
Returns `Signal<string \| undefined>`.
19242005

2006+
**`injectLegacySignInRecovery`**
2007+
2008+
Injects the legacy sign-in recovery state from the UI store as a signal.
2009+
2010+
Returns `Signal<LegacySignInRecovery \| undefined>`.
2011+
2012+
**`injectClearLegacySignInRecovery`**
2013+
2014+
Injects a callback that clears the current legacy sign-in recovery state.
2015+
2016+
Returns `() => void`.
2017+
19252018
**`injectTranslation`**
19262019

19272020
Injects a translated string for a given category and key.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type { APIRequestContext, Locator, Page } from "@playwright/test";
18+
import { enUs } from "@firebase-oss/ui-translations";
19+
import { expect, test } from "../fixtures/test-harness";
20+
21+
const AUTH_EMULATOR_BASE_URL = "http://127.0.0.1:9099";
22+
const FIREBASE_PROJECT_ID = "demo-test";
23+
const RECOVERY_PATH = "/screens/legacy-recovery-demo";
24+
25+
const projectsUnderTest = ["react", "angular-example"] as const;
26+
27+
type EmulatorUser = {
28+
localId: string;
29+
providerUserInfo?: Array<{ providerId: string }>;
30+
};
31+
32+
function uniqueEmail(projectName: string, label: string): string {
33+
return `${projectName}-${label}-${crypto.randomUUID()}@example.test`;
34+
}
35+
36+
async function clearEmulatorUsers(request: APIRequestContext): Promise<void> {
37+
const response = await request.delete(
38+
`${AUTH_EMULATOR_BASE_URL}/emulator/v1/projects/${FIREBASE_PROJECT_ID}/accounts`
39+
);
40+
expect(response.ok(), await response.text()).toBe(true);
41+
}
42+
43+
async function createGoogleAccount(request: APIRequestContext, email: string): Promise<void> {
44+
const claims = {
45+
sub: crypto.randomUUID(),
46+
name: "Playwright Recovery User",
47+
email,
48+
email_verified: true,
49+
};
50+
const requestUri =
51+
`${AUTH_EMULATOR_BASE_URL}/emulator/auth/handler?providerId=google.com&id_token=` +
52+
encodeURIComponent(JSON.stringify(claims));
53+
const response = await request.post(
54+
`${AUTH_EMULATOR_BASE_URL}/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=fake-api-key`,
55+
{
56+
data: {
57+
requestUri,
58+
sessionId: "ValueNotUsedByAuthEmulator",
59+
returnSecureToken: true,
60+
returnIdpCredential: true,
61+
},
62+
}
63+
);
64+
65+
expect(response.ok(), await response.text()).toBe(true);
66+
}
67+
68+
/**
69+
* The Auth emulator widget marks every OAuth email as verified. Its backend then silently merges
70+
* providers with matching emails instead of returning account-exists-with-different-credential.
71+
* Production providers can return an unverified email, so rewrite only the attempted provider's
72+
* emulator assertion to exercise Firebase Auth's real conflict response and credential payload.
73+
*/
74+
async function forceUnverifiedProviderEmail(page: Page, providerId: string): Promise<void> {
75+
await page.route("**/accounts:signInWithIdp?*", async (route) => {
76+
const request = route.request();
77+
const body = request.postDataJSON() as { requestUri: string };
78+
const requestUri = new URL(body.requestUri);
79+
80+
if (requestUri.searchParams.get("providerId") === providerId) {
81+
const idToken = requestUri.searchParams.get("id_token");
82+
if (idToken) {
83+
const claims = JSON.parse(idToken) as { email_verified?: boolean };
84+
claims.email_verified = false;
85+
requestUri.searchParams.set("id_token", JSON.stringify(claims));
86+
body.requestUri = requestUri.toString();
87+
}
88+
}
89+
90+
await route.continue({ postData: JSON.stringify(body) });
91+
});
92+
}
93+
94+
async function completeNewProviderSignIn(
95+
page: Page,
96+
buttonName: string,
97+
emulatorProviderName: string,
98+
email: string
99+
): Promise<void> {
100+
const popupPromise = page.waitForEvent("popup");
101+
await page.getByRole("button", { name: buttonName, exact: true }).click();
102+
const popup = await popupPromise;
103+
104+
const addAccountButton = popup.getByRole("button", { name: "Add new account" });
105+
await expect(addAccountButton).toBeVisible();
106+
const emailInput = popup.locator("#email-input");
107+
await expect(async () => {
108+
await addAccountButton.click();
109+
await expect(emailInput).toBeVisible({ timeout: 1_000 });
110+
}).toPass({ timeout: 10_000 });
111+
112+
await emailInput.fill(email);
113+
await popup.locator("#display-name-input").fill("Playwright Recovery User");
114+
await Promise.all([
115+
popup.waitForEvent("close"),
116+
popup.getByRole("button", { name: new RegExp(`Sign in with ${emulatorProviderName}`, "i") }).click(),
117+
]);
118+
}
119+
120+
async function completeExistingGoogleSignIn(page: Page, dialog: Locator, email: string): Promise<void> {
121+
const popupPromise = page.waitForEvent("popup");
122+
await dialog.getByRole("button", { name: enUs.translations.labels.signInWithGoogle }).click();
123+
const popup = await popupPromise;
124+
125+
const existingAccount = popup.getByText(email, { exact: true });
126+
await expect(existingAccount).toBeVisible();
127+
await Promise.all([popup.waitForEvent("close"), existingAccount.click()]);
128+
}
129+
130+
async function getUsersByEmail(request: APIRequestContext, email: string): Promise<EmulatorUser[]> {
131+
const response = await request.post(
132+
`${AUTH_EMULATOR_BASE_URL}/identitytoolkit.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/accounts:lookup`,
133+
{
134+
headers: { authorization: "Bearer owner" },
135+
data: { email: [email] },
136+
}
137+
);
138+
expect(response.ok(), await response.text()).toBe(true);
139+
140+
const body = (await response.json()) as { users?: EmulatorUser[] };
141+
return body.users ?? [];
142+
}
143+
144+
for (const projectName of projectsUnderTest) {
145+
test.describe(`legacy sign-in recovery (${projectName})`, () => {
146+
test.describe.configure({ timeout: 90_000 });
147+
148+
test.beforeEach(async ({ request }, testInfo) => {
149+
test.skip(testInfo.project.name !== projectName, `runs only on the ${projectName} project`);
150+
await clearEmulatorUsers(request);
151+
});
152+
153+
test("shows previous methods and links the pending OAuth credential", async ({ page, request }) => {
154+
const email = uniqueEmail(projectName, "default");
155+
await createGoogleAccount(request, email);
156+
await forceUnverifiedProviderEmail(page, "github.com");
157+
158+
await page.goto(RECOVERY_PATH);
159+
await completeNewProviderSignIn(page, enUs.translations.labels.signInWithGitHub, "GitHub.com", email);
160+
161+
const dialog = page.getByRole("dialog", {
162+
name: enUs.translations.messages.legacySignInRecoverySelectMethod,
163+
});
164+
await expect(dialog).toBeVisible();
165+
await expect(dialog).toContainText(email);
166+
await expect(dialog.getByRole("button", { name: enUs.translations.labels.signInWithGoogle })).toBeVisible();
167+
await expect(page.getByText(enUs.translations.errors.accountExistsWithDifferentCredential)).toBeVisible();
168+
// "pendingCred" mirrors PENDING_CREDENTIAL_STORAGE_KEY in packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts.
169+
// Left as a literal here since page.evaluate runs in the browser context and can't import from the package.
170+
expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).not.toBeNull();
171+
172+
await completeExistingGoogleSignIn(page, dialog, email);
173+
174+
await expect(dialog).toHaveCount(0);
175+
await expect.poll(() => page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).toBeNull();
176+
177+
await expect
178+
.poll(async () => {
179+
const users = await getUsersByEmail(request, email);
180+
return {
181+
count: users.length,
182+
providers: users[0]?.providerUserInfo?.map(({ providerId }) => providerId).sort(),
183+
};
184+
})
185+
.toEqual({ count: 1, providers: ["github.com", "google.com"] });
186+
});
187+
188+
test("supports custom recovery UI and clears it when dismissed", async ({ page, request }) => {
189+
const email = uniqueEmail(projectName, "handled");
190+
await createGoogleAccount(request, email);
191+
await forceUnverifiedProviderEmail(page, "github.com");
192+
193+
await page.goto(`${RECOVERY_PATH}?legacyRecovery=handled`);
194+
await completeNewProviderSignIn(page, enUs.translations.labels.signInWithGitHub, "GitHub.com", email);
195+
196+
await expect(page.getByRole("dialog")).toHaveCount(0);
197+
const customRecovery = page.getByTestId("custom-legacy-recovery");
198+
await expect(customRecovery).toBeVisible();
199+
await expect(page.getByTestId("custom-legacy-recovery-email")).toHaveText(email);
200+
await expect(page.getByTestId("custom-legacy-recovery-methods")).toContainText("google.com");
201+
// "pendingCred" mirrors PENDING_CREDENTIAL_STORAGE_KEY in packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts.
202+
// Left as a literal here since page.evaluate runs in the browser context and can't import from the package.
203+
expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).not.toBeNull();
204+
205+
await page.getByRole("button", { name: "Custom dismiss" }).click();
206+
await expect(customRecovery).toHaveCount(0);
207+
// clearLegacySignInRecovery() removes the pending credential synchronously, so no polling
208+
// is needed here (contrast with the completeExistingGoogleSignIn case above, which clears
209+
// it via an async sign-in flow).
210+
expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).toBeNull();
211+
});
212+
});
213+
}

examples/angular/src/app/app.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { provideClientHydration, withEventReplay } from "@angular/platform-brows
2323
import { provideFirebaseApp, initializeApp } from "@angular/fire/app";
2424
import { provideAuth, getAuth, connectAuthEmulator } from "@angular/fire/auth";
2525
import { provideFirebaseUI, provideFirebaseUIPolicies } from "@firebase-oss/ui-angular";
26-
import { initializeUI } from "@firebase-oss/ui-core";
26+
import { initializeUI, legacyFetchSignInWithEmail } from "@firebase-oss/ui-core";
2727

2828
const firebaseConfig = {
2929
apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I",
@@ -48,7 +48,7 @@ export const appConfig: ApplicationConfig = {
4848
}
4949
return auth;
5050
}),
51-
provideFirebaseUI((apps) => initializeUI({ app: apps[0] })),
51+
provideFirebaseUI((apps) => initializeUI({ app: apps[0], behaviors: [legacyFetchSignInWithEmail()] })),
5252
provideFirebaseUIPolicies(() => ({
5353
termsOfServiceUrl: "https://www.google.com",
5454
privacyPolicyUrl: "https://www.google.com",

0 commit comments

Comments
 (0)