diff --git a/README.md b/README.md index 6f950c3db..1d46311eb 100644 --- a/README.md +++ b/README.md @@ -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 ( +
+

You have previously signed in with a different method for {recovery.email}.

+ {recovery.signInMethods.includes('google.com') && ( + + )} + {recovery.signInMethods.includes('github.com') && ( + + )} +
+ ); +} + +export function CustomSignInScreen() { + return ( + + + + ); +} +``` + +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. @@ -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`** @@ -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`** @@ -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. @@ -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. @@ -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` | Emitted when sign-in succeeds | @@ -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` | Emitted when OAuth sign-in succeeds | @@ -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` @@ -1922,6 +2003,18 @@ By default, any missing translations will fallback to English if not specified. Returns `Signal`. + **`injectLegacySignInRecovery`** + + Injects the legacy sign-in recovery state from the UI store as a signal. + + Returns `Signal`. + + **`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. diff --git a/e2e/tests/legacy-sign-in-recovery.spec.ts b/e2e/tests/legacy-sign-in-recovery.spec.ts new file mode 100644 index 000000000..ae536e1c2 --- /dev/null +++ b/e2e/tests/legacy-sign-in-recovery.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + }); + }); +} diff --git a/examples/angular/src/app/app.config.ts b/examples/angular/src/app/app.config.ts index 69676139b..05d698804 100644 --- a/examples/angular/src/app/app.config.ts +++ b/examples/angular/src/app/app.config.ts @@ -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", @@ -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", diff --git a/examples/angular/src/app/routes.ts b/examples/angular/src/app/routes.ts index fcafffd4a..e06a22b98 100644 --- a/examples/angular/src/app/routes.ts +++ b/examples/angular/src/app/routes.ts @@ -44,6 +44,13 @@ export const routes: RouteConfig[] = [ loadComponent: () => import("./screens/sign-in-auth-screen-w-oauth").then((m) => m.SignInAuthScreenWithOAuthComponent), }, + { + name: "Legacy Recovery Demo", + description: + "Use this screen to test wrong-provider recovery for email/password and OAuth attempts in a project that has email enumeration protection disabled.", + path: "/screens/legacy-recovery-demo", + loadComponent: () => import("./screens/legacy-recovery-demo").then((m) => m.LegacyRecoveryDemoComponent), + }, { name: "Sign Up Screen", description: "A sign up screen with email and password.", diff --git a/examples/angular/src/app/screens/legacy-recovery-demo.ts b/examples/angular/src/app/screens/legacy-recovery-demo.ts new file mode 100644 index 000000000..df56b5c2a --- /dev/null +++ b/examples/angular/src/app/screens/legacy-recovery-demo.ts @@ -0,0 +1,111 @@ +/** + * 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 { Component, inject } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + SignInAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + YahooSignInButtonComponent, + injectLegacySignInRecovery, + injectClearLegacySignInRecovery, +} from "@firebase-oss/ui-angular"; +import { ActivatedRoute, Router } from "@angular/router"; + +@Component({ + selector: "app-legacy-recovery-demo", + standalone: true, + imports: [ + CommonModule, + SignInAuthScreenComponent, + ContentComponent, + GoogleSignInButtonComponent, + FacebookSignInButtonComponent, + AppleSignInButtonComponent, + GitHubSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + YahooSignInButtonComponent, + ], + template: ` +
+

Legacy recovery demo

+

+ Use this screen to test wrong-provider recovery with both email/password and OAuth attempts, in a project that + has email enumeration protection disabled. +

+

+ Suggested flow: create an account with Google first, sign out, then come back here and try the same email with + email/password or another provider like GitHub. +

+
+ + + + + + + + + + + + + + @if (handled && recovery()) { +
+

Custom recovery UI

+

+ Previous sign-in methods for + {{ recovery()!.email }}: + {{ recovery()!.signInMethods.join(", ") }} +

+ +
+ } + `, + styles: [], +}) +/** + * Demo screen combining email/password and OAuth sign-in, used to exercise the + * `legacyFetchSignInWithEmail` recovery flow in e2e tests. + */ +export class LegacyRecoveryDemoComponent { + private router = inject(Router); + private route = inject(ActivatedRoute); + + recovery = injectLegacySignInRecovery(); + private clearLegacyRecovery = injectClearLegacySignInRecovery(); + + // e2e scenario switch: "handled" suppresses the default recovery modal in favor of the custom + // UI above, proving apps can opt out of the built-in flow. + handled = this.route.snapshot.queryParamMap.get("legacyRecovery") === "handled"; + + onSignIn() { + this.router.navigate(["/"]); + } + + clearRecovery() { + this.clearLegacyRecovery(); + } +} diff --git a/examples/react/src/firebase/firebase.ts b/examples/react/src/firebase/firebase.ts index e0132cda3..ac1996822 100644 --- a/examples/react/src/firebase/firebase.ts +++ b/examples/react/src/firebase/firebase.ts @@ -16,7 +16,7 @@ "use client"; -import { countryCodes, initializeUI, oneTapSignIn } from "@firebase-oss/ui-core"; +import { countryCodes, initializeUI, legacyFetchSignInWithEmail, oneTapSignIn } from "@firebase-oss/ui-core"; import { getApps, initializeApp } from "firebase/app"; import { connectAuthEmulator, getAuth } from "firebase/auth"; @@ -30,6 +30,7 @@ export const ui = initializeUI({ app: firebaseApp, behaviors: [ // autoAnonymousLogin(), + legacyFetchSignInWithEmail(), oneTapSignIn({ clientId: "616577669988-led6l3rqek9ckn9t1unj4l8l67070fhp.apps.googleusercontent.com", }), diff --git a/examples/react/src/routes.ts b/examples/react/src/routes.ts index f46f72903..5e92925fc 100644 --- a/examples/react/src/routes.ts +++ b/examples/react/src/routes.ts @@ -1,6 +1,7 @@ import SignInAuthScreenPage from "./screens/sign-in-auth-screen"; import SignInAuthScreenWithHandlersPage from "./screens/sign-in-auth-screen-w-handlers"; import SignInAuthScreenWithOAuthPage from "./screens/sign-in-auth-screen-w-oauth"; +import LegacyRecoveryDemoPage from "./screens/legacy-recovery-demo"; import SignUpAuthScreenPage from "./screens/sign-up-auth-screen"; import SignUpAuthScreenWithHandlersPage from "./screens/sign-up-auth-screen-w-handlers"; import SignUpAuthScreenWithOAuthPage from "./screens/sign-up-auth-screen-w-oauth"; @@ -32,6 +33,13 @@ export const routes = [ path: "/screens/sign-in-auth-screen-w-oauth", component: SignInAuthScreenWithOAuthPage, }, + { + name: "Legacy Recovery Demo", + description: + "Use this screen to test wrong-provider recovery for email/password and OAuth attempts in a project that has email enumeration protection disabled.", + path: "/screens/legacy-recovery-demo", + component: LegacyRecoveryDemoPage, + }, { name: "Sign Up Screen", description: "A sign up screen with email and password.", diff --git a/examples/react/src/screens/legacy-recovery-demo.tsx b/examples/react/src/screens/legacy-recovery-demo.tsx new file mode 100644 index 000000000..1c224aafb --- /dev/null +++ b/examples/react/src/screens/legacy-recovery-demo.tsx @@ -0,0 +1,97 @@ +/** + * 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 { + AppleSignInButton, + FacebookSignInButton, + GitHubSignInButton, + GoogleSignInButton, + MicrosoftSignInButton, + SignInAuthScreen, + TwitterSignInButton, + YahooSignInButton, + useLegacySignInRecovery, +} from "@firebase-oss/ui-react"; +import { useNavigate, useSearchParams } from "react-router"; + +/** + * A minimal custom recovery UI, used by the `?legacyRecovery=handled` e2e scenario to prove that + * apps can suppress the default `` modal (via `showLegacySignInRecovery={false}`) + * and build their own UI on top of `useLegacySignInRecovery()`. + */ +function CustomLegacyRecovery() { + const { recovery, clearRecovery } = useLegacySignInRecovery(); + + if (!recovery) { + return null; + } + + return ( +
+

Custom recovery UI

+

+ Previous sign-in methods for {recovery.email}:{" "} + {recovery.signInMethods.join(", ")} +

+ +
+ ); +} + +export default function LegacyRecoveryDemoPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + // e2e scenario switch: "handled" suppresses the default recovery modal in favor of the custom + // UI above, proving apps can opt out of the built-in flow. + const handled = searchParams.get("legacyRecovery") === "handled"; + + return ( +
+
+

Legacy recovery demo

+

+ Use this screen to test wrong-provider recovery with both email/password and OAuth attempts, in a project that + has email enumeration protection disabled. +

+

+ Suggested flow: create an account with Google first, sign out, then come back here and try the same email with + email/password or another provider like GitHub. +

+
+ + { + navigate("/"); + }} + showLegacySignInRecovery={!handled} + > +
+ + + + + + + +
+
+ + {handled ? : null} +
+ ); +} diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts index 9a9578775..358d98030 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.spec.ts @@ -31,6 +31,32 @@ import { import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { ContentComponent } from "../../components/content"; +import { LegacySignInRecoveryComponent } from "../../components/legacy-sign-in-recovery"; + +jest.mock("@angular/fire/auth", () => { + const actual = jest.requireActual("@angular/fire/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + providerId = "google.com"; + }, + GithubAuthProvider: class GithubAuthProvider { + providerId = "github.com"; + }, + FacebookAuthProvider: class FacebookAuthProvider { + providerId = "facebook.com"; + }, + TwitterAuthProvider: class TwitterAuthProvider { + providerId = "twitter.com"; + }, + OAuthProvider: class OAuthProvider { + providerId: string; + constructor(providerId: string) { + this.providerId = providerId; + } + }, + }; +}); jest.mock("../../../provider", () => ({ injectTranslation: jest.fn(), @@ -54,6 +80,13 @@ class MockPoliciesComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-legacy-sign-in-recovery", + template: '
Legacy Recovery
', + standalone: true, +}) +class MockLegacySignInRecoveryComponent {} + @Component({ template: ` @@ -84,6 +117,20 @@ class TestHostWithMultipleProvidersComponent {} }) class TestHostWithoutContentComponent {} +@Component({ + template: ``, + standalone: true, + imports: [OAuthScreenComponent], +}) +class TestHostWithRecoveryComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [OAuthScreenComponent], +}) +class TestHostWithoutRecoveryComponent {} + @Component({ selector: "fui-multi-factor-auth-assertion-screen", template: '
MFA Assertion Screen
', @@ -146,6 +193,15 @@ describe("", () => { multiFactorResolver: null, }); }); + + TestBed.overrideComponent(OAuthScreenComponent, { + remove: { + imports: [LegacySignInRecoveryComponent], + }, + add: { + imports: [MockLegacySignInRecoveryComponent], + }, + }); }); afterEach(() => { @@ -161,6 +217,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -181,6 +238,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -201,6 +259,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -222,6 +281,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -261,12 +321,73 @@ describe("", () => { expect(redirectErrorElement).toBeInTheDocument(); }); + it("does not render legacy recovery by default", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).not.toBeInTheDocument(); + }); + + it("renders legacy recovery when explicitly enabled", async () => { + const { container } = await render(TestHostWithRecoveryComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).toBeInTheDocument(); + }); + + it("does not render legacy recovery when explicitly disabled", async () => { + const { container } = await render(TestHostWithoutRecoveryComponent, { + imports: [ + OAuthScreenComponent, + MockPoliciesComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).not.toBeInTheDocument(); + }); + it("has correct CSS classes", async () => { const { container } = await render(TestHostWithoutContentComponent, { imports: [ OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -292,6 +413,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -325,6 +447,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -358,6 +481,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -391,6 +515,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -428,6 +553,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -465,6 +591,7 @@ describe("", () => { OAuthScreenComponent, MockPoliciesComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, diff --git a/packages/angular/src/lib/auth/screens/oauth-screen.ts b/packages/angular/src/lib/auth/screens/oauth-screen.ts index d12851600..fe24994b0 100644 --- a/packages/angular/src/lib/auth/screens/oauth-screen.ts +++ b/packages/angular/src/lib/auth/screens/oauth-screen.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Component, computed, Output, EventEmitter } from "@angular/core"; +import { Component, computed, Output, EventEmitter, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { CardComponent, @@ -27,6 +27,7 @@ import { injectTranslation, injectUI, injectUserAuthenticated } from "../../prov import { PoliciesComponent } from "../../components/policies"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; +import { LegacySignInRecoveryComponent } from "../../components/legacy-sign-in-recovery"; import { type User } from "@angular/fire/auth"; @Component({ @@ -45,6 +46,7 @@ import { type User } from "@angular/fire/auth"; PoliciesComponent, MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, + LegacySignInRecoveryComponent, ], template: ` @if (mfaResolver()) { @@ -59,6 +61,9 @@ import { type User } from "@angular/fire/auth";
+ @if (showLegacySignInRecovery()) { + + }
@@ -76,6 +81,8 @@ import { type User } from "@angular/fire/auth"; */ export class OAuthScreenComponent { private ui = injectUI(); + /** Whether to show the built-in legacy sign-in recovery UI. Defaults to `false`; opt in explicitly. */ + showLegacySignInRecovery = input(false); mfaResolver = computed(() => this.ui().multiFactorResolver); diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts index bed61fbfe..6d7829f28 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.spec.ts @@ -32,6 +32,32 @@ import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor import { MultiFactorAuthAssertionFormComponent } from "../forms/multi-factor-auth-assertion-form"; import { TotpMultiFactorAssertionFormComponent } from "../forms/mfa/totp-multi-factor-assertion-form"; import { TotpMultiFactorGenerator } from "firebase/auth"; +import { LegacySignInRecoveryComponent } from "../../components/legacy-sign-in-recovery"; + +jest.mock("@angular/fire/auth", () => { + const actual = jest.requireActual("@angular/fire/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + providerId = "google.com"; + }, + GithubAuthProvider: class GithubAuthProvider { + providerId = "github.com"; + }, + FacebookAuthProvider: class FacebookAuthProvider { + providerId = "facebook.com"; + }, + TwitterAuthProvider: class TwitterAuthProvider { + providerId = "twitter.com"; + }, + OAuthProvider: class OAuthProvider { + providerId: string; + constructor(providerId: string) { + this.providerId = providerId; + } + }, + }; +}); @Component({ selector: "fui-sign-in-auth-form", @@ -47,6 +73,13 @@ class MockSignInAuthFormComponent {} }) class MockRedirectErrorComponent {} +@Component({ + selector: "fui-legacy-sign-in-recovery", + template: '
Legacy Recovery
', + standalone: true, +}) +class MockLegacySignInRecoveryComponent {} + @Component({ template: ` @@ -65,6 +98,20 @@ class TestHostWithContentComponent {} }) class TestHostWithoutContentComponent {} +@Component({ + template: ``, + standalone: true, + imports: [SignInAuthScreenComponent], +}) +class TestHostWithRecoveryComponent {} + +@Component({ + template: ``, + standalone: true, + imports: [SignInAuthScreenComponent], +}) +class TestHostWithoutRecoveryComponent {} + describe("", () => { let authStateSubject: Subject; let userAuthenticatedCallback: ((user: User) => void) | null = null; @@ -106,6 +153,15 @@ describe("", () => { multiFactorResolver: null, }); }); + + TestBed.overrideComponent(SignInAuthScreenComponent, { + remove: { + imports: [LegacySignInRecoveryComponent], + }, + add: { + imports: [MockLegacySignInRecoveryComponent], + }, + }); }); afterEach(() => { @@ -121,6 +177,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -140,6 +197,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -160,6 +218,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -180,6 +239,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -193,12 +253,70 @@ describe("", () => { expect(redirectErrorElement).toBeInTheDocument(); }); + it("does not render legacy recovery by default", async () => { + const { container } = await render(TestHostWithoutContentComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).not.toBeInTheDocument(); + }); + + it("renders legacy recovery when explicitly enabled", async () => { + const { container } = await render(TestHostWithRecoveryComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).toBeInTheDocument(); + }); + + it("does not render legacy recovery when explicitly disabled", async () => { + const { container } = await render(TestHostWithoutRecoveryComponent, { + imports: [ + SignInAuthScreenComponent, + MockSignInAuthFormComponent, + MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, + MultiFactorAuthAssertionScreenComponent, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardSubtitleComponent, + CardContentComponent, + ], + }); + + expect(container.querySelector("fui-legacy-sign-in-recovery")).not.toBeInTheDocument(); + }); + it("has correct CSS classes", async () => { const { container } = await render(TestHostWithoutContentComponent, { imports: [ SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -223,6 +341,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -255,6 +374,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -287,6 +407,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -323,6 +444,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -359,6 +481,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, @@ -395,6 +518,7 @@ describe("", () => { SignInAuthScreenComponent, MockSignInAuthFormComponent, MockRedirectErrorComponent, + MockLegacySignInRecoveryComponent, MultiFactorAuthAssertionScreenComponent, CardComponent, CardHeaderComponent, diff --git a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts index 86cba026b..5786f8c75 100644 --- a/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts +++ b/packages/angular/src/lib/auth/screens/sign-in-auth-screen.ts @@ -14,13 +14,14 @@ * limitations under the License. */ -import { Component, Output, EventEmitter, computed, inject, effect } from "@angular/core"; +import { Component, Output, EventEmitter, computed, input } from "@angular/core"; import { CommonModule } from "@angular/common"; import { injectTranslation, injectUI, injectUserAuthenticated } from "../../provider"; import { SignInAuthFormComponent } from "../forms/sign-in-auth-form"; import { MultiFactorAuthAssertionScreenComponent } from "../screens/multi-factor-auth-assertion-screen"; import { RedirectErrorComponent } from "../../components/redirect-error"; +import { LegacySignInRecoveryComponent } from "../../components/legacy-sign-in-recovery"; import { CardComponent, CardHeaderComponent, @@ -45,6 +46,7 @@ import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; SignInAuthFormComponent, MultiFactorAuthAssertionScreenComponent, RedirectErrorComponent, + LegacySignInRecoveryComponent, ], template: ` @if (mfaResolver()) { @@ -58,6 +60,9 @@ import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; + @if (showLegacySignInRecovery()) { + + } @@ -73,6 +78,8 @@ import { Auth, authState, User, UserCredential } from "@angular/fire/auth"; */ export class SignInAuthScreenComponent { private ui = injectUI(); + /** Whether to show the built-in legacy sign-in recovery UI. Defaults to `false`; opt in explicitly. */ + showLegacySignInRecovery = input(false); mfaResolver = computed(() => this.ui().multiFactorResolver); titleText = injectTranslation("labels", "signIn"); diff --git a/packages/angular/src/lib/components/legacy-sign-in-recovery.spec.ts b/packages/angular/src/lib/components/legacy-sign-in-recovery.spec.ts new file mode 100644 index 000000000..fdc9a9be2 --- /dev/null +++ b/packages/angular/src/lib/components/legacy-sign-in-recovery.spec.ts @@ -0,0 +1,267 @@ +/** + * 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 { render, screen, fireEvent } from "@testing-library/angular"; +import { Component, EventEmitter } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { LegacySignInRecoveryComponent } from "./legacy-sign-in-recovery"; +import { AppleSignInButtonComponent } from "../auth/oauth/apple-sign-in-button"; +import { FacebookSignInButtonComponent } from "../auth/oauth/facebook-sign-in-button"; +import { GitHubSignInButtonComponent } from "../auth/oauth/github-sign-in-button"; +import { GoogleSignInButtonComponent } from "../auth/oauth/google-sign-in-button"; +import { MicrosoftSignInButtonComponent } from "../auth/oauth/microsoft-sign-in-button"; +import { TwitterSignInButtonComponent } from "../auth/oauth/twitter-sign-in-button"; +import { YahooSignInButtonComponent } from "../auth/oauth/yahoo-sign-in-button"; + +jest.mock("@angular/fire/auth", () => { + const actual = jest.requireActual("@angular/fire/auth"); + return { + ...actual, + GoogleAuthProvider: class GoogleAuthProvider { + providerId = "google.com"; + }, + GithubAuthProvider: class GithubAuthProvider { + providerId = "github.com"; + }, + FacebookAuthProvider: class FacebookAuthProvider { + providerId = "facebook.com"; + }, + TwitterAuthProvider: class TwitterAuthProvider { + providerId = "twitter.com"; + }, + OAuthProvider: class OAuthProvider { + providerId: string; + constructor(providerId: string) { + this.providerId = providerId; + } + }, + }; +}); + +jest.mock("../provider", () => ({ + injectClearLegacySignInRecovery: jest.fn(), + injectLegacySignInRecovery: jest.fn(), + injectTranslation: jest.fn(), + injectUI: jest.fn(), +})); + +@Component({ + template: ``, + standalone: true, + imports: [LegacySignInRecoveryComponent], +}) +class TestHostComponent {} + +@Component({ + selector: "fui-google-sign-in-button", + template: '', + standalone: true, + outputs: ["signIn"], +}) +class MockGoogleSignInButtonComponent { + signIn = new EventEmitter(); +} + +@Component({ + selector: "fui-github-sign-in-button", + template: '', + standalone: true, + outputs: ["signIn"], +}) +class MockGitHubSignInButtonComponent { + signIn = new EventEmitter(); +} + +@Component({ + selector: "fui-facebook-sign-in-button", + template: '', + standalone: true, +}) +class MockFacebookSignInButtonComponent {} + +@Component({ + selector: "fui-apple-sign-in-button", + template: '', + standalone: true, +}) +class MockAppleSignInButtonComponent {} + +@Component({ + selector: "fui-microsoft-sign-in-button", + template: '', + standalone: true, +}) +class MockMicrosoftSignInButtonComponent {} + +@Component({ + selector: "fui-twitter-sign-in-button", + template: '', + standalone: true, +}) +class MockTwitterSignInButtonComponent {} + +@Component({ + selector: "fui-yahoo-sign-in-button", + template: '', + standalone: true, +}) +class MockYahooSignInButtonComponent {} + +describe("", () => { + beforeEach(() => { + const { + injectClearLegacySignInRecovery, + injectLegacySignInRecovery, + injectTranslation, + injectUI, + } = require("../provider"); + + injectClearLegacySignInRecovery.mockReturnValue(jest.fn()); + injectLegacySignInRecovery.mockReturnValue(() => undefined); + injectUI.mockReturnValue(() => ({ + locale: { + locale: "en-US", + translations: { + messages: { + legacySignInRecoveryPrompt: "You have previously signed in with a different method for {email}.", + }, + }, + }, + state: "idle", + })); + injectTranslation.mockImplementation((category: string, key: string) => { + const mockTranslations: Record> = { + labels: { + signInWithGoogle: "Sign in with Google", + signInWithGitHub: "Sign in with GitHub", + dismiss: "Dismiss", + }, + messages: { + legacySignInRecoveryAccountFound: "Found Your Account", + legacySignInRecoveryPrompt: "You have previously signed in with a different method for {email}.", + legacySignInRecoverySelectMethod: "Choose one of your previous sign-in methods to continue.", + legacySignInRecoveryEmailPassword: "Use the email and password form to continue.", + legacySignInRecoveryEmailLink: "Use your email link sign-in flow to continue.", + }, + }; + return () => mockTranslations[category]?.[key] || `${category}.${key}`; + }); + + TestBed.overrideComponent(LegacySignInRecoveryComponent, { + remove: { + imports: [ + AppleSignInButtonComponent, + FacebookSignInButtonComponent, + GitHubSignInButtonComponent, + GoogleSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + YahooSignInButtonComponent, + ], + }, + add: { + imports: [ + MockAppleSignInButtonComponent, + MockFacebookSignInButtonComponent, + MockGitHubSignInButtonComponent, + MockGoogleSignInButtonComponent, + MockMicrosoftSignInButtonComponent, + MockTwitterSignInButtonComponent, + MockYahooSignInButtonComponent, + ], + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders nothing when there is no recovery state", async () => { + const { container } = await render(TestHostComponent); + + expect(container.querySelector(".fui-legacy-sign-in-recovery")).toBeNull(); + }); + + it("renders recovery copy and recognized provider buttons", async () => { + const { injectLegacySignInRecovery } = require("../provider"); + injectLegacySignInRecovery.mockReturnValue(() => ({ + email: "test@example.com", + signInMethods: ["google.com", "github.com", "password", "emailLink"], + })); + + await render(TestHostComponent); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect(screen.getByText("Found Your Account")).toBeDefined(); + expect( + screen.getByText("You have previously signed in with a different method for test@example.com.") + ).toBeDefined(); + expect(screen.getByText("Choose one of your previous sign-in methods to continue.")).toBeDefined(); + expect(screen.getByRole("button", { name: "Sign in with Google" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Sign in with GitHub" })).toBeDefined(); + expect(screen.getByText("Use the email and password form to continue.")).toBeDefined(); + expect(screen.getByText("Use your email link sign-in flow to continue.")).toBeDefined(); + }); + + it("never renders a button for the attempted provider, even if it appears in signInMethods", async () => { + const { injectLegacySignInRecovery } = require("../provider"); + injectLegacySignInRecovery.mockReturnValue(() => ({ + email: "test@example.com", + signInMethods: ["google.com", "github.com"], + attemptedProviderId: "github.com", + })); + + await render(TestHostComponent); + + expect(screen.getByRole("button", { name: "Sign in with Google" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "Sign in with GitHub" })).toBeNull(); + }); + + it("clears recovery state when dismissed", async () => { + const { injectLegacySignInRecovery, injectClearLegacySignInRecovery } = require("../provider"); + const clearRecovery = jest.fn(); + + injectLegacySignInRecovery.mockReturnValue(() => ({ + email: "test@example.com", + signInMethods: ["google.com"], + })); + injectClearLegacySignInRecovery.mockReturnValue(clearRecovery); + + await render(TestHostComponent); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(clearRecovery).toHaveBeenCalledTimes(1); + }); + + it("clears recovery state when the modal backdrop is clicked", async () => { + const { injectLegacySignInRecovery, injectClearLegacySignInRecovery } = require("../provider"); + const clearRecovery = jest.fn(); + + injectLegacySignInRecovery.mockReturnValue(() => ({ + email: "test@example.com", + signInMethods: ["google.com"], + })); + injectClearLegacySignInRecovery.mockReturnValue(clearRecovery); + + await render(TestHostComponent); + + fireEvent.click(screen.getByRole("dialog").parentElement as HTMLElement); + + expect(clearRecovery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/angular/src/lib/components/legacy-sign-in-recovery.ts b/packages/angular/src/lib/components/legacy-sign-in-recovery.ts new file mode 100644 index 000000000..ea5ec13eb --- /dev/null +++ b/packages/angular/src/lib/components/legacy-sign-in-recovery.ts @@ -0,0 +1,173 @@ +/** + * 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 { CommonModule } from "@angular/common"; +import { Component, HostListener } from "@angular/core"; +import { ButtonComponent } from "./button"; +import { injectClearLegacySignInRecovery, injectLegacySignInRecovery, injectTranslation } from "../provider"; +import { AppleSignInButtonComponent } from "../auth/oauth/apple-sign-in-button"; +import { FacebookSignInButtonComponent } from "../auth/oauth/facebook-sign-in-button"; +import { GitHubSignInButtonComponent } from "../auth/oauth/github-sign-in-button"; +import { GoogleSignInButtonComponent } from "../auth/oauth/google-sign-in-button"; +import { MicrosoftSignInButtonComponent } from "../auth/oauth/microsoft-sign-in-button"; +import { TwitterSignInButtonComponent } from "../auth/oauth/twitter-sign-in-button"; +import { YahooSignInButtonComponent } from "../auth/oauth/yahoo-sign-in-button"; + +@Component({ + selector: "fui-legacy-sign-in-recovery", + standalone: true, + imports: [ + CommonModule, + ButtonComponent, + AppleSignInButtonComponent, + FacebookSignInButtonComponent, + GitHubSignInButtonComponent, + GoogleSignInButtonComponent, + MicrosoftSignInButtonComponent, + TwitterSignInButtonComponent, + YahooSignInButtonComponent, + ], + host: { + style: "display: block;", + }, + template: ` + @if (recovery()) { + + } + `, +}) +/** + * Displays default recovery UI for legacy sign-in method suggestions. + */ +export class LegacySignInRecoveryComponent { + recovery = injectLegacySignInRecovery(); + private clearLegacyRecovery = injectClearLegacySignInRecovery(); + accountFoundText = injectTranslation("messages", "legacySignInRecoveryAccountFound" as never); + recoveryPromptTemplate = injectTranslation("messages", "legacySignInRecoveryPrompt" as never); + selectMethodText = injectTranslation("messages", "legacySignInRecoverySelectMethod" as never); + emailPasswordText = injectTranslation("messages", "legacySignInRecoveryEmailPassword" as never); + emailLinkText = injectTranslation("messages", "legacySignInRecoveryEmailLink" as never); + dismissText = injectTranslation("labels", "dismiss" as never); + + accountFoundLabel() { + return this.accountFoundText(); + } + + recoveryPromptLabel() { + const recovery = this.recovery(); + if (!recovery) { + return ""; + } + + return this.recoveryPromptTemplate().replace("{email}", recovery.email); + } + + selectMethodLabel() { + return this.selectMethodText(); + } + + emailPasswordLabel() { + return this.emailPasswordText(); + } + + emailLinkLabel() { + return this.emailLinkText(); + } + + dismissLabel() { + return this.dismissText(); + } + + hasMethod(method: string) { + return this.recovery()?.signInMethods.includes(method) ?? false; + } + + /** + * Whether an OAuth sign-in button should be offered for `method`. + * + * Excludes `attemptedProviderId` defensively so the recovery UI never re-offers the exact + * provider the user just failed with, even if Firebase's API were to include it in + * `signInMethods` under some edge case. + */ + canOfferMethod(method: string) { + const recovery = this.recovery(); + return (recovery?.signInMethods.includes(method) ?? false) && method !== recovery?.attemptedProviderId; + } + + clearRecovery() { + this.clearLegacyRecovery(); + } + + handleBackdropClick(event: MouseEvent) { + if (event.target === event.currentTarget) { + this.clearRecovery(); + } + } + + @HostListener("document:keydown.escape") + onEscapeKey() { + if (this.recovery()) { + this.clearRecovery(); + } + } +} diff --git a/packages/angular/src/lib/provider.spec.ts b/packages/angular/src/lib/provider.spec.ts index 2833bcf41..fffe5a232 100644 --- a/packages/angular/src/lib/provider.spec.ts +++ b/packages/angular/src/lib/provider.spec.ts @@ -15,7 +15,12 @@ import { TestBed } from "@angular/core/testing"; import { FirebaseApps } from "@angular/fire/app"; -import { injectTranslation, provideFirebaseUI } from "./provider"; +import { + injectClearLegacySignInRecovery, + injectLegacySignInRecovery, + injectTranslation, + provideFirebaseUI, +} from "./provider"; import { getTranslation, type TranslationCategory, type TranslationKey } from "@firebase-oss/ui-core"; const mockUI = { @@ -23,6 +28,11 @@ const mockUI = { locale: "en-US", translations: {}, }, + legacySignInRecovery: { + email: "test@example.com", + signInMethods: ["google.com"], + }, + clearLegacySignInRecovery: jest.fn(), }; describe("injectTranslation", () => { @@ -96,6 +106,44 @@ describe("injectTranslation", () => { }); }); +describe("legacy sign-in recovery injectors", () => { + const mockStore = { + get: () => mockUI, + subscribe: jest.fn(() => () => {}), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + TestBed.configureTestingModule({ + providers: [ + { provide: FirebaseApps, useValue: [{ name: "test-app" }] }, + provideFirebaseUI(() => mockStore as any), + ], + }); + }); + + it("returns the current legacy sign-in recovery state", () => { + TestBed.runInInjectionContext(() => { + const recovery = injectLegacySignInRecovery(); + + expect(recovery()).toEqual({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + }); + }); + + it("returns a callback that clears the recovery state", () => { + TestBed.runInInjectionContext(() => { + const clearRecovery = injectClearLegacySignInRecovery(); + clearRecovery(); + + expect(mockUI.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); + }); + }); +}); + /** * Compile-time type safety tests for TranslationCategory and TranslationKey. * diff --git a/packages/angular/src/lib/provider.ts b/packages/angular/src/lib/provider.ts index 78fe6a700..2686dbe78 100644 --- a/packages/angular/src/lib/provider.ts +++ b/packages/angular/src/lib/provider.ts @@ -49,6 +49,7 @@ import { createMultiFactorTotpAuthVerifyFormSchema, FirebaseUIStore, type FirebaseUI as FirebaseUIType, + type LegacySignInRecovery, getTranslation, getBehavior, type CountryData, @@ -481,3 +482,23 @@ export function injectRedirectError(): Signal { return redirectError instanceof Error ? redirectError.message : String(redirectError); }); } + +/** + * Injects legacy sign-in recovery data populated by the legacyFetchSignInWithEmail behavior. + * + * @returns A computed signal containing the recovery data, or undefined when no recovery is active. + */ +export function injectLegacySignInRecovery(): Signal { + const ui = injectUI(); + return computed(() => ui().legacySignInRecovery); +} + +/** + * Injects a callback for clearing legacy sign-in recovery data. + * + * @returns A function that clears the current recovery state. + */ +export function injectClearLegacySignInRecovery(): () => void { + const ui = injectUI(); + return () => ui().clearLegacySignInRecovery(); +} diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index 684bb430f..f99215fb7 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -62,6 +62,7 @@ export { export { ContentComponent } from "./lib/components/content"; export { CountrySelectorComponent } from "./lib/components/country-selector"; export { DividerComponent } from "./lib/components/divider"; +export { LegacySignInRecoveryComponent } from "./lib/components/legacy-sign-in-recovery"; export { PoliciesComponent } from "./lib/components/policies"; export { RedirectErrorComponent } from "./lib/components/redirect-error"; diff --git a/packages/core/src/auth.error-propagation.test.ts b/packages/core/src/auth.error-propagation.test.ts new file mode 100644 index 000000000..a656bd4ed --- /dev/null +++ b/packages/core/src/auth.error-propagation.test.ts @@ -0,0 +1,96 @@ +/** + * 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. + */ + +/** + * Regression coverage for the "swallowed error" bug fixed by commit 5977d79f. + * + * Every catch block in `auth.ts` used to call `handleFirebaseError(ui, error);` + * without `await`/`return`. Since `handleFirebaseError` is async and always + * throws, the rejection became an unhandled promise rejection instead of + * propagating to the caller, and the enclosing function resolved with + * `undefined` via its `finally` block. + * + * `auth.test.ts` mocks `./errors` at module scope (`vi.mock("./errors", () => + * ({ handleFirebaseError: vi.fn() }))`), so it only proves `handleFirebaseError` + * was *called* - it can never prove that the caller's promise actually + * rejects, because the mock never rejects. This file deliberately does NOT + * mock `./errors`, so the real implementation (which always throws) runs + * end-to-end. Without `return await handleFirebaseError(...)` at each call + * site, every test below would fail because the awaited call would resolve to + * `undefined` instead of rejecting. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FirebaseError } from "firebase/app"; +import { signInWithEmailAndPassword, signInWithProvider } from "./auth"; +import { FirebaseUIError } from "./errors"; +import { providerPopupStrategy } from "./behaviors"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + signInWithCredential: vi.fn(), + signInWithPopup: vi.fn(), + signInWithRedirect: vi.fn(), + EmailAuthProvider: { + credential: vi.fn((email: string, password: string) => ({ providerId: "password", email, password })), + }, +})); + +import { signInWithCredential as _signInWithCredential, signInWithPopup as _signInWithPopup } from "firebase/auth"; + +describe("auth.ts error propagation (real handleFirebaseError, no ./errors mock)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("signInWithEmailAndPassword rejects with a FirebaseUIError when the underlying sign-in call throws", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const sdkError = new FirebaseError("auth/user-not-found", "There is no user record for this identifier."); + vi.mocked(_signInWithCredential).mockRejectedValue(sdkError); + + await expect(signInWithEmailAndPassword(mockUI, email, password)).rejects.toThrow(FirebaseUIError); + + // The promise must reject with the *real* FirebaseUIError, carrying the original error code. + await expect(signInWithEmailAndPassword(mockUI, email, password)).rejects.toMatchObject({ + code: "auth/user-not-found", + }); + + // Regardless of the error, state must still settle back to idle (finally block). + expect(mockUI.setState).toHaveBeenCalledWith("idle"); + }); + + it("signInWithProvider rejects with a FirebaseUIError when the underlying popup sign-in call throws", async () => { + const mockUI = createMockUI({ + // Use the real popup strategy behavior so `getBehavior(ui, "providerSignInStrategy")` + // resolves to a genuine handler that calls the (mocked) Firebase SDK. + behaviors: providerPopupStrategy(), + }); + const provider = { providerId: "google.com" } as any; + + const sdkError = new FirebaseError("auth/operation-not-allowed", "Google sign-in is not enabled."); + vi.mocked(_signInWithPopup).mockRejectedValue(sdkError); + + await expect(signInWithProvider(mockUI, provider)).rejects.toThrow(FirebaseUIError); + + await expect(signInWithProvider(mockUI, provider)).rejects.toMatchObject({ + code: "auth/operation-not-allowed", + }); + + expect(mockUI.setState).toHaveBeenCalledWith("idle"); + }); +}); diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts index 47a6e854e..ecd171b03 100644 --- a/packages/core/src/auth.test.ts +++ b/packages/core/src/auth.test.ts @@ -33,6 +33,8 @@ import { } from "./auth"; vi.mock("firebase/auth", () => ({ + getAuth: vi.fn(), + getRedirectResult: vi.fn().mockResolvedValue(null), signInWithCredential: vi.fn(), createUserWithEmailAndPassword: vi.fn(), sendPasswordResetEmail: vi.fn(), @@ -57,10 +59,18 @@ vi.mock("firebase/auth", () => ({ linkWithCredential: vi.fn(), })); -vi.mock("./behaviors", () => ({ - hasBehavior: vi.fn(), - getBehavior: vi.fn(), -})); +vi.mock("./behaviors", async () => { + // `initializeUI` (used by the "real clearLegacySignInRecovery" regression test below) needs + // the real `defaultBehaviors` to build a working `FirebaseUI` instance - only `hasBehavior`/ + // `getBehavior` are stubbed here, since the rest of this file drives behavior selection + // directly through them. + const actual = await vi.importActual("./behaviors"); + return { + ...actual, + hasBehavior: vi.fn(), + getBehavior: vi.fn(), + }; +}); vi.mock("./errors", () => ({ handleFirebaseError: vi.fn(), @@ -86,7 +96,9 @@ import { } from "firebase/auth"; import { hasBehavior, getBehavior } from "./behaviors"; import { handleFirebaseError } from "./errors"; -import { FirebaseError } from "firebase/app"; +import { PENDING_CREDENTIAL_STORAGE_KEY } from "./behaviors/legacy-fetch-sign-in-with-email"; +import { FirebaseError, type FirebaseApp } from "firebase/app"; +import { initializeUI, type FirebaseUI } from "./config"; import { createMockUI } from "~/tests/utils"; @@ -115,6 +127,9 @@ describe("signInWithEmailAndPassword", () => { expect(_signInWithCredential).toHaveBeenCalledTimes(1); expect(result.providerId).toBe("password"); + + // Legacy recovery state is only cleared once sign-in has actually succeeded. + expect(mockUI.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); }); it("should call the autoUpgradeAnonymousCredential behavior if enabled and return a value", async () => { @@ -174,8 +189,22 @@ describe("signInWithEmailAndPassword", () => { await signInWithEmailAndPassword(mockUI, email, password); - expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(handleFirebaseError).toHaveBeenCalledWith( + mockUI, + expect.objectContaining({ + ...error, + email, + customData: { + email, + }, + }) + ); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + // A failed attempt must not clear any legacy recovery state that's currently + // guiding the user, otherwise the recovery UI would disappear before it can + // report the new failure. + expect(mockUI.clearLegacySignInRecovery).not.toHaveBeenCalled(); }); }); @@ -1044,7 +1073,7 @@ describe("handlePendingCredential", () => { it("should rehydrate an OAuth credential via OAuthProvider.credentialFromJSON and link it", async () => { const mockUI = createMockUI(); const storedJSON = { providerId: "google.com", signInMethod: "google.com", idToken: "fake-id-token" }; - window.sessionStorage.setItem("pendingCred", JSON.stringify(storedJSON)); + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, JSON.stringify(storedJSON)); const rehydratedCredential = { providerId: "google.com" } as any; const linkedUserCredential = { ...mockUserCredential, providerId: "google.com" } as UserCredential; @@ -1058,13 +1087,13 @@ describe("handlePendingCredential", () => { expect(OAuthProvider.credentialFromJSON).toHaveBeenCalledWith(storedJSON); expect(_linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, rehydratedCredential); expect(result).toBe(linkedUserCredential); - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); }); it("should fall back to SAMLAuthProvider when OAuthProvider cannot rehydrate the credential", async () => { const mockUI = createMockUI(); const storedJSON = { providerId: "saml.my-provider", signInMethod: "saml.my-provider", pendingToken: "abc" }; - window.sessionStorage.setItem("pendingCred", JSON.stringify(storedJSON)); + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, JSON.stringify(storedJSON)); const rehydratedCredential = { providerId: "saml.my-provider" } as any; const linkedUserCredential = { ...mockUserCredential, providerId: "saml.my-provider" } as UserCredential; @@ -1081,13 +1110,13 @@ describe("handlePendingCredential", () => { expect(SAMLAuthProvider.credentialFromJSON).toHaveBeenCalledWith(storedJSON); expect(_linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, rehydratedCredential); expect(result).toBe(linkedUserCredential); - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); }); it("should return the original user and clear storage when the credential cannot be rehydrated", async () => { const mockUI = createMockUI(); const storedJSON = { providerId: "unknown", signInMethod: "unknown" }; - window.sessionStorage.setItem("pendingCred", JSON.stringify(storedJSON)); + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, JSON.stringify(storedJSON)); vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential); vi.mocked(OAuthProvider.credentialFromJSON).mockImplementation(() => { @@ -1101,12 +1130,12 @@ describe("handlePendingCredential", () => { expect(_linkWithCredential).not.toHaveBeenCalled(); expect(result).toBe(mockUserCredential); - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); }); it("should return the original user and clear storage when the stored credential is invalid JSON", async () => { const mockUI = createMockUI(); - window.sessionStorage.setItem("pendingCred", "{invalid-json"); + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, "{invalid-json"); vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential); @@ -1115,13 +1144,13 @@ describe("handlePendingCredential", () => { expect(OAuthProvider.credentialFromJSON).not.toHaveBeenCalled(); expect(_linkWithCredential).not.toHaveBeenCalled(); expect(result).toBe(mockUserCredential); - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); }); it("should return the original user and clear storage when linkWithCredential fails", async () => { const mockUI = createMockUI(); const storedJSON = { providerId: "google.com", signInMethod: "google.com", idToken: "fake-id-token" }; - window.sessionStorage.setItem("pendingCred", JSON.stringify(storedJSON)); + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, JSON.stringify(storedJSON)); const rehydratedCredential = { providerId: "google.com" } as any; @@ -1135,7 +1164,63 @@ describe("handlePendingCredential", () => { expect(_linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, rehydratedCredential); expect(result).toBe(mockUserCredential); - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); + }); +}); + +describe("handlePendingCredential (regression: real clearLegacySignInRecovery)", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.sessionStorage.clear(); + }); + + afterEach(() => { + window.sessionStorage.clear(); + }); + + /** + * Uses the real `clearLegacySignInRecovery` (via `initializeUI`), instead of the + * `createMockUI()` no-op mock used by every other test in this file, so this test + * actually exercises the real sessionStorage interaction that regressed: since + * `clearLegacySignInRecovery()` also removes `PENDING_CREDENTIAL_STORAGE_KEY` (see + * `config.ts`), reading that key AFTER calling it - rather than before - would silently + * skip `linkWithCredential` on every successful sign-in that had a pending credential. + * A no-op mock can't catch this, since it never touches sessionStorage at all. + */ + function createRealUI(): FirebaseUI { + const store = initializeUI({ + app: {} as FirebaseApp, + auth: {} as Auth, + }); + return store.get(); + } + + it("links the pending credential read from sessionStorage before clearLegacySignInRecovery runs", async () => { + const ui = createRealUI(); + const email = "test@example.com"; + const password = "password123"; + + const storedJSON = { providerId: "google.com", signInMethod: "google.com", idToken: "fake-id-token" }; + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, JSON.stringify(storedJSON)); + + const credential = EmailAuthProvider.credential(email, password); + const mockUserCredential = { user: { uid: "user123" }, providerId: "password" } as UserCredential; + const rehydratedCredential = { providerId: "google.com" } as any; + const linkedUserCredential = { ...mockUserCredential, providerId: "google.com" } as UserCredential; + + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential); + vi.mocked(_signInWithCredential).mockResolvedValue(mockUserCredential); + vi.mocked(OAuthProvider.credentialFromJSON).mockReturnValue(rehydratedCredential); + vi.mocked(_linkWithCredential).mockResolvedValue(linkedUserCredential); + + const result = await signInWithEmailAndPassword(ui, email, password); + + expect(OAuthProvider.credentialFromJSON).toHaveBeenCalledWith(storedJSON); + expect(_linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, rehydratedCredential); + expect(result).toBe(linkedUserCredential); + expect(ui.legacySignInRecovery).toBeUndefined(); + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); }); }); @@ -1233,6 +1318,10 @@ describe("signInWithProvider", () => { expect(getBehavior).toHaveBeenCalledWith(mockUI, "providerSignInStrategy"); expect(mockProviderStrategy).toHaveBeenCalledWith(mockUI, provider); expect(result).toBe(mockResult); + + // Legacy recovery state is only cleared once sign-in has actually succeeded, so + // that a recovery button click doesn't dismiss the recovery UI before it resolves. + expect(mockUI.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); }); it("should call autoUpgradeAnonymousProvider behavior if enabled and return result", async () => { @@ -1288,6 +1377,10 @@ describe("signInWithProvider", () => { expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + // A failed recovery sign-in attempt must leave the legacy recovery state intact + // so the recovery modal stays open and can surface the error. + expect(mockUI.clearLegacySignInRecovery).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 33c161d3b..d322eba68 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -43,6 +43,7 @@ import QRCode from "qrcode-generator"; import { type FirebaseUI } from "./config"; import { handleFirebaseError } from "./errors"; import { hasBehavior, getBehavior } from "./behaviors/index"; +import { PENDING_CREDENTIAL_STORAGE_KEY } from "./behaviors/legacy-fetch-sign-in-with-email"; import { FirebaseError } from "firebase/app"; import { getTranslation } from "./translations"; @@ -75,11 +76,26 @@ function credentialFromJSON(json: unknown): AuthCredential | null { } } -async function handlePendingCredential(_ui: FirebaseUI, user: UserCredential): Promise { - const pendingCredString = window.sessionStorage.getItem("pendingCred"); +async function handlePendingCredential(ui: FirebaseUI, user: UserCredential): Promise { + // The pending credential was persisted in plaintext `sessionStorage` by + // `persistPendingCredential` (see the fuller trade-off explanation there, in + // `legacy-fetch-sign-in-with-email.ts`). It must be read BEFORE + // `clearLegacySignInRecovery()` below, since that call also removes this same + // sessionStorage key (as of the sessionStorage-clearing fix in `config.ts`) - reading + // it after would always see it already gone, silently skipping `linkWithCredential`. + const pendingCredString = window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY); + + // Sign-in succeeded, so any legacy recovery UI that was guiding the user here is no longer + // needed. This also removes `PENDING_CREDENTIAL_STORAGE_KEY` from sessionStorage, which is + // why it must run after the read above, not before. + ui.clearLegacySignInRecovery(); + if (!pendingCredString) return user; - window.sessionStorage.removeItem("pendingCred"); + // Redundant with the removal `clearLegacySignInRecovery()` performs above, but kept as an + // explicit safety net here in case a caller supplies a `clearLegacySignInRecovery` that + // doesn't clear sessionStorage (e.g. a test double, or a future alternate implementation). + window.sessionStorage.removeItem(PENDING_CREDENTIAL_STORAGE_KEY); try { const pendingCred = credentialFromJSON(JSON.parse(pendingCredString)); @@ -96,6 +112,30 @@ function setPendingState(ui: FirebaseUI) { ui.setState("pending"); } +function attachEmailToError(error: unknown, email: string): unknown { + if (!error || typeof error !== "object") { + return error; + } + + const emailError = error as { + code?: string; + message?: string; + name?: string; + email?: string; + customData?: { + email?: string; + }; + }; + + emailError.email = emailError.email ?? email; + emailError.customData = { + ...emailError.customData, + email: emailError.customData?.email ?? email, + }; + + return emailError; +} + /** * Signs in with an email and password. * @@ -126,7 +166,7 @@ export async function signInWithEmailAndPassword( const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, attachEmailToError(error, email)); } finally { ui.setState("idle"); } @@ -178,7 +218,7 @@ export async function createUserWithEmailAndPassword( return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -231,7 +271,7 @@ export async function verifyPhoneNumber( return await provider.verifyPhoneNumber(phoneNumber, appVerifier); } } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -268,7 +308,7 @@ export async function confirmPhoneNumber( const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -286,7 +326,7 @@ export async function sendPasswordResetEmail(ui: FirebaseUI, email: string): Pro setPendingState(ui); await _sendPasswordResetEmail(ui.auth, email); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -314,7 +354,7 @@ export async function sendSignInLinkToEmail(ui: FirebaseUI, email: string): Prom // TODO: Should this be a behavior ("storageStrategy")? window.localStorage.setItem("emailForSignIn", email); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -358,7 +398,7 @@ export async function signInWithCredential(ui: FirebaseUI, credential: AuthCrede const result = await _signInWithCredential(ui.auth, credential); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -377,7 +417,7 @@ export async function signInWithCustomToken(ui: FirebaseUI, customToken: string) const result = await _signInWithCustomToken(ui.auth, customToken); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -395,7 +435,7 @@ export async function signInAnonymously(ui: FirebaseUI): Promise const result = await _signInAnonymously(ui.auth); return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -431,7 +471,7 @@ export async function signInWithProvider(ui: FirebaseUI, provider: AuthProvider) // Otherwise, they will have been redirected. return handlePendingCredential(ui, result); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -507,7 +547,7 @@ export async function signInWithMultiFactorAssertion(ui: FirebaseUI, assertion: ui.setMultiFactorResolver(undefined); return result; } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -530,7 +570,7 @@ export async function enrollWithMultiFactorAssertion( setPendingState(ui); await multiFactor(ui.auth.currentUser!).enroll(assertion, displayName); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } @@ -549,7 +589,7 @@ export async function generateTotpSecret(ui: FirebaseUI): Promise { const session = await mfaUser.getSession(); return await TotpMultiFactorGenerator.generateSecret(session); } catch (error) { - handleFirebaseError(ui, error); + return await handleFirebaseError(ui, error); } finally { ui.setState("idle"); } diff --git a/packages/core/src/behaviors/index.test.ts b/packages/core/src/behaviors/index.test.ts index 0994558eb..a6b718070 100644 --- a/packages/core/src/behaviors/index.test.ts +++ b/packages/core/src/behaviors/index.test.ts @@ -21,6 +21,7 @@ import { autoUpgradeAnonymousUsers, getBehavior, hasBehavior, + legacyFetchSignInWithEmail, recaptchaVerification, requireDisplayName, defaultBehaviors, @@ -36,6 +37,10 @@ vi.mock("./require-display-name", () => ({ requireDisplayNameHandler: vi.fn(), })); +vi.mock("./legacy-fetch-sign-in-with-email", () => ({ + legacyFetchSignInWithEmailHandler: vi.fn(), +})); + vi.mock("firebase/auth", () => ({ RecaptchaVerifier: vi.fn(), signInWithPopup: vi.fn(), @@ -74,6 +79,7 @@ describe("hasBehavior", () => { autoUpgradeAnonymousProvider: { type: "callable" as const, handler: vi.fn() }, recaptchaVerification: { type: "callable" as const, handler: vi.fn() }, requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + legacyFetchSignInWithEmail: { type: "callable" as const, handler: vi.fn() }, } as any, }); @@ -82,6 +88,7 @@ describe("hasBehavior", () => { expect(hasBehavior(mockUI, "autoUpgradeAnonymousProvider")).toBe(true); expect(hasBehavior(mockUI, "recaptchaVerification")).toBe(true); expect(hasBehavior(mockUI, "requireDisplayName")).toBe(true); + expect(hasBehavior(mockUI, "legacyFetchSignInWithEmail")).toBe(true); }); }); @@ -110,6 +117,7 @@ describe("getBehavior", () => { autoUpgradeAnonymousProvider: { type: "callable" as const, handler: vi.fn() }, recaptchaVerification: { type: "callable" as const, handler: vi.fn() }, requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + legacyFetchSignInWithEmail: { type: "callable" as const, handler: vi.fn() }, }; const ui = createMockUI({ behaviors: mockBehaviors as any }); @@ -121,6 +129,7 @@ describe("getBehavior", () => { expect(getBehavior(ui, "autoUpgradeAnonymousProvider")).toBe(mockBehaviors.autoUpgradeAnonymousProvider.handler); expect(getBehavior(ui, "recaptchaVerification")).toBe(mockBehaviors.recaptchaVerification.handler); expect(getBehavior(ui, "requireDisplayName")).toBe(mockBehaviors.requireDisplayName.handler); + expect(getBehavior(ui, "legacyFetchSignInWithEmail")).toBe(mockBehaviors.legacyFetchSignInWithEmail.handler); }); }); @@ -263,6 +272,29 @@ describe("requireDisplayName", () => { }); }); +describe("legacyFetchSignInWithEmail", () => { + it("should return behavior with correct structure", () => { + const behavior = legacyFetchSignInWithEmail(); + + expect(behavior).toHaveProperty("legacyFetchSignInWithEmail"); + expect(behavior.legacyFetchSignInWithEmail).toHaveProperty("type", "callable"); + expect(behavior.legacyFetchSignInWithEmail).toHaveProperty("handler"); + expect(typeof behavior.legacyFetchSignInWithEmail.handler).toBe("function"); + }); + + it("should call the legacyFetchSignInWithEmailHandler when executed", async () => { + const behavior = legacyFetchSignInWithEmail(); + const mockUI = createMockUI(); + const mockError = { code: "auth/account-exists-with-different-credential", message: "Mismatch" } as any; + + const { legacyFetchSignInWithEmailHandler } = await import("./legacy-fetch-sign-in-with-email"); + + await behavior.legacyFetchSignInWithEmail.handler(mockUI, mockError); + + expect(legacyFetchSignInWithEmailHandler).toHaveBeenCalledWith(mockUI, mockError); + }); +}); + describe("defaultBehaviors", () => { it("should include recaptchaVerification by default", () => { expect(defaultBehaviors).toHaveProperty("recaptchaVerification"); @@ -276,5 +308,6 @@ describe("defaultBehaviors", () => { expect(defaultBehaviors).not.toHaveProperty("autoUpgradeAnonymousCredential"); expect(defaultBehaviors).not.toHaveProperty("autoUpgradeAnonymousProvider"); expect(defaultBehaviors).not.toHaveProperty("requireDisplayName"); + expect(defaultBehaviors).not.toHaveProperty("legacyFetchSignInWithEmail"); }); }); diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts index 5841d41e5..920a7e775 100644 --- a/packages/core/src/behaviors/index.ts +++ b/packages/core/src/behaviors/index.ts @@ -23,6 +23,7 @@ import * as providerStrategyHandlers from "./provider-strategy"; import * as oneTapSignInHandlers from "./one-tap"; import * as requireDisplayNameHandlers from "./require-display-name"; import * as countryCodesHandlers from "./country-codes"; +import * as legacyFetchSignInWithEmailHandlers from "./legacy-fetch-sign-in-with-email"; import { callableBehavior, initBehavior, @@ -51,6 +52,9 @@ type Registry = { oneTapSignIn: InitBehavior<(ui: FirebaseUI) => ReturnType>; requireDisplayName: CallableBehavior; countryCodes: CallableBehavior; + legacyFetchSignInWithEmail: CallableBehavior< + typeof legacyFetchSignInWithEmailHandlers.legacyFetchSignInWithEmailHandler + >; }; /** A behavior or set of behaviors from the registry. */ @@ -183,6 +187,23 @@ export function countryCodes(options?: countryCodesHandlers.CountryCodesOptions) }; } +/** + * Adds support for [deprecated methods and behavior](https://firebase.google.com/docs/auth/web/email-link-auth#differentiating_emailpassword_from_email_link) + * (like `fetchSignInMethodsForEmail()`) when [email enumeration protection](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) + * is disabled, by fetching previous sign-in methods for OAuth account mismatch flows. + * + * If your app relies on this legacy behavior, we recommend migrating away from it and enabling + * email enumeration protection as soon as you can. Projects created after September 15, 2023 have + * this protection enabled by default, in which case this behavior becomes a no-op. + * + * @returns A behavior that populates legacy sign-in recovery state. + */ +export function legacyFetchSignInWithEmail(): Behavior<"legacyFetchSignInWithEmail"> { + return { + legacyFetchSignInWithEmail: callableBehavior(legacyFetchSignInWithEmailHandlers.legacyFetchSignInWithEmailHandler), + }; +} + /** * Checks if a specific behavior is enabled for the given FirebaseUI instance. * diff --git a/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.test.ts b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.test.ts new file mode 100644 index 000000000..a624116f2 --- /dev/null +++ b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.test.ts @@ -0,0 +1,541 @@ +/** + * 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 { beforeEach, describe, expect, it, vi } from "vitest"; +import { + isLegacySignInRecoveryErrorCode, + legacyFetchSignInWithEmailHandler, + PENDING_CREDENTIAL_STORAGE_KEY, +} from "./legacy-fetch-sign-in-with-email"; +import { createMockUI } from "~/tests/utils"; +import { initializeUI } from "~/config"; +import type { FirebaseApp } from "firebase/app"; +import type { Auth } from "firebase/auth"; + +vi.mock("firebase/auth", () => ({ + fetchSignInMethodsForEmail: vi.fn(), + OAuthProvider: { + credentialFromError: vi.fn().mockReturnValue(null), + }, + getRedirectResult: vi.fn().mockResolvedValue(null), +})); + +import { fetchSignInMethodsForEmail, OAuthProvider } from "firebase/auth"; + +let mockSessionStorage: Record; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(OAuthProvider.credentialFromError).mockReset().mockReturnValue(null); + + mockSessionStorage = {}; + Object.defineProperty(window, "sessionStorage", { + value: { + setItem: vi.fn((key: string, value: string) => { + mockSessionStorage[key] = value; + }), + getItem: vi.fn((key: string) => mockSessionStorage[key] || null), + removeItem: vi.fn((key: string) => { + delete mockSessionStorage[key]; + }), + clear: vi.fn(() => { + Object.keys(mockSessionStorage).forEach((key) => delete mockSessionStorage[key]); + }), + }, + writable: true, + }); +}); + +describe("legacyFetchSignInWithEmailHandler", () => { + it("stores the pending credential and recovery data when the email is available", async () => { + const ui = createMockUI(); + const credential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + credential, + customData: { + email: "test@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["password", "emailLink"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + PENDING_CREDENTIAL_STORAGE_KEY, + JSON.stringify(credential.toJSON()) + ); + expect(fetchSignInMethodsForEmail).toHaveBeenCalledWith(ui.auth, "test@example.com"); + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "test@example.com", + signInMethods: ["password", "emailLink"], + attemptedProviderId: "google.com", + pendingProviderId: "google.com", + }); + }); + + it("extracts the pending credential from a Firebase OAuth error", async () => { + const ui = createMockUI(); + const credential = { + providerId: "github.com", + toJSON: vi.fn().mockReturnValue({ providerId: "github.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + customData: { + email: "oauth@example.com", + }, + } as any; + + vi.mocked(OAuthProvider.credentialFromError).mockReturnValue(credential); + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["google.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(OAuthProvider.credentialFromError).toHaveBeenCalledWith(error); + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + PENDING_CREDENTIAL_STORAGE_KEY, + JSON.stringify(credential.toJSON()) + ); + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "oauth@example.com", + signInMethods: ["google.com"], + attemptedProviderId: "github.com", + pendingProviderId: "github.com", + }); + }); + + it("continues recovery without a pending credential when OAuth extraction fails", async () => { + const ui = createMockUI(); + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + customData: { + email: "oauth@example.com", + }, + } as any; + + vi.mocked(OAuthProvider.credentialFromError).mockImplementation(() => { + throw new Error("Malformed OAuth response"); + }); + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["google.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "oauth@example.com", + signInMethods: ["google.com"], + attemptedProviderId: undefined, + pendingProviderId: undefined, + }); + }); + + it("falls back to the top-level error email field", async () => { + const ui = createMockUI(); + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + email: "fallback@example.com", + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["github.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(fetchSignInMethodsForEmail).toHaveBeenCalledWith(ui.auth, "fallback@example.com"); + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "fallback@example.com", + signInMethods: ["github.com"], + attemptedProviderId: undefined, + pendingProviderId: undefined, + }); + }); + + it("marks password as the attempted provider for wrong-password recovery", async () => { + const ui = createMockUI(); + const error = { + code: "auth/wrong-password", + message: "Wrong password", + customData: { + email: "password@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["google.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(fetchSignInMethodsForEmail).toHaveBeenCalledWith(ui.auth, "password@example.com"); + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "password@example.com", + signInMethods: ["google.com"], + attemptedProviderId: "password", + pendingProviderId: undefined, + }); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("marks password as the attempted provider for invalid-credential recovery", async () => { + const ui = createMockUI(); + const error = { + code: "auth/invalid-credential", + message: "Invalid credential", + customData: { + email: "invalid@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["github.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "invalid@example.com", + signInMethods: ["github.com"], + attemptedProviderId: "password", + pendingProviderId: undefined, + }); + }); + + it("marks password as the attempted provider for invalid-login-credentials recovery", async () => { + const ui = createMockUI(); + const error = { + code: "auth/invalid-login-credentials", + message: "Invalid login credentials", + customData: { + email: "login@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["google.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "login@example.com", + signInMethods: ["google.com"], + attemptedProviderId: "password", + pendingProviderId: undefined, + }); + }); + + it("clears recovery state when no email can be extracted", async () => { + const ui = createMockUI(); + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + } as any; + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(fetchSignInMethodsForEmail).not.toHaveBeenCalled(); + expect(ui.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); + }); + + it("clears recovery state when fetching sign-in methods fails", async () => { + const ui = createMockUI(); + const credential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + credential, + customData: { + email: "test@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockRejectedValue(new Error("Network failure")); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + PENDING_CREDENTIAL_STORAGE_KEY, + JSON.stringify(credential.toJSON()) + ); + expect(ui.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); + }); + + it("clears recovery state when no sign-in methods are returned", async () => { + const ui = createMockUI(); + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + customData: { + email: "test@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue([]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(ui.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); + expect(ui.setLegacySignInRecovery).not.toHaveBeenCalled(); + }); + + it("clears recovery state when the only sign-in method matches the attempted password", async () => { + const ui = createMockUI(); + const error = { + code: "auth/wrong-password", + message: "Wrong password", + customData: { + email: "typo@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["password"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(fetchSignInMethodsForEmail).toHaveBeenCalledWith(ui.auth, "typo@example.com"); + expect(ui.clearLegacySignInRecovery).toHaveBeenCalledTimes(1); + expect(ui.setLegacySignInRecovery).not.toHaveBeenCalled(); + }); + + it("sets recovery state when an OAuth conflict resolves to a genuinely different method", async () => { + const ui = createMockUI(); + const credential = { + providerId: "github.com", + toJSON: vi.fn().mockReturnValue({ providerId: "github.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + credential, + customData: { + email: "oauth@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue(["google.com"]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(ui.setLegacySignInRecovery).toHaveBeenCalledWith({ + email: "oauth@example.com", + signInMethods: ["google.com"], + attemptedProviderId: "github.com", + pendingProviderId: "github.com", + }); + expect(ui.clearLegacySignInRecovery).not.toHaveBeenCalled(); + }); +}); + +/** + * Uses the real `clearLegacySignInRecovery` (via `initializeUI`), instead of the + * `createMockUI()` no-op mock, to prove the actual regression scenario is fixed end-to-end: + * a pending credential persisted by this handler must not survive an abandoned recovery + * attempt, since `handlePendingCredential` in `auth.ts` would otherwise silently link it to + * an unrelated, later sign-in. + */ +describe("legacyFetchSignInWithEmailHandler (real clearLegacySignInRecovery)", () => { + function createRealUI() { + const store = initializeUI({ + app: {} as FirebaseApp, + auth: {} as Auth, + }); + return store.get(); + } + + it("removes the pending credential from sessionStorage when no email can be extracted", async () => { + const ui = createRealUI(); + const credential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + credential, + } as any; + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); + }); + + it("removes the pending credential from sessionStorage when fetching sign-in methods fails", async () => { + const ui = createRealUI(); + const credential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + credential, + customData: { + email: "test@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockRejectedValue(new Error("Network failure")); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); + }); + + it("removes the pending credential from sessionStorage when there is no actionable recovery method", async () => { + const ui = createRealUI(); + const credential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "token" }), + } as any; + const error = { + code: "auth/account-exists-with-different-credential", + message: "Mismatch", + credential, + customData: { + email: "test@example.com", + }, + } as any; + + vi.mocked(fetchSignInMethodsForEmail).mockResolvedValue([]); + + await legacyFetchSignInWithEmailHandler(ui, error); + + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); + }); +}); + +/** + * Proves the generation guard closes the race described in the review finding: a slow or + * duplicate `legacyFetchSignInWithEmailHandler` call must not act on a stale + * `fetchSignInMethodsForEmail()` resolution once a newer state transition (another call + * completing, or the user dismissing recovery) has already superseded it. Uses the real + * `initializeUI` store (not `createMockUI()`) since the guard is driven by the real + * `setLegacySignInRecovery`/`clearLegacySignInRecovery` implementations bumping an internal + * generation counter. + */ +describe("legacyFetchSignInWithEmailHandler (generation guard against stale async resolutions)", () => { + function createRealUI() { + const store = initializeUI({ + app: {} as FirebaseApp, + auth: {} as Auth, + }); + return { store, ui: store.get() }; + } + + function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + } + + it("does not reopen recovery after the user dismisses it, when a duplicate, superseded call resolves late (double-submit race)", async () => { + const { store, ui } = createRealUI(); + const setSpy = vi.spyOn(ui, "setLegacySignInRecovery"); + + const error = { + code: "auth/wrong-password", + message: "Wrong password", + customData: { email: "race@example.com" }, + } as any; + + const deferredFirst = createDeferred(); + const deferredSecond = createDeferred(); + vi.mocked(fetchSignInMethodsForEmail) + .mockImplementationOnce(() => deferredFirst.promise) + .mockImplementationOnce(() => deferredSecond.promise); + + // Simulate a double-submit: two handler invocations in flight for the same failed + // sign-in attempt, both capturing the same starting generation. + const firstCall = legacyFetchSignInWithEmailHandler(ui, error); + const secondCall = legacyFetchSignInWithEmailHandler(ui, error); + + // The first call's fetch resolves first, opening the recovery UI. + deferredFirst.resolve(["google.com"]); + await firstCall; + expect(setSpy).toHaveBeenCalledTimes(1); + expect(store.get().legacySignInRecovery).toMatchObject({ email: "race@example.com" }); + + // The user dismisses the recovery UI before the second (duplicate) call resolves. + ui.clearLegacySignInRecovery(); + expect(store.get().legacySignInRecovery).toBeUndefined(); + + // The superseded second call finally resolves. It must not reopen the modal the user + // just dismissed. + deferredSecond.resolve(["google.com"]); + await secondCall; + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(store.get().legacySignInRecovery).toBeUndefined(); + }); + + it("does not clobber a newer recovery already set by a second call, when a stale call's fetch resolves with no actionable methods", async () => { + const { store, ui } = createRealUI(); + const clearSpy = vi.spyOn(ui, "clearLegacySignInRecovery"); + + const staleError = { + code: "auth/wrong-password", + message: "Wrong password", + customData: { email: "stale@example.com" }, + } as any; + const freshError = { + code: "auth/wrong-password", + message: "Wrong password", + customData: { email: "fresh@example.com" }, + } as any; + + const deferredStale = createDeferred(); + vi.mocked(fetchSignInMethodsForEmail) + .mockImplementationOnce(() => deferredStale.promise) + .mockResolvedValueOnce(["google.com"]); + + // A slow, stale attempt starts first... + const staleCall = legacyFetchSignInWithEmailHandler(ui, staleError); + // ...but a second, unrelated attempt completes first and sets genuine recovery data. + await legacyFetchSignInWithEmailHandler(ui, freshError); + expect(store.get().legacySignInRecovery).toMatchObject({ email: "fresh@example.com" }); + + // The stale call's fetch finally resolves with no actionable recovery method, which + // would normally clear recovery state - but it must not wipe out the fresh data. + deferredStale.resolve(["password"]); + await staleCall; + + expect(clearSpy).not.toHaveBeenCalled(); + expect(store.get().legacySignInRecovery).toMatchObject({ email: "fresh@example.com" }); + }); +}); + +describe("isLegacySignInRecoveryErrorCode", () => { + it.each([ + "auth/account-exists-with-different-credential", + "auth/wrong-password", + "auth/invalid-credential", + "auth/invalid-login-credentials", + ])("returns true for %s", (code) => { + expect(isLegacySignInRecoveryErrorCode(code)).toBe(true); + }); + + it("returns false for unrelated error codes", () => { + expect(isLegacySignInRecoveryErrorCode("auth/user-not-found")).toBe(false); + expect(isLegacySignInRecoveryErrorCode("auth/too-many-requests")).toBe(false); + }); +}); diff --git a/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts new file mode 100644 index 000000000..403f35ebc --- /dev/null +++ b/packages/core/src/behaviors/legacy-fetch-sign-in-with-email.ts @@ -0,0 +1,200 @@ +/** + * 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 { FirebaseError } from "firebase/app"; +import { fetchSignInMethodsForEmail, OAuthProvider } from "firebase/auth"; +import type { Auth, AuthCredential } from "firebase/auth"; +import type { LegacySignInRecovery, FirebaseUI } from "~/config"; + +type FirebaseErrorWithCredential = FirebaseError & { credential: AuthCredential }; +type FirebaseErrorWithEmail = FirebaseError & { + email?: string; + customData?: { + email?: string; + }; +}; + +/** Firebase Auth error codes that should trigger the `legacyFetchSignInWithEmail` recovery flow. */ +export const LEGACY_SIGN_IN_RECOVERY_ERROR_CODES: readonly string[] = [ + "auth/account-exists-with-different-credential", + "auth/wrong-password", + "auth/invalid-credential", + "auth/invalid-login-credentials", +]; + +/** sessionStorage key used to persist a pending OAuth credential across a sign-in/relink flow. */ +export const PENDING_CREDENTIAL_STORAGE_KEY = "pendingCred"; + +/** + * The subset of {@link LEGACY_SIGN_IN_RECOVERY_ERROR_CODES} that indicates a failed password + * sign-in attempt, rather than an OAuth credential conflict. + */ +const PASSWORD_ATTEMPT_ERROR_CODES: readonly string[] = [ + "auth/wrong-password", + "auth/invalid-credential", + "auth/invalid-login-credentials", +]; + +/** + * Checks whether a Firebase Auth error code should trigger the `legacyFetchSignInWithEmail` recovery flow. + * + * @param code - The Firebase Auth error code to check. + * @returns True if the error code should trigger the recovery flow. + */ +export function isLegacySignInRecoveryErrorCode(code: string): boolean { + return LEGACY_SIGN_IN_RECOVERY_ERROR_CODES.includes(code); +} + +function errorContainsCredential(error: FirebaseError): error is FirebaseErrorWithCredential { + return "credential" in error && error.credential != null; +} + +function getEmailFromError(error: FirebaseError): string | undefined { + const emailError = error as FirebaseErrorWithEmail; + return emailError.customData?.email ?? emailError.email; +} + +function getPendingCredential(error: FirebaseError): AuthCredential | undefined { + if (error.code !== "auth/account-exists-with-different-credential") { + return undefined; + } + + if (errorContainsCredential(error)) { + return error.credential; + } + + try { + return OAuthProvider.credentialFromError(error) ?? undefined; + } catch { + return undefined; + } +} + +function buildRecovery( + error: FirebaseError, + email: string, + signInMethods: string[], + pendingCredential?: AuthCredential +): LegacySignInRecovery { + const pendingProviderId = pendingCredential?.providerId; + const attemptedProviderId = + pendingProviderId ?? (PASSWORD_ATTEMPT_ERROR_CODES.includes(error.code) ? "password" : undefined); + + return { + email, + signInMethods, + attemptedProviderId, + pendingProviderId, + }; +} + +/** + * Checks whether a recovery has at least one sign-in method that differs from the one the + * user just attempted. Without this, the recovery UI would open with nothing useful to offer — + * e.g. a password typo where `fetchSignInMethodsForEmail` returns `["password"]` (the SAME + * method just tried), or an empty method list (e.g. Email Enumeration Protection). + */ +function hasActionableRecoveryMethod(recovery: LegacySignInRecovery): boolean { + return recovery.signInMethods.some((method) => method !== recovery.attemptedProviderId); +} + +/** + * Persists a pending OAuth credential to `sessionStorage` so it can survive the + * recovery flow (e.g. a password re-auth or a redirect) and be reapplied afterward. + * + * Security trade-off (accepted, pre-existing): the serialized credential (via + * `credential.toJSON()`) may include OAuth access/ID tokens, and is stored in + * **plaintext**. This is scoped to `sessionStorage`, so it is same-origin only and is + * cleared automatically when the tab/session ends. It is also consumed and removed + * immediately upon use in {@link handlePendingCredential} in `auth.ts`, minimizing the + * exposure window. + */ +function persistPendingCredential(credential?: AuthCredential) { + if (!credential) { + return; + } + + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, JSON.stringify(credential.toJSON())); +} + +/** + * Tracks a monotonically increasing "generation" of legacy-sign-in-recovery state per `Auth` + * instance. Bumped by `setLegacySignInRecovery`/`clearLegacySignInRecovery` (see `config.ts`) + * on every state transition, regardless of who triggers it (this handler, a duplicate + * in-flight call, or the user dismissing the recovery UI). + * + * This lets `legacyFetchSignInWithEmailHandler` detect that its own in-flight + * `fetchSignInMethodsForEmail()` call has been superseded by a newer transition and bail + * out without touching the store, closing a race where a slow/duplicate call could reopen a + * dismissed recovery modal or clobber a newer, unrelated recovery attempt. + * + * Kept module-internal (keyed by `Auth` instance, rather than a `name`/store lookup) and not + * exposed on the public `FirebaseUI` type: it's purely an implementation detail of this race + * guard, not something consumers should read or depend on. `Auth` is used as the key because, + * unlike `legacySignInRecovery` itself, it's a stable field on `FirebaseUI` that is never + * replaced via `setKey`, so it reliably identifies "this UI instance" across snapshots. + */ +const legacySignInRecoveryGenerations = new WeakMap(); + +function getLegacySignInRecoveryGeneration(auth: Auth): number { + return legacySignInRecoveryGenerations.get(auth) ?? 0; +} + +/** Called by `config.ts` whenever legacy sign-in recovery state changes. Not part of the public API. */ +export function bumpLegacySignInRecoveryGeneration(auth: Auth): void { + legacySignInRecoveryGenerations.set(auth, getLegacySignInRecoveryGeneration(auth) + 1); +} + +export async function legacyFetchSignInWithEmailHandler(ui: FirebaseUI, error: FirebaseError): Promise { + const pendingCredential = getPendingCredential(error); + persistPendingCredential(pendingCredential); + + const email = getEmailFromError(error); + if (!email) { + // No `await` has happened yet, so nothing could have superseded this call - the + // generation check below is unnecessary here. + ui.clearLegacySignInRecovery(); + return; + } + + // Captured immediately before the only `await` in this function (rather than at the top + // of the handler) since that's the sole point where this call can be superseded by another + // state transition; capturing it earlier would be equivalent here (nothing async happens + // beforehand) but ties the snapshot more precisely to the risk it's guarding against. + const generation = getLegacySignInRecoveryGeneration(ui.auth); + + try { + const signInMethods = await fetchSignInMethodsForEmail(ui.auth, email); + if (getLegacySignInRecoveryGeneration(ui.auth) !== generation) { + // Superseded while this fetch was in flight (e.g. a duplicate call resolved first, or + // the user dismissed recovery). A newer transition already reflects the current state + // more accurately than this call could, so bail out without touching the store. + return; + } + + const recovery = buildRecovery(error, email, signInMethods, pendingCredential); + if (hasActionableRecoveryMethod(recovery)) { + ui.setLegacySignInRecovery(recovery); + } else { + ui.clearLegacySignInRecovery(); + } + } catch { + if (getLegacySignInRecoveryGeneration(ui.auth) !== generation) { + return; + } + ui.clearLegacySignInRecovery(); + } +} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 52bc5fa64..2cfefb7d2 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -20,6 +20,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { initializeUI } from "./config"; import { enUs, registerLocale } from "@firebase-oss/ui-translations"; import { autoUpgradeAnonymousUsers, autoAnonymousLogin } from "./behaviors"; +import { PENDING_CREDENTIAL_STORAGE_KEY } from "./behaviors/legacy-fetch-sign-in-with-email"; // Mock Firebase Auth vi.mock("firebase/auth", () => ({ @@ -444,6 +445,87 @@ describe("initializeUI", () => { expect(ui.get().redirectError).toBeUndefined(); }); + it("should have legacySignInRecovery undefined by default", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + + it("should set and clear legacySignInRecovery correctly", () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + const recovery = { + email: "test@example.com", + signInMethods: ["google.com", "password"], + attemptedProviderId: "github.com", + pendingProviderId: "github.com", + }; + + expect(ui.get().legacySignInRecovery).toBeUndefined(); + ui.get().setLegacySignInRecovery(recovery); + expect(ui.get().legacySignInRecovery).toEqual(recovery); + ui.get().clearLegacySignInRecovery(); + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + + it("should remove the pending credential from sessionStorage when clearLegacySignInRecovery is called", () => { + const mockSessionStorage: Record = {}; + Object.defineProperty(global, "window", { + value: { + sessionStorage: { + setItem: vi.fn((key: string, value: string) => { + mockSessionStorage[key] = value; + }), + getItem: vi.fn((key: string) => mockSessionStorage[key] ?? null), + removeItem: vi.fn((key: string) => { + delete mockSessionStorage[key]; + }), + }, + }, + writable: true, + configurable: true, + }); + + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, JSON.stringify({ providerId: "google.com" })); + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).not.toBeNull(); + + ui.get().clearLegacySignInRecovery(); + + expect(window.sessionStorage.removeItem).toHaveBeenCalledWith(PENDING_CREDENTIAL_STORAGE_KEY); + expect(window.sessionStorage.getItem(PENDING_CREDENTIAL_STORAGE_KEY)).toBeNull(); + + delete (global as any).window; + }); + + it("should not throw when clearLegacySignInRecovery is called without a window (SSR)", () => { + delete (global as any).window; + + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + + expect(() => ui.get().clearLegacySignInRecovery()).not.toThrow(); + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + it("should handle redirect error when getRedirectResult throws", async () => { Object.defineProperty(global, "window", { value: {}, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index e190397ef..50653fb26 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -20,6 +20,10 @@ import { type Auth, getAuth, getRedirectResult, type MultiFactorResolver } from import { map } from "nanostores"; import { deepMap, type DeepMapStore } from "@nanostores/deepmap"; import { type Behavior, type Behaviors, defaultBehaviors } from "./behaviors"; +import { + bumpLegacySignInRecoveryGeneration, + PENDING_CREDENTIAL_STORAGE_KEY, +} from "./behaviors/legacy-fetch-sign-in-with-email"; import type { InitBehavior, RedirectBehavior } from "./behaviors/utils"; import { type FirebaseUIState } from "./state"; import { handleFirebaseError } from "./errors"; @@ -39,6 +43,20 @@ export type FirebaseUIOptions = { behaviors?: Behavior[]; }; +/** + * Recovery state populated when a sign-in attempt should be redirected to a previously-used method. + */ +export type LegacySignInRecovery = { + /** The email address associated with the conflicting account. */ + email: string; + /** The sign-in methods returned by fetchSignInMethodsForEmail(). */ + signInMethods: string[]; + /** The provider used for the failed sign-in attempt, if known. */ + attemptedProviderId?: string; + /** The provider from the pending credential that can be linked after recovery, if known. */ + pendingProviderId?: string; +}; + /** * The main FirebaseUI instance that provides access to Firebase Auth and UI state management. * @@ -69,6 +87,12 @@ export type FirebaseUI = { redirectError?: Error; /** Sets the redirect error. */ setRedirectError: (error?: Error) => void; + /** Recovery data for guiding a user back to their previous sign-in method. */ + legacySignInRecovery?: LegacySignInRecovery; + /** Sets the legacy sign-in recovery data. */ + setLegacySignInRecovery: (recovery?: LegacySignInRecovery) => void; + /** Clears the legacy sign-in recovery data. */ + clearLegacySignInRecovery: () => void; }; export const $config = map>>({}); @@ -137,6 +161,29 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT const current = $config.get()[name]!; current.setKey(`redirectError`, error); }, + legacySignInRecovery: undefined, + setLegacySignInRecovery: (recovery?: LegacySignInRecovery) => { + const current = $config.get()[name]!; + current.setKey(`legacySignInRecovery`, recovery); + + // Any state transition supersedes prior in-flight legacyFetchSignInWithEmail work, + // so a stale async call can detect it's been superseded. See + // `bumpLegacySignInRecoveryGeneration` for details. + bumpLegacySignInRecoveryGeneration(current.get().auth); + }, + clearLegacySignInRecovery: () => { + const current = $config.get()[name]!; + current.setKey(`legacySignInRecovery`, undefined); + bumpLegacySignInRecoveryGeneration(current.get().auth); + + // Abandoning recovery must also drop any pending credential persisted to + // `sessionStorage` by the legacyFetchSignInWithEmail behavior. Otherwise a stale + // OAuth credential from an abandoned recovery attempt would silently get linked + // to a later, unrelated sign-in via `handlePendingCredential` in auth.ts. + if (typeof window !== "undefined") { + window.sessionStorage.removeItem(PENDING_CREDENTIAL_STORAGE_KEY); + } + }, }) ); @@ -169,9 +216,9 @@ export function initializeUI(config: FirebaseUIOptions, name: string = "[DEFAULT .then((result) => { return Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result))); }) - .catch((error) => { + .catch(async (error) => { try { - handleFirebaseError(ui, error); + await handleFirebaseError(ui, error); } catch (error) { ui.setRedirectError(error instanceof Error ? error : new Error(String(error))); } diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts index 36ca4d2da..caf3eb6fb 100644 --- a/packages/core/src/errors.test.ts +++ b/packages/core/src/errors.test.ts @@ -14,28 +14,36 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { FirebaseError } from "firebase/app"; import { Auth, AuthCredential, MultiFactorResolver } from "firebase/auth"; +import { ERROR_CODE_MAP } from "@firebase-oss/ui-translations"; import { FirebaseUIError, handleFirebaseError } from "./errors"; +import { PENDING_CREDENTIAL_STORAGE_KEY } from "./behaviors/legacy-fetch-sign-in-with-email"; import { createMockUI } from "~/tests/utils"; -import { ERROR_CODE_MAP } from "@firebase-oss/ui-translations"; vi.mock("./translations", () => ({ getTranslation: vi.fn(), })); +vi.mock("./behaviors", () => ({ + hasBehavior: vi.fn(), + getBehavior: vi.fn(), +})); + vi.mock("firebase/auth", () => ({ getMultiFactorResolver: vi.fn(), })); import { getTranslation } from "./translations"; +import { getBehavior, hasBehavior } from "./behaviors"; import { getMultiFactorResolver } from "firebase/auth"; -let mockSessionStorage: { [key: string]: string }; +let mockSessionStorage: Record; beforeEach(() => { vi.clearAllMocks(); + vi.mocked(hasBehavior).mockReturnValue(false); mockSessionStorage = {}; Object.defineProperty(window, "sessionStorage", { @@ -56,188 +64,254 @@ beforeEach(() => { }); describe("FirebaseUIError", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should create a FirebaseUIError with translated message", () => { + it("creates a FirebaseUIError with translated message", () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); - const expectedTranslation = "User not found (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("User not found (translated)"); const error = new FirebaseUIError(mockUI, mockFirebaseError); expect(error).toBeInstanceOf(FirebaseError); expect(error.code).toBe("auth/user-not-found"); - expect(error.message).toBe(expectedTranslation); + expect(error.message).toBe("User not found (translated)"); expect(getTranslation).toHaveBeenCalledWith(mockUI, "errors", ERROR_CODE_MAP["auth/user-not-found"]); }); - it("should handle unknown error codes gracefully", () => { + it("handles unknown error codes gracefully", () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/unknown-error", "Unknown error"); - const expectedTranslation = "Unknown error (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Unknown error (translated)"); const error = new FirebaseUIError(mockUI, mockFirebaseError); expect(error.code).toBe("auth/unknown-error"); - expect(error.message).toBe(expectedTranslation); - expect(getTranslation).toHaveBeenCalledWith( - mockUI, - "errors", - ERROR_CODE_MAP["auth/unknown-error" as keyof typeof ERROR_CODE_MAP] - ); + expect(error.message).toBe("Unknown error (translated)"); }); }); describe("handleFirebaseError", () => { - it("should throw non-Firebase errors as-is", () => { + it("throws non-Firebase errors as-is", async () => { const mockUI = createMockUI(); - const nonFirebaseError = new Error("Regular error"); - expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow("Regular error"); + await expect(handleFirebaseError(mockUI, new Error("Regular error"))).rejects.toThrow("Regular error"); }); - it("should throw non-Firebase errors with different types", () => { + it("throws non-Firebase errors with different types", async () => { const mockUI = createMockUI(); - const stringError = "String error"; - const numberError = 42; - const nullError = null; - const undefinedError = undefined; - - expect(() => handleFirebaseError(mockUI, stringError)).toThrow("String error"); - expect(() => handleFirebaseError(mockUI, numberError)).toThrow(); - expect(() => handleFirebaseError(mockUI, nullError)).toThrow(); - expect(() => handleFirebaseError(mockUI, undefinedError)).toThrow(); + + await expect(handleFirebaseError(mockUI, "String error")).rejects.toBe("String error"); + await expect(handleFirebaseError(mockUI, 42)).rejects.toBe(42); + await expect(handleFirebaseError(mockUI, null)).rejects.toBeNull(); + await expect(handleFirebaseError(mockUI, undefined)).rejects.toBeUndefined(); }); - it("should throw FirebaseUIError for Firebase errors", () => { + it("throws FirebaseUIError for Firebase errors", async () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); - const expectedTranslation = "User not found (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("User not found (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); try { - handleFirebaseError(mockUI, mockFirebaseError); + await handleFirebaseError(mockUI, mockFirebaseError); } catch (error) { expect(error).toBeInstanceOf(FirebaseUIError); expect(error).toBeInstanceOf(FirebaseError); expect((error as FirebaseUIError).code).toBe("auth/user-not-found"); - expect((error as FirebaseUIError).message).toBe(expectedTranslation); + expect((error as FirebaseUIError).message).toBe("User not found (translated)"); } }); - it("should store credential in sessionStorage for account-exists-with-different-credential", () => { + it("stores credential in sessionStorage for account-exists-with-different-credential by default", async () => { const mockUI = createMockUI(); const mockCredential = { providerId: "google.com", toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "mock-token" }), } as unknown as AuthCredential; - const mockFirebaseError = { code: "auth/account-exists-with-different-credential", message: "Account exists with different credential", credential: mockCredential, } as FirebaseError & { credential: AuthCredential }; - const expectedTranslation = "Account exists with different credential (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Account exists with different credential (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); - expect(window.sessionStorage.setItem).toHaveBeenCalledWith("pendingCred", JSON.stringify(mockCredential.toJSON())); + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + PENDING_CREDENTIAL_STORAGE_KEY, + JSON.stringify(mockCredential.toJSON()) + ); expect(mockCredential.toJSON).toHaveBeenCalled(); }); - it("should not store credential for other error types", () => { + it("delegates account-exists-with-different-credential to the behavior when enabled", async () => { + const mockUI = createMockUI(); + const mockCredential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "mock-token" }), + } as unknown as AuthCredential; + const mockFirebaseError = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential", + credential: mockCredential, + customData: { + email: "test@example.com", + }, + } as unknown as FirebaseError & { credential: AuthCredential }; + const behavior = vi.fn().mockResolvedValue(undefined); + + vi.mocked(getTranslation).mockReturnValue("Account exists with different credential (translated)"); + vi.mocked(hasBehavior).mockImplementation((_, key) => key === "legacyFetchSignInWithEmail"); + vi.mocked(getBehavior).mockReturnValue(behavior); + + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "legacyFetchSignInWithEmail"); + expect(behavior).toHaveBeenCalledWith(mockUI, mockFirebaseError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("delegates wrong-password to the recovery behavior when enabled", async () => { + const mockUI = createMockUI(); + const mockFirebaseError = { + code: "auth/wrong-password", + message: "Wrong password", + customData: { + email: "test@example.com", + }, + } as unknown as FirebaseError; + const behavior = vi.fn().mockResolvedValue(undefined); + + vi.mocked(getTranslation).mockReturnValue("Wrong password (translated)"); + vi.mocked(hasBehavior).mockImplementation((_, key) => key === "legacyFetchSignInWithEmail"); + vi.mocked(getBehavior).mockReturnValue(behavior); + + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "legacyFetchSignInWithEmail"); + expect(behavior).toHaveBeenCalledWith(mockUI, mockFirebaseError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("delegates invalid-credential to the recovery behavior when enabled", async () => { + const mockUI = createMockUI(); + const mockFirebaseError = { + code: "auth/invalid-credential", + message: "Invalid credential", + customData: { + email: "test@example.com", + }, + } as unknown as FirebaseError; + const behavior = vi.fn().mockResolvedValue(undefined); + + vi.mocked(getTranslation).mockReturnValue("Invalid credential (translated)"); + vi.mocked(hasBehavior).mockImplementation((_, key) => key === "legacyFetchSignInWithEmail"); + vi.mocked(getBehavior).mockReturnValue(behavior); + + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "legacyFetchSignInWithEmail"); + expect(behavior).toHaveBeenCalledWith(mockUI, mockFirebaseError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("delegates invalid-login-credentials to the recovery behavior when enabled", async () => { + const mockUI = createMockUI(); + const mockFirebaseError = { + code: "auth/invalid-login-credentials", + message: "Invalid login credentials", + customData: { + email: "test@example.com", + }, + } as unknown as FirebaseError; + const behavior = vi.fn().mockResolvedValue(undefined); + + vi.mocked(getTranslation).mockReturnValue("Invalid login credentials (translated)"); + vi.mocked(hasBehavior).mockImplementation((_, key) => key === "legacyFetchSignInWithEmail"); + vi.mocked(getBehavior).mockReturnValue(behavior); + + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); + + expect(getBehavior).toHaveBeenCalledWith(mockUI, "legacyFetchSignInWithEmail"); + expect(behavior).toHaveBeenCalledWith(mockUI, mockFirebaseError); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("does not store credential for other error types", async () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); - const expectedTranslation = "User not found (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("User not found (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); }); - it("should handle account-exists-with-different-credential without credential", () => { + it("handles account-exists-with-different-credential without credential", async () => { const mockUI = createMockUI(); const mockFirebaseError = { code: "auth/account-exists-with-different-credential", message: "Account exists with different credential", } as FirebaseError; - const expectedTranslation = "Account exists with different credential (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Account exists with different credential (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); }); - it("should call setMultiFactorResolver when auth/multi-factor-auth-required error is thrown", () => { + it("calls setMultiFactorResolver when auth/multi-factor-auth-required is thrown", async () => { const mockUI = createMockUI(); const mockResolver = { auth: {} as Auth, session: null, hints: [], } as unknown as MultiFactorResolver; - const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required"); - const expectedTranslation = "Multi-factor authentication required (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Multi-factor authentication required (translated)"); vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver); - expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, error)).rejects.toBeInstanceOf(FirebaseUIError); expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); }); - it("should still throw FirebaseUIError after setting multi-factor resolver", () => { + it("still throws FirebaseUIError after setting multi-factor resolver", async () => { const mockUI = createMockUI(); const mockResolver = { auth: {} as Auth, session: null, hints: [], } as unknown as MultiFactorResolver; - const error = new FirebaseError("auth/multi-factor-auth-required", "Multi-factor authentication required"); - const expectedTranslation = "Multi-factor authentication required (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("Multi-factor authentication required (translated)"); vi.mocked(getMultiFactorResolver).mockReturnValue(mockResolver); - expect(() => handleFirebaseError(mockUI, error)).toThrow(FirebaseUIError); - - expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); - expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); - try { - handleFirebaseError(mockUI, error); - } catch (error) { - expect(error).toBeInstanceOf(FirebaseUIError); - expect(error).toBeInstanceOf(FirebaseError); - expect((error as FirebaseUIError).code).toBe("auth/multi-factor-auth-required"); - expect((error as FirebaseUIError).message).toBe(expectedTranslation); + await handleFirebaseError(mockUI, error); + } catch (caught) { + expect(getMultiFactorResolver).toHaveBeenCalledWith(mockUI.auth, error); + expect(mockUI.setMultiFactorResolver).toHaveBeenCalledWith(mockResolver); + expect(caught).toBeInstanceOf(FirebaseUIError); + expect((caught as FirebaseUIError).code).toBe("auth/multi-factor-auth-required"); + expect((caught as FirebaseUIError).message).toBe("Multi-factor authentication required (translated)"); } }); - it("should not call setMultiFactorResolver for other error types", () => { + it("does not call setMultiFactorResolver for other error types", async () => { const mockUI = createMockUI(); const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); - const expectedTranslation = "User not found (translated)"; - vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + vi.mocked(getTranslation).mockReturnValue("User not found (translated)"); - expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + await expect(handleFirebaseError(mockUI, mockFirebaseError)).rejects.toBeInstanceOf(FirebaseUIError); expect(getMultiFactorResolver).not.toHaveBeenCalled(); expect(mockUI.setMultiFactorResolver).not.toHaveBeenCalled(); @@ -245,62 +319,29 @@ describe("handleFirebaseError", () => { }); describe("isFirebaseError utility", () => { - it("should identify FirebaseError objects", () => { - const firebaseError = new FirebaseError("auth/user-not-found", "User not found"); - + it("identifies FirebaseError objects", async () => { const mockUI = createMockUI(); vi.mocked(getTranslation).mockReturnValue("translated message"); - expect(() => handleFirebaseError(mockUI, firebaseError)).toThrow(FirebaseUIError); - }); - - it("should reject non-FirebaseError objects", () => { - const mockUI = createMockUI(); - const nonFirebaseError = { code: "test", message: "test" }; - - expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow(); - }); - - it("should reject objects without code and message", () => { - const mockUI = createMockUI(); - const invalidObject = { someProperty: "value" }; - - expect(() => handleFirebaseError(mockUI, invalidObject)).toThrow(); + await expect( + handleFirebaseError(mockUI, new FirebaseError("auth/user-not-found", "User not found")) + ).rejects.toBeInstanceOf(FirebaseUIError); }); -}); -describe("errorContainsCredential utility", () => { - it("should identify FirebaseError with credential", () => { + it("treats plain objects with code and message like Firebase errors", async () => { const mockUI = createMockUI(); - const mockCredential = { - providerId: "google.com", - toJSON: vi.fn().mockReturnValue({ providerId: "google.com" }), - } as unknown as AuthCredential; - - const firebaseErrorWithCredential = { - code: "auth/account-exists-with-different-credential", - message: "Account exists with different credential", - credential: mockCredential, - } as FirebaseError & { credential: AuthCredential }; - vi.mocked(getTranslation).mockReturnValue("translated message"); - expect(() => handleFirebaseError(mockUI, firebaseErrorWithCredential)).toThrowError(FirebaseUIError); - - expect(window.sessionStorage.setItem).toHaveBeenCalledWith("pendingCred", JSON.stringify(mockCredential.toJSON())); + await expect(handleFirebaseError(mockUI, { code: "test", message: "test" })).rejects.toBeInstanceOf( + FirebaseUIError + ); }); - it("should handle FirebaseError without credential", () => { + it("rejects objects without code and message", async () => { const mockUI = createMockUI(); - const firebaseErrorWithoutCredential = { - code: "auth/account-exists-with-different-credential", - message: "Account exists with different credential", - } as FirebaseError; - vi.mocked(getTranslation).mockReturnValue("translated message"); - - expect(() => handleFirebaseError(mockUI, firebaseErrorWithoutCredential)).toThrowError(FirebaseUIError); - - expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + await expect(handleFirebaseError(mockUI, { someProperty: "value" })).rejects.toEqual({ + someProperty: "value", + }); }); }); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 628aa6eca..61bf62d96 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -18,6 +18,11 @@ import { ERROR_CODE_MAP, type ErrorCode } from "@firebase-oss/ui-translations"; import { FirebaseError } from "firebase/app"; import { type AuthCredential, getMultiFactorResolver, type MultiFactorError } from "firebase/auth"; import { type FirebaseUI } from "./config"; +import { getBehavior, hasBehavior } from "./behaviors"; +import { + isLegacySignInRecoveryErrorCode, + PENDING_CREDENTIAL_STORAGE_KEY, +} from "./behaviors/legacy-fetch-sign-in-with-email"; import { getTranslation } from "./translations"; /** @@ -42,19 +47,21 @@ export class FirebaseUIError extends FirebaseError { * * @param ui - The FirebaseUI instance. * @param error - The error to handle. - * @returns {never} A never type. + * @returns {Promise} A never type wrapped in a promise. */ -export function handleFirebaseError(ui: FirebaseUI, error: unknown): never { +export async function handleFirebaseError(ui: FirebaseUI, error: unknown): Promise { // If it's not a Firebase error, then we just throw it and preserve the original error. if (!isFirebaseError(error)) { throw error; } - // TODO(ehesp): Type error as unknown, check instance of FirebaseError - // TODO(ehesp): Support via behavior - // Note: the credential is stored via `toJSON()`; it must be rehydrated (see handlePendingCredential in auth.ts) before being passed to linkWithCredential. - if (error.code === "auth/account-exists-with-different-credential" && errorContainsCredential(error)) { - window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON())); + if (isLegacySignInRecoveryErrorCode(error.code)) { + if (hasBehavior(ui, "legacyFetchSignInWithEmail")) { + await getBehavior(ui, "legacyFetchSignInWithEmail")(ui, error); + } else if (error.code === "auth/account-exists-with-different-credential" && errorContainsCredential(error)) { + // Note: the credential is stored via `toJSON()`; it must be rehydrated (see handlePendingCredential in auth.ts) before being passed to linkWithCredential. + window.sessionStorage.setItem(PENDING_CREDENTIAL_STORAGE_KEY, JSON.stringify(error.credential.toJSON())); + } } // Update the UI with the multi-factor resolver if the error is thrown. diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts index fa5e6daff..a2b264b7f 100644 --- a/packages/core/tests/utils.ts +++ b/packages/core/tests/utils.ts @@ -34,6 +34,9 @@ export function createMockUI(overrides?: Partial): FirebaseUI { setMultiFactorResolver: vi.fn(), redirectError: undefined, setRedirectError: vi.fn(), + legacySignInRecovery: undefined, + setLegacySignInRecovery: vi.fn(), + clearLegacySignInRecovery: vi.fn(), ...overrides, }; } diff --git a/packages/react/src/auth/screens/oauth-screen.test.tsx b/packages/react/src/auth/screens/oauth-screen.test.tsx index efc480075..e31cca36b 100644 --- a/packages/react/src/auth/screens/oauth-screen.test.tsx +++ b/packages/react/src/auth/screens/oauth-screen.test.tsx @@ -32,6 +32,10 @@ vi.mock("~/components/redirect-error", () => ({ RedirectError: () =>
Redirect Error
, })); +vi.mock("~/components/legacy-sign-in-recovery", () => ({ + LegacySignInRecovery: () =>
Legacy Recovery
, +})); + vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({ MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
@@ -118,6 +122,42 @@ describe("", () => { expect(screen.getByTestId("policies")).toBeDefined(); }); + it("does not render LegacySignInRecovery by default", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.queryByTestId("legacy-sign-in-recovery")).toBeNull(); + }); + + it("renders LegacySignInRecovery when explicitly enabled", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.getByTestId("legacy-sign-in-recovery")).toBeDefined(); + }); + + it("does not render LegacySignInRecovery when explicitly disabled", () => { + const ui = createMockUI(); + + render( + + OAuth Provider + + ); + + expect(screen.queryByTestId("legacy-sign-in-recovery")).toBeNull(); + }); + it("renders children before the Policies component", () => { const ui = createMockUI(); diff --git a/packages/react/src/auth/screens/oauth-screen.tsx b/packages/react/src/auth/screens/oauth-screen.tsx index 5454938f5..6fd30d929 100644 --- a/packages/react/src/auth/screens/oauth-screen.tsx +++ b/packages/react/src/auth/screens/oauth-screen.tsx @@ -22,11 +22,14 @@ import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "~/compon import { Policies } from "~/components/policies"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; +import { LegacySignInRecovery } from "~/components/legacy-sign-in-recovery"; /** Props for the OAuthScreen component. */ export type OAuthScreenProps = PropsWithChildren<{ /** Callback function called when sign-in is successful. */ onSignIn?: (user: User) => void; + /** Whether to show the built-in legacy sign-in recovery UI. Defaults to `false`; opt in explicitly. */ + showLegacySignInRecovery?: boolean; }>; /** @@ -37,7 +40,7 @@ export type OAuthScreenProps = PropsWithChildren<{ * * @returns The OAuth screen component. */ -export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { +export function OAuthScreen({ children, onSignIn, showLegacySignInRecovery = false }: OAuthScreenProps) { const ui = useUI(); const titleText = getTranslation(ui, "labels", "signIn"); @@ -59,6 +62,7 @@ export function OAuthScreen({ children, onSignIn }: OAuthScreenProps) { {children} + {showLegacySignInRecovery ? : null} diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx index cf315722c..b9001e75a 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.test.tsx @@ -51,6 +51,10 @@ vi.mock("~/components/redirect-error", () => ({ RedirectError: () =>
Redirect Error
, })); +vi.mock("~/components/legacy-sign-in-recovery", () => ({ + LegacySignInRecovery: () =>
Legacy Recovery
, +})); + vi.mock("~/auth/screens/multi-factor-auth-assertion-screen", () => ({ MultiFactorAuthAssertionScreen: ({ onSuccess }: { onSuccess?: (credential: any) => void }) => (
@@ -110,6 +114,42 @@ describe("", () => { expect(screen.getByTestId("sign-in-auth-form")).toBeDefined(); }); + it("does not render LegacySignInRecovery by default", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("legacy-sign-in-recovery")).toBeNull(); + }); + + it("renders LegacySignInRecovery when explicitly enabled", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.getByTestId("legacy-sign-in-recovery")).toBeDefined(); + }); + + it("does not render LegacySignInRecovery when explicitly disabled", () => { + const ui = createMockUI(); + + render( + + + + ); + + expect(screen.queryByTestId("legacy-sign-in-recovery")).toBeNull(); + }); + it("passes onForgotPasswordClick to SignInAuthForm", () => { const mockOnForgotPasswordClick = vi.fn(); const ui = createMockUI(); diff --git a/packages/react/src/auth/screens/sign-in-auth-screen.tsx b/packages/react/src/auth/screens/sign-in-auth-screen.tsx index f7308fa74..c3b87cd7e 100644 --- a/packages/react/src/auth/screens/sign-in-auth-screen.tsx +++ b/packages/react/src/auth/screens/sign-in-auth-screen.tsx @@ -23,11 +23,14 @@ import { Card, CardContent, CardHeader, CardSubtitle, CardTitle } from "../../co import { SignInAuthForm, type SignInAuthFormProps } from "../forms/sign-in-auth-form"; import { MultiFactorAuthAssertionScreen } from "./multi-factor-auth-assertion-screen"; import { RedirectError } from "~/components/redirect-error"; +import { LegacySignInRecovery } from "~/components/legacy-sign-in-recovery"; /** Props for the SignInAuthScreen component. */ export type SignInAuthScreenProps = PropsWithChildren> & { /** Callback function called when sign-in is successful. */ onSignIn?: (user: User) => void; + /** Whether to show the built-in legacy sign-in recovery UI. Defaults to `false`; opt in explicitly. */ + showLegacySignInRecovery?: boolean; }; /** @@ -37,7 +40,12 @@ export type SignInAuthScreenProps = PropsWithChildren + {showLegacySignInRecovery ? : null} {children ? ( <> {getTranslation(ui, "messages", "dividerOr")} diff --git a/packages/react/src/components/index.tsx b/packages/react/src/components/index.tsx index 2dda12d95..b8f5103cf 100644 --- a/packages/react/src/components/index.tsx +++ b/packages/react/src/components/index.tsx @@ -25,5 +25,6 @@ export { } from "./country-selector"; export { Divider, type DividerProps } from "./divider"; export { form } from "./form"; +export { LegacySignInRecovery } from "./legacy-sign-in-recovery"; export { Policies, type PolicyProps, type PolicyURL } from "./policies"; export { RedirectError } from "./redirect-error"; diff --git a/packages/react/src/components/legacy-sign-in-recovery.test.tsx b/packages/react/src/components/legacy-sign-in-recovery.test.tsx new file mode 100644 index 000000000..33892cb46 --- /dev/null +++ b/packages/react/src/components/legacy-sign-in-recovery.test.tsx @@ -0,0 +1,202 @@ +/** + * 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 { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { registerLocale } from "@firebase-oss/ui-translations"; +import { LegacySignInRecovery } from "~/components/legacy-sign-in-recovery"; +import { CreateFirebaseUIProvider, createMockUI } from "~/tests/utils"; + +vi.mock("~/auth/oauth/google-sign-in-button", () => ({ + GoogleSignInButton: ({ onSignIn }: { onSignIn?: (credential: unknown) => void }) => ( + + ), +})); + +vi.mock("~/auth/oauth/github-sign-in-button", () => ({ + GitHubSignInButton: ({ onSignIn }: { onSignIn?: (credential: unknown) => void }) => ( + + ), +})); + +vi.mock("~/auth/oauth/facebook-sign-in-button", () => ({ + FacebookSignInButton: () => , +})); + +vi.mock("~/auth/oauth/apple-sign-in-button", () => ({ + AppleSignInButton: () => , +})); + +vi.mock("~/auth/oauth/microsoft-sign-in-button", () => ({ + MicrosoftSignInButton: () => , +})); + +vi.mock("~/auth/oauth/twitter-sign-in-button", () => ({ + TwitterSignInButton: () => , +})); + +vi.mock("~/auth/oauth/yahoo-sign-in-button", () => ({ + YahooSignInButton: () => , +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("", () => { + const recoveryLocale = registerLocale("legacy-recovery", { + messages: { + legacySignInRecoveryAccountFound: "Found Your Account", + legacySignInRecoveryPrompt: "You have previously signed in with a different method for {email}.", + legacySignInRecoverySelectMethod: "Choose one of your previous sign-in methods to continue.", + legacySignInRecoveryEmailPassword: "Use the email and password form to continue.", + legacySignInRecoveryEmailLink: "Use your email link sign-in flow to continue.", + }, + labels: { + dismiss: "Dismiss", + }, + }); + + it("returns null when there is no recovery state", () => { + const ui = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("renders recovery copy and recognized provider buttons", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com", "github.com", "password", "emailLink"], + }); + + render( + + + + ); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect(screen.getByText("Found Your Account")).toBeDefined(); + expect( + screen.getByText("You have previously signed in with a different method for test@example.com.") + ).toBeDefined(); + expect(screen.getByText("Choose one of your previous sign-in methods to continue.")).toBeDefined(); + expect(screen.getByTestId("google-recovery-button")).toBeDefined(); + expect(screen.getByTestId("github-recovery-button")).toBeDefined(); + expect(screen.getByText("Use the email and password form to continue.")).toBeDefined(); + expect(screen.getByText("Use your email link sign-in flow to continue.")).toBeDefined(); + }); + + it("renders the default en-us eyebrow label via the translation system", () => { + const ui = createMockUI(); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + + render( + + + + ); + + expect(screen.getByText("Account Found")).toBeDefined(); + }); + + it("never renders a button for the attempted provider, even if it appears in signInMethods", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com", "github.com"], + attemptedProviderId: "github.com", + }); + + render( + + + + ); + + expect(screen.getByTestId("google-recovery-button")).toBeDefined(); + expect(screen.queryByTestId("github-recovery-button")).toBeNull(); + }); + + it("clears recovery when dismissed", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + + it("clears recovery when the modal backdrop is clicked", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("dialog").parentElement as HTMLElement); + + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); + + it("clears recovery after a successful recovery sign-in", () => { + const ui = createMockUI({ locale: recoveryLocale }); + ui.get().setLegacySignInRecovery({ + email: "test@example.com", + signInMethods: ["google.com"], + }); + + render( + + + + ); + + fireEvent.click(screen.getByTestId("google-recovery-button")); + + expect(ui.get().legacySignInRecovery).toBeUndefined(); + }); +}); diff --git a/packages/react/src/components/legacy-sign-in-recovery.tsx b/packages/react/src/components/legacy-sign-in-recovery.tsx new file mode 100644 index 000000000..bbe3fe505 --- /dev/null +++ b/packages/react/src/components/legacy-sign-in-recovery.tsx @@ -0,0 +1,129 @@ +/** + * 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. + */ + +"use client"; + +import { getTranslation } from "@firebase-oss/ui-core"; +import { useCallback, useEffect, useId } from "react"; +import { AppleSignInButton } from "~/auth/oauth/apple-sign-in-button"; +import { FacebookSignInButton } from "~/auth/oauth/facebook-sign-in-button"; +import { GitHubSignInButton } from "~/auth/oauth/github-sign-in-button"; +import { GoogleSignInButton } from "~/auth/oauth/google-sign-in-button"; +import { MicrosoftSignInButton } from "~/auth/oauth/microsoft-sign-in-button"; +import { TwitterSignInButton } from "~/auth/oauth/twitter-sign-in-button"; +import { YahooSignInButton } from "~/auth/oauth/yahoo-sign-in-button"; +import { useLegacySignInRecovery, useUI } from "~/hooks"; +import { Button } from "./button"; + +function hasMethod(signInMethods: string[], method: string) { + return signInMethods.includes(method); +} + +/** + * Whether an OAuth sign-in button should be offered for `method`. + * + * Excludes `attemptedProviderId` defensively so the recovery UI never re-offers the exact + * provider the user just failed with, even if Firebase's API were to include it in + * `signInMethods` under some edge case. + */ +function canOfferMethod(recovery: { signInMethods: string[]; attemptedProviderId?: string }, method: string) { + return hasMethod(recovery.signInMethods, method) && method !== recovery.attemptedProviderId; +} + +/** + * Displays default recovery UI for legacy sign-in method suggestions. + * + * Returns null if there is no recovery state. + */ +export function LegacySignInRecovery() { + const ui = useUI(); + const { recovery, clearRecovery } = useLegacySignInRecovery(); + const descriptionId = useId(); + const handleRecoverySignIn = useCallback(() => { + clearRecovery(); + }, [clearRecovery]); + const handleBackdropClick = useCallback( + (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + clearRecovery(); + } + }, + [clearRecovery] + ); + + useEffect(() => { + if (!recovery) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + clearRecovery(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [recovery, clearRecovery]); + + if (!recovery) { + return null; + } + + return ( +
+
+
+ {getTranslation(ui, "messages", "legacySignInRecoveryAccountFound")} +
+
+

+ {getTranslation(ui, "messages", "legacySignInRecoveryPrompt", { email: recovery.email })} +

+

+ {getTranslation(ui, "messages", "legacySignInRecoverySelectMethod")} +

+
+
+ {canOfferMethod(recovery, "google.com") ? : null} + {canOfferMethod(recovery, "github.com") ? : null} + {canOfferMethod(recovery, "facebook.com") ? : null} + {canOfferMethod(recovery, "apple.com") ? : null} + {canOfferMethod(recovery, "microsoft.com") ? : null} + {canOfferMethod(recovery, "twitter.com") ? : null} + {canOfferMethod(recovery, "yahoo.com") ? : null} +
+
+ {hasMethod(recovery.signInMethods, "password") ? ( +

{getTranslation(ui, "messages", "legacySignInRecoveryEmailPassword")}

+ ) : null} + {hasMethod(recovery.signInMethods, "emailLink") ? ( +

{getTranslation(ui, "messages", "legacySignInRecoveryEmailLink")}

+ ) : null} +
+ +
+
+ ); +} diff --git a/packages/react/src/hooks.test.tsx b/packages/react/src/hooks.test.tsx index a05757438..b2d58bb60 100644 --- a/packages/react/src/hooks.test.tsx +++ b/packages/react/src/hooks.test.tsx @@ -19,6 +19,7 @@ import { renderHook, act, cleanup, waitFor } from "@testing-library/react"; import { useUI, useRedirectError, + useLegacySignInRecovery, useSignInAuthFormSchema, useSignUpAuthFormSchema, useForgotPasswordAuthFormSchema, @@ -845,6 +846,69 @@ describe("useRedirectError", () => { }); }); +describe("useLegacySignInRecovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + it("returns undefined when no recovery state exists", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useLegacySignInRecovery(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.recovery).toBeUndefined(); + }); + + it("returns recovery data from UI state", () => { + const mockUI = createMockUI(); + const recovery = { + email: "test@example.com", + signInMethods: ["google.com", "password"], + attemptedProviderId: "github.com", + pendingProviderId: "github.com", + }; + + act(() => { + mockUI.get().setLegacySignInRecovery(recovery); + }); + + const { result } = renderHook(() => useLegacySignInRecovery(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.recovery).toEqual(recovery); + }); + + it("clears the recovery state", () => { + const mockUI = createMockUI(); + const recovery = { + email: "test@example.com", + signInMethods: ["google.com"], + }; + + act(() => { + mockUI.get().setLegacySignInRecovery(recovery); + }); + + const { result, rerender } = renderHook(() => useLegacySignInRecovery(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.recovery).toEqual(recovery); + + act(() => { + result.current.clearRecovery(); + }); + + rerender(); + + expect(result.current.recovery).toBeUndefined(); + }); +}); + describe("useRecaptchaVerifier", () => { beforeEach(() => { cleanup(); diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index d978def1d..f0cd0e5cf 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useContext, useMemo, useEffect, useRef, useState } from "react"; +import { useContext, useMemo, useEffect, useRef, useState, useCallback } from "react"; import type { RecaptchaVerifier, User } from "firebase/auth"; import { createEmailLinkAuthFormSchema, @@ -94,6 +94,26 @@ export function useRedirectError() { }, [ui.redirectError]); } +/** + * Gets legacy sign-in recovery data populated by the legacyFetchSignInWithEmail behavior. + * + * @returns The recovery data and a callback to clear it. + */ +export function useLegacySignInRecovery() { + const ui = useUI(); + const clearRecovery = useCallback(() => { + ui.clearLegacySignInRecovery(); + }, [ui]); + + return useMemo( + () => ({ + recovery: ui.legacySignInRecovery, + clearRecovery, + }), + [ui.legacySignInRecovery, clearRecovery] + ); +} + /** * Gets a memoized Zod schema for sign-in form validation. * diff --git a/packages/react/tests/utils.tsx b/packages/react/tests/utils.tsx index 67ac68d2b..2c15d6a5e 100644 --- a/packages/react/tests/utils.tsx +++ b/packages/react/tests/utils.tsx @@ -28,13 +28,32 @@ export function createMockUI(overrides?: Partial): FirebaseUI const { auth, ...restOverrides } = overrides || {}; - return initializeUI({ + const store = initializeUI({ app: {} as FirebaseApp, auth: auth ?? defaultAuth, locale: enUs, behaviors: [] as Behavior[], ...restOverrides, }); + + const ui = store.get() as FirebaseUI & { + setLegacySignInRecovery?: (recovery?: FirebaseUI["legacySignInRecovery"]) => void; + clearLegacySignInRecovery?: () => void; + }; + + if (!ui.setLegacySignInRecovery) { + ui.setLegacySignInRecovery = (recovery) => { + store.setKey("legacySignInRecovery", recovery as FirebaseUI["legacySignInRecovery"]); + }; + } + + if (!ui.clearLegacySignInRecovery) { + ui.clearLegacySignInRecovery = () => { + store.setKey("legacySignInRecovery", undefined); + }; + } + + return store; } export const createFirebaseUIProvider = ({ children, ui }: { children: React.ReactNode; ui: FirebaseUIStore }) => ( diff --git a/packages/styles/src/base.css b/packages/styles/src/base.css index 7d2d66d03..4cf3bb692 100644 --- a/packages/styles/src/base.css +++ b/packages/styles/src/base.css @@ -234,6 +234,34 @@ @apply hover:underline font-semibold; } + :where(.fui-legacy-sign-in-recovery-modal) { + @apply fixed inset-0 z-50 flex items-center justify-center p-4; + background: color-mix(in srgb, var(--color-text) 18%, transparent); + backdrop-filter: blur(10px); + } + + :where(.fui-legacy-sign-in-recovery-modal__card) { + @apply w-full max-w-md space-y-5 border-border shadow-2xl; + background: + linear-gradient(180deg, color-mix(in srgb, var(--color-background) 94%, var(--color-primary) 6%), var(--color-background)); + } + + :where(.fui-legacy-sign-in-recovery-modal__eyebrow) { + @apply inline-flex items-center rounded-full border border-border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-text-muted; + } + + :where(.fui-legacy-sign-in-recovery-modal__content) { + @apply space-y-2; + } + + :where(.fui-legacy-sign-in-recovery-modal__prompt) { + @apply text-lg font-semibold text-text text-balance; + } + + :where(.fui-legacy-sign-in-recovery-modal__notes) { + @apply space-y-2 text-sm text-text-muted; + } + .fui-provider__button[data-provider="google.com"][data-themed="true"] { --google-primary: #131314; --color-primary: var(--google-primary); diff --git a/packages/translations/src/locales/en-us.ts b/packages/translations/src/locales/en-us.ts index f1f2e08c1..d6a78c8dc 100644 --- a/packages/translations/src/locales/en-us.ts +++ b/packages/translations/src/locales/en-us.ts @@ -58,6 +58,11 @@ export const enUS = { dividerOr: "or", termsAndPrivacy: "By continuing, you agree to our {tos} and {privacy}.", mfaSmsAssertionPrompt: "A verification code will be sent to {phoneNumber} to complete the authentication process.", + legacySignInRecoveryAccountFound: "Account Found", + legacySignInRecoveryPrompt: "You have previously signed in with a different method for {email}.", + legacySignInRecoverySelectMethod: "Choose one of your previous sign-in methods to continue.", + legacySignInRecoveryEmailPassword: "Use the email and password form to continue.", + legacySignInRecoveryEmailLink: "Use your email link sign-in flow to continue.", }, labels: { emailAddress: "Email Address", @@ -88,6 +93,7 @@ export const enUS = { privacyPolicy: "Privacy Policy", resendCode: "Resend Code", sending: "Sending...", + dismiss: "Dismiss", multiFactorEnrollment: "Multi-factor Enrollment", multiFactorAssertion: "Multi-factor Authentication", mfaTotpVerification: "TOTP Verification", diff --git a/packages/translations/src/mapping.test.ts b/packages/translations/src/mapping.test.ts index b6cff2b86..4863a7fd8 100644 --- a/packages/translations/src/mapping.test.ts +++ b/packages/translations/src/mapping.test.ts @@ -29,6 +29,7 @@ describe("mapping.ts", () => { it("should map Firebase auth error codes to translation keys", () => { expect(ERROR_CODE_MAP["auth/user-not-found"]).toBe("userNotFound"); expect(ERROR_CODE_MAP["auth/wrong-password"]).toBe("wrongPassword"); + expect(ERROR_CODE_MAP["auth/invalid-login-credentials"]).toBe("invalidCredential"); expect(ERROR_CODE_MAP["auth/invalid-email"]).toBe("invalidEmail"); expect(ERROR_CODE_MAP["auth/user-disabled"]).toBe("userDisabled"); expect(ERROR_CODE_MAP["auth/network-request-failed"]).toBe("networkRequestFailed"); @@ -109,6 +110,7 @@ describe("mapping.ts", () => { const testErrorCodes: ErrorCode[] = [ "auth/user-not-found", "auth/wrong-password", + "auth/invalid-login-credentials", "auth/invalid-email", "auth/network-request-failed", ]; diff --git a/packages/translations/src/mapping.ts b/packages/translations/src/mapping.ts index 0ff0eaf12..75f3bad33 100644 --- a/packages/translations/src/mapping.ts +++ b/packages/translations/src/mapping.ts @@ -22,6 +22,7 @@ import type { ErrorKey, TranslationCategory, TranslationKey, TranslationSet } fr export const ERROR_CODE_MAP = { "auth/user-not-found": "userNotFound", "auth/wrong-password": "wrongPassword", + "auth/invalid-login-credentials": "invalidCredential", "auth/invalid-email": "invalidEmail", "auth/unverified-email": "unverifiedEmail", "auth/user-disabled": "userDisabled", diff --git a/packages/translations/src/types.ts b/packages/translations/src/types.ts index 7cd41537f..e1bf072b5 100644 --- a/packages/translations/src/types.ts +++ b/packages/translations/src/types.ts @@ -117,6 +117,16 @@ export type Translations = { termsAndPrivacy?: string; /** Translation for MFA SMS assertion prompt message. */ mfaSmsAssertionPrompt?: string; + /** Translation for the legacy sign-in recovery modal's eyebrow label. */ + legacySignInRecoveryAccountFound?: string; + /** Translation for legacy sign-in recovery prompt message. */ + legacySignInRecoveryPrompt?: string; + /** Translation for selecting a previous sign-in method. */ + legacySignInRecoverySelectMethod?: string; + /** Translation for continuing with email and password. */ + legacySignInRecoveryEmailPassword?: string; + /** Translation for continuing with an email link. */ + legacySignInRecoveryEmailLink?: string; }; /** UI label translations. */ labels?: { @@ -174,6 +184,8 @@ export type Translations = { resendCode?: string; /** Translation for sending state text. */ sending?: string; + /** Translation for dismiss action. */ + dismiss?: string; /** Translation for multi-factor enrollment label. */ multiFactorEnrollment?: string; /** Translation for multi-factor assertion label. */