Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
0aeb8d8
feat: inital implementation for setting up fetchsigninwithemail behav…
russellwheatley Mar 23, 2026
bf8c271
test: test the new legacySignInWithEmail behaviour
russellwheatley Mar 23, 2026
8af2a9f
chore: pnpm-lock
russellwheatley Mar 23, 2026
b4be9b4
test: legacy-sign-in component
russellwheatley Mar 23, 2026
4c27682
docs: update README with legacyFetchSignInWithEmail behaviour
russellwheatley Mar 23, 2026
0e6bed5
chore: remove dev dependency from example
russellwheatley Mar 23, 2026
5977d79
feat: increase errors that trigger recovery flow
russellwheatley Mar 23, 2026
65004db
chore: update react example app with latest flow
russellwheatley Mar 23, 2026
c171e76
chore: update base css styles
russellwheatley Mar 23, 2026
b35738d
test: numerous tests for updates to recovery flow
russellwheatley Mar 23, 2026
e9d33c2
docs: recovery flow is now a modal
russellwheatley Mar 23, 2026
d64a8cd
Merge branch 'main' into fetchsigninwithemail
russellwheatley Mar 26, 2026
3d62f32
chore: format
russellwheatley Mar 26, 2026
36799f1
chore: format
russellwheatley Apr 2, 2026
b09ce8d
chore: format
russellwheatley Apr 2, 2026
13d3183
chore: update license years to 2026
russellwheatley Apr 2, 2026
0bd5bff
fix: clear legacy sign-in recovery only on success, not on attempt start
russellwheatley Jul 15, 2026
80ee026
refactor: share legacy sign-in recovery error codes between errors.ts…
russellwheatley Jul 15, 2026
f91546c
Merge remote-tracking branch 'origin/main' into fetchsigninwithemail
russellwheatley Jul 15, 2026
4206ba2
refactor(angular): remove redundant LegacySignInRecovery type + cast …
russellwheatley Jul 15, 2026
ceb9ad4
fix(auth): preserve OAuth credential for legacy recovery
russellwheatley Jul 16, 2026
49aa6b4
fix(core): stop opening legacy sign-in recovery UI with nothing actio…
russellwheatley Jul 16, 2026
a5b9c11
refactor(core): extract shared PENDING_CREDENTIAL_STORAGE_KEY constant
russellwheatley Jul 16, 2026
8b9e754
docs: document Email Enumeration Protection trade-off for legacyFetch…
russellwheatley Jul 16, 2026
8663dea
fix(react,angular): never re-offer the just-attempted provider in rec…
russellwheatley Jul 16, 2026
a693f2f
docs(core): document plaintext sessionStorage trade-off for pending c…
russellwheatley Jul 16, 2026
221e16c
test(core): add regression coverage for silent error-swallowing in au…
russellwheatley Jul 16, 2026
9ef456b
fix(react,angular): disable legacy sign-in recovery UI by default
russellwheatley Jul 17, 2026
21714c7
docs(core): expand legacyFetchSignInWithEmail JSDoc with migration gu…
russellwheatley Jul 17, 2026
76eee01
docs(examples): clarify email enumeration protection requirement in l…
russellwheatley Jul 17, 2026
5252084
docs(examples): align legacy recovery demo copy with email enumeratio…
russellwheatley Jul 17, 2026
6fba47f
Merge branch 'main' into fetchsigninwithemail
russellwheatley Jul 20, 2026
051be47
fix(core): clear pending OAuth credential from sessionStorage on reco…
russellwheatley Jul 22, 2026
3afc747
fix(core): guard against stale legacyFetchSignInWithEmail fetch races
russellwheatley Jul 22, 2026
e54e457
fix(i18n): extract hardcoded 'Account Found' recovery label into tran…
russellwheatley Jul 22, 2026
40f493b
test(e2e): assert pendingCred is cleared on custom recovery dismiss
russellwheatley Jul 22, 2026
3d60bd9
fix(core): read pending credential before clearing legacy recovery state
russellwheatley Jul 23, 2026
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
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,61 @@ const ui = initializeUI({
});
```

#### `legacyFetchSignInWithEmail`

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.

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.

> **⚠️ 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.

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.

```ts
import { legacyFetchSignInWithEmail } from '@firebase-oss/ui-core';

const ui = initializeUI({
app,
behaviors: [legacyFetchSignInWithEmail()],
});
```

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()`:

```tsx
import { GitHubSignInButton, GoogleSignInButton, SignInAuthScreen, useLegacySignInRecovery } from '@firebase-oss/ui-react';

function WrongProviderRecovery() {
const { recovery, clearRecovery } = useLegacySignInRecovery();

if (!recovery) {
return null;
}

return (
<div>
<p>You have previously signed in with a different method for {recovery.email}.</p>
{recovery.signInMethods.includes('google.com') && (
<GoogleSignInButton onSignIn={clearRecovery} />
)}
{recovery.signInMethods.includes('github.com') && (
<GitHubSignInButton onSignIn={clearRecovery} />
)}
</div>
);
}

export function CustomSignInScreen() {
return (
<SignInAuthScreen showLegacySignInRecovery={false}>
<WrongProviderRecovery />
</SignInAuthScreen>
);
}
```

Angular apps can hide the built-in recovery UI with `showLegacySignInRecovery="false"` and read the same state with `injectLegacySignInRecovery()` / `injectClearLegacySignInRecovery()`.

#### `oneTapSignIn`

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

**`SignUpAuthScreen`**

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

**`OAuthButton`**

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

**`LegacySignInRecovery`**

Default component for displaying suggested previous sign-in methods from `legacyFetchSignInWithEmail`.

**`Card`**

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

Returns `string | undefined`.

**`useLegacySignInRecovery`**

Gets the legacy sign-in recovery state populated by `legacyFetchSignInWithEmail`.

Returns `{ recovery: LegacySignInRecovery | undefined; clearRecovery: () => void }`.

**`useSignInAuthFormSchema`**

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

Screen component for email/password sign-in.

| Input | Type | Description |
|-------|:----:|-------------|
| showLegacySignInRecovery | `boolean` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |

| Output | Type | Description |
|--------|:----:|-------------|
| signIn | `EventEmitter<User>` | Emitted when sign-in succeeds |
Expand Down Expand Up @@ -1754,6 +1825,10 @@ By default, any missing translations will fallback to English if not specified.

Screen component for OAuth provider sign-in.

| Input | Type | Description |
|-------|:----:|-------------|
| showLegacySignInRecovery | `boolean` | Whether to show the built-in legacy sign-in recovery UI (defaults to `false`) |

| Output | Type | Description |
|--------|:----:|-------------|
| onSignIn | `EventEmitter<User>` | Emitted when OAuth sign-in succeeds |
Expand Down Expand Up @@ -1904,6 +1979,12 @@ By default, any missing translations will fallback to English if not specified.

Component that displays redirect errors from Firebase UI authentication flow.

**`LegacySignInRecoveryComponent`**

Selector: `fui-legacy-sign-in-recovery`

Default component for displaying suggested previous sign-in methods from `legacyFetchSignInWithEmail`.

**`ContentComponent`**

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

Returns `Signal<string \| undefined>`.

**`injectLegacySignInRecovery`**

Injects the legacy sign-in recovery state from the UI store as a signal.

Returns `Signal<LegacySignInRecovery \| undefined>`.

**`injectClearLegacySignInRecovery`**

Injects a callback that clears the current legacy sign-in recovery state.

Returns `() => void`.

**`injectTranslation`**

Injects a translated string for a given category and key.
Expand Down
213 changes: 213 additions & 0 deletions e2e/tests/legacy-sign-in-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* Copyright 2026 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 { APIRequestContext, Locator, Page } from "@playwright/test";
import { enUs } from "@firebase-oss/ui-translations";
import { expect, test } from "../fixtures/test-harness";

const AUTH_EMULATOR_BASE_URL = "http://127.0.0.1:9099";
const FIREBASE_PROJECT_ID = "demo-test";
const RECOVERY_PATH = "/screens/legacy-recovery-demo";

const projectsUnderTest = ["react", "angular-example"] as const;

type EmulatorUser = {
localId: string;
providerUserInfo?: Array<{ providerId: string }>;
};

function uniqueEmail(projectName: string, label: string): string {
return `${projectName}-${label}-${crypto.randomUUID()}@example.test`;
}

async function clearEmulatorUsers(request: APIRequestContext): Promise<void> {
const response = await request.delete(
`${AUTH_EMULATOR_BASE_URL}/emulator/v1/projects/${FIREBASE_PROJECT_ID}/accounts`
);
expect(response.ok(), await response.text()).toBe(true);
}

async function createGoogleAccount(request: APIRequestContext, email: string): Promise<void> {
const claims = {
sub: crypto.randomUUID(),
name: "Playwright Recovery User",
email,
email_verified: true,
};
const requestUri =
`${AUTH_EMULATOR_BASE_URL}/emulator/auth/handler?providerId=google.com&id_token=` +
encodeURIComponent(JSON.stringify(claims));
const response = await request.post(
`${AUTH_EMULATOR_BASE_URL}/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=fake-api-key`,
{
data: {
requestUri,
sessionId: "ValueNotUsedByAuthEmulator",
returnSecureToken: true,
returnIdpCredential: true,
},
}
);

expect(response.ok(), await response.text()).toBe(true);
}

/**
* The Auth emulator widget marks every OAuth email as verified. Its backend then silently merges
* providers with matching emails instead of returning account-exists-with-different-credential.
* Production providers can return an unverified email, so rewrite only the attempted provider's
* emulator assertion to exercise Firebase Auth's real conflict response and credential payload.
*/
async function forceUnverifiedProviderEmail(page: Page, providerId: string): Promise<void> {
await page.route("**/accounts:signInWithIdp?*", async (route) => {
const request = route.request();
const body = request.postDataJSON() as { requestUri: string };
const requestUri = new URL(body.requestUri);

if (requestUri.searchParams.get("providerId") === providerId) {
const idToken = requestUri.searchParams.get("id_token");
if (idToken) {
const claims = JSON.parse(idToken) as { email_verified?: boolean };
claims.email_verified = false;
requestUri.searchParams.set("id_token", JSON.stringify(claims));
body.requestUri = requestUri.toString();
}
}

await route.continue({ postData: JSON.stringify(body) });
});
}

async function completeNewProviderSignIn(
page: Page,
buttonName: string,
emulatorProviderName: string,
email: string
): Promise<void> {
const popupPromise = page.waitForEvent("popup");
await page.getByRole("button", { name: buttonName, exact: true }).click();
const popup = await popupPromise;

const addAccountButton = popup.getByRole("button", { name: "Add new account" });
await expect(addAccountButton).toBeVisible();
const emailInput = popup.locator("#email-input");
await expect(async () => {
await addAccountButton.click();
await expect(emailInput).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 10_000 });

await emailInput.fill(email);
await popup.locator("#display-name-input").fill("Playwright Recovery User");
await Promise.all([
popup.waitForEvent("close"),
popup.getByRole("button", { name: new RegExp(`Sign in with ${emulatorProviderName}`, "i") }).click(),
]);
}

async function completeExistingGoogleSignIn(page: Page, dialog: Locator, email: string): Promise<void> {
const popupPromise = page.waitForEvent("popup");
await dialog.getByRole("button", { name: enUs.translations.labels.signInWithGoogle }).click();
const popup = await popupPromise;

const existingAccount = popup.getByText(email, { exact: true });
await expect(existingAccount).toBeVisible();
await Promise.all([popup.waitForEvent("close"), existingAccount.click()]);
}

async function getUsersByEmail(request: APIRequestContext, email: string): Promise<EmulatorUser[]> {
const response = await request.post(
`${AUTH_EMULATOR_BASE_URL}/identitytoolkit.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/accounts:lookup`,
{
headers: { authorization: "Bearer owner" },
data: { email: [email] },
}
);
expect(response.ok(), await response.text()).toBe(true);

const body = (await response.json()) as { users?: EmulatorUser[] };
return body.users ?? [];
}

for (const projectName of projectsUnderTest) {
test.describe(`legacy sign-in recovery (${projectName})`, () => {
test.describe.configure({ timeout: 90_000 });

test.beforeEach(async ({ request }, testInfo) => {
test.skip(testInfo.project.name !== projectName, `runs only on the ${projectName} project`);
await clearEmulatorUsers(request);
});

test("shows previous methods and links the pending OAuth credential", async ({ page, request }) => {
const email = uniqueEmail(projectName, "default");
await createGoogleAccount(request, email);
await forceUnverifiedProviderEmail(page, "github.com");

await page.goto(RECOVERY_PATH);
await completeNewProviderSignIn(page, enUs.translations.labels.signInWithGitHub, "GitHub.com", email);

const dialog = page.getByRole("dialog", {
name: enUs.translations.messages.legacySignInRecoverySelectMethod,
});
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(email);
await expect(dialog.getByRole("button", { name: enUs.translations.labels.signInWithGoogle })).toBeVisible();
await expect(page.getByText(enUs.translations.errors.accountExistsWithDifferentCredential)).toBeVisible();
// "pendingCred" mirrors PENDING_CREDENTIAL_STORAGE_KEY in packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts.
// Left as a literal here since page.evaluate runs in the browser context and can't import from the package.
expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).not.toBeNull();

await completeExistingGoogleSignIn(page, dialog, email);

await expect(dialog).toHaveCount(0);
await expect.poll(() => page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).toBeNull();

await expect
.poll(async () => {
const users = await getUsersByEmail(request, email);
return {
count: users.length,
providers: users[0]?.providerUserInfo?.map(({ providerId }) => providerId).sort(),
};
})
.toEqual({ count: 1, providers: ["github.com", "google.com"] });
});

test("supports custom recovery UI and clears it when dismissed", async ({ page, request }) => {
const email = uniqueEmail(projectName, "handled");
await createGoogleAccount(request, email);
await forceUnverifiedProviderEmail(page, "github.com");

await page.goto(`${RECOVERY_PATH}?legacyRecovery=handled`);
await completeNewProviderSignIn(page, enUs.translations.labels.signInWithGitHub, "GitHub.com", email);

await expect(page.getByRole("dialog")).toHaveCount(0);
const customRecovery = page.getByTestId("custom-legacy-recovery");
await expect(customRecovery).toBeVisible();
await expect(page.getByTestId("custom-legacy-recovery-email")).toHaveText(email);
await expect(page.getByTestId("custom-legacy-recovery-methods")).toContainText("google.com");
// "pendingCred" mirrors PENDING_CREDENTIAL_STORAGE_KEY in packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts.
// Left as a literal here since page.evaluate runs in the browser context and can't import from the package.
expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).not.toBeNull();

await page.getByRole("button", { name: "Custom dismiss" }).click();
await expect(customRecovery).toHaveCount(0);
// clearLegacySignInRecovery() removes the pending credential synchronously, so no polling
// is needed here (contrast with the completeExistingGoogleSignIn case above, which clears
// it via an async sign-in flow).
expect(await page.evaluate(() => window.sessionStorage.getItem("pendingCred"))).toBeNull();
});
});
}
4 changes: 2 additions & 2 deletions examples/angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { provideClientHydration, withEventReplay } from "@angular/platform-brows
import { provideFirebaseApp, initializeApp } from "@angular/fire/app";
import { provideAuth, getAuth, connectAuthEmulator } from "@angular/fire/auth";
import { provideFirebaseUI, provideFirebaseUIPolicies } from "@firebase-oss/ui-angular";
import { initializeUI } from "@firebase-oss/ui-core";
import { initializeUI, legacyFetchSignInWithEmail } from "@firebase-oss/ui-core";

const firebaseConfig = {
apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I",
Expand All @@ -48,7 +48,7 @@ export const appConfig: ApplicationConfig = {
}
return auth;
}),
provideFirebaseUI((apps) => initializeUI({ app: apps[0] })),
provideFirebaseUI((apps) => initializeUI({ app: apps[0], behaviors: [legacyFetchSignInWithEmail()] })),
provideFirebaseUIPolicies(() => ({
termsOfServiceUrl: "https://www.google.com",
privacyPolicyUrl: "https://www.google.com",
Expand Down
Loading