diff --git a/packages/functional-tests/lib/passkeyPolyfill.ts b/packages/functional-tests/lib/passkeyPolyfill.ts index 36d38b23b6a..57edd612d9d 100644 --- a/packages/functional-tests/lib/passkeyPolyfill.ts +++ b/packages/functional-tests/lib/passkeyPolyfill.ts @@ -10,11 +10,29 @@ import { VirtualAuthenticator, type VirtualCredential, } from '../../../libs/accounts/passkey/src/lib/virtual-authenticator'; +// Pull the canonical WebAuthn error name list from fxa-settings so the +// polyfill can never drift from the categoriser. The module is a pure +// constants file (no imports, no decorators) — Babel-safe. +import { ERROR_MAP } from '../../fxa-settings/src/lib/passkeys/webauthn-errors'; import type { RegistrationResponseJSON, AuthenticationResponseJSON, } from '@simplewebauthn/server'; +/** + * DOMException names the polyfill passes through unchanged. Derived from the + * canonical {@link ERROR_MAP}, with two intentional exclusions: + * - `TypeError`: Playwright surfaces serialisation hiccups as TypeError; we + * deliberately collapse those to NotAllowedError so they categorise as a + * user-cancel rather than landing in the "unexpected" bucket. + * - `AuthenticatorAlreadyRegistered`: synthetic key the categoriser + * fabricates during registration with excludeCredentials. The browser + * never throws this name as a DOMException. + */ +const WEBAUTHN_DOM_EXCEPTION_NAMES = Object.keys(ERROR_MAP).filter( + (name) => name !== 'TypeError' && name !== 'AuthenticatorAlreadyRegistered' +); + type Mode = 'pending' | 'success' | 'cancel'; type Trigger = () => Promise; type PostCheck = () => Promise; @@ -111,6 +129,23 @@ export class PasskeyPolyfill { } } + /** + * Simulate a successful assertion ceremony (sign-in). Unlike {@link success}, + * does not wait for a new credential to be added — assertions reuse existing + * credentials minted by prior `success()` calls. The trigger is responsible + * for awaiting whatever post-assertion side effect indicates completion + * (typically a navigation). + */ + async assertion(trigger: Trigger) { + const previous = this.mode; + this.mode = 'success'; + try { + await trigger(); + } finally { + this.mode = previous; + } + } + /** * Simulate a user-cancelled browser prompt: switch to `cancel` mode so the * polyfill rejects the ceremony with a `NotAllowedError` DOMException, then @@ -283,12 +318,19 @@ const BROWSER_POLYFILL = `(() => { 'NotAllowedError' ); } + // Templated from WEBAUTHN_DOM_EXCEPTION_NAMES at module load — + // see that constant for the source-of-truth and exclusions. + const WEBAUTHN_ERROR_NAMES = ${JSON.stringify(WEBAUTHN_DOM_EXCEPTION_NAMES)}; try { return await fn(options, window.location.origin); } catch (err) { // exposeFunction serialises Errors; rebuild a DOMException so the // fxa-settings webauthn-errors handler categorises correctly. - const errName = (err && err.name) || 'NotAllowedError'; + const incoming = err && err.name; + const errName = + incoming && WEBAUTHN_ERROR_NAMES.indexOf(incoming) !== -1 + ? incoming + : 'NotAllowedError'; const msg = (err && err.message) || 'WebAuthn operation failed'; throw new DOMException(msg, errName); } diff --git a/packages/functional-tests/pages/signin.ts b/packages/functional-tests/pages/signin.ts index 8cf10103f12..3352b550b07 100644 --- a/packages/functional-tests/pages/signin.ts +++ b/packages/functional-tests/pages/signin.ts @@ -78,7 +78,11 @@ export class SigninPage extends PasskeyPage { } get signInButton() { - return this.page.getByRole('button', { name: 'Sign in' }); + return this.page.getByRole('button', { name: 'Sign in', exact: true }); + } + + get passkeySigninButton() { + return this.page.getByRole('button', { name: 'Sign in with passkey' }); } get signinBouncedHeading() { diff --git a/packages/functional-tests/tests/passkeys/passkeys.spec.ts b/packages/functional-tests/tests/_reference/passkey-virtual-authenticator.spec.ts similarity index 82% rename from packages/functional-tests/tests/passkeys/passkeys.spec.ts rename to packages/functional-tests/tests/_reference/passkey-virtual-authenticator.spec.ts index 11285a3d905..6e8b9eded36 100644 --- a/packages/functional-tests/tests/passkeys/passkeys.spec.ts +++ b/packages/functional-tests/tests/_reference/passkey-virtual-authenticator.spec.ts @@ -5,19 +5,19 @@ import { expect, test } from '../../lib/fixtures/standard'; /** - * These tests use a demo site at https://passkeys.eu which is a simple - * webauthn/passkeys registration and authentication demo. + * Reference-only suite. The in-house passkey functional tests live at + * `tests/settings/passkey.spec.ts` — those are what runs in CI against our + * own surfaces. * - * The site allows any origin to register and authenticate passkeys, making it - * suitable for testing purposes and making sure the `PasskeyVirtualAuthenticator` - * wrapper works as expected. Much of the logic and complexity here can be moved - * into the signIn/signUp pages, but for now this keeps things simple and readable + * This file exercises the `PasskeyVirtualAuthenticator` wrapper against the + * https://passkeys.eu demo site (any-origin registration/authentication). + * It cannot run in CI because the third-party site blocks us, so the suite + * is skipped wholesale. Kept as a worked example of the CDP / + * virtual-authenticator setup pattern. */ test.describe('severity-1 #smoke', () => { - // Skip all tests in this suite because they cannot run in CI (third party site blocks us). - // But, leaving here for reference until we implement Passkey support and tests in our own app. - test.skip(true, 'Tests use a demo site and are not part of smoke suite'); + test.skip(true, 'Reference-only — see tests/settings/passkey.spec.ts'); /** * Passkeys have a potential to collide with other tests due to the use of * CDP sessions and virtual authenticators. To avoid this, we run all passkey diff --git a/packages/functional-tests/tests/passkeyAuth/passkey-signin.spec.ts b/packages/functional-tests/tests/passkeyAuth/passkey-signin.spec.ts new file mode 100644 index 00000000000..a7e30528c24 --- /dev/null +++ b/packages/functional-tests/tests/passkeyAuth/passkey-signin.spec.ts @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * Passkey sign-in flow. Requires the same flags as the registration suite + * (FEATURE_FLAGS_PASSKEYS_ENABLED, FEATURE_FLAGS_PASSKEY_REGISTRATION_ENABLED) + * plus FEATURE_FLAGS_PASSKEY_AUTHENTICATION_ENABLED on the content server, and + * passkeys.enabled=true on the auth-server. The suite is skipped at runtime + * if any of those report as disabled. + */ + +import { Page, expect, test } from '../../lib/fixtures/standard'; +import { BaseTarget, Credentials } from '../../lib/targets/base'; +import { TestAccountTracker } from '../../lib/testAccountTracker'; +import { SettingsPage } from '../../pages/settings'; +import { SettingsPasskeyAddPage } from '../../pages/settings/passkey'; +import { SigninPage } from '../../pages/signin'; + +test.describe('severity-1 #smoke', () => { + test.describe('passkey sign-in', () => { + test.beforeEach(async ({ pages: { configPage } }) => { + const config = await configPage.getConfig(); + test.skip( + !config.featureFlags?.passkeysEnabled || + !config.featureFlags?.passkeyRegistrationEnabled || + !config.featureFlags?.passkeyAuthenticationEnabled, + 'Passkey feature flags are not enabled' + ); + }); + + test('signs in with a registered passkey from email-first', async ({ + target, + pages: { page, settings, settingsPasskeyAdd, signin }, + testAccountTracker, + }) => { + await setUpAccountWithPasskey({ + target, + page, + settings, + settingsPasskeyAdd, + signin, + testAccountTracker, + }); + await clearSession(page); + await page.goto(target.contentServerUrl); + + await settingsPasskeyAdd.passkeyAuth.assertion(async () => { + await signin.passkeySigninButton.click(); + await page.waitForURL(/settings/); + }); + + await expect(settings.settingsHeading).toBeVisible(); + }); + + test('signs in with a registered passkey from /signin after submitting an email', async ({ + target, + pages: { page, settings, settingsPasskeyAdd, signin }, + testAccountTracker, + }) => { + const { email } = await setUpAccountWithPasskey({ + target, + page, + settings, + settingsPasskeyAdd, + signin, + testAccountTracker, + }); + await clearSession(page); + await page.goto(target.contentServerUrl); + + // Submit the email on email-first to land on /signin's password form, + // then choose passkey from there instead of typing the password. + await signin.fillOutEmailFirstForm(email); + await expect(signin.passwordFormHeading).toBeVisible(); + + await settingsPasskeyAdd.passkeyAuth.assertion(async () => { + await signin.passkeySigninButton.click(); + await page.waitForURL(/settings/); + }); + + await expect(settings.settingsHeading).toBeVisible(); + }); + + test('shows an error banner when the user cancels the WebAuthn prompt', async ({ + target, + pages: { page, settings, settingsPasskeyAdd, signin }, + testAccountTracker, + }) => { + await setUpAccountWithPasskey({ + target, + page, + settings, + settingsPasskeyAdd, + signin, + testAccountTracker, + }); + await clearSession(page); + await page.goto(target.contentServerUrl); + + const signinUrl = page.url(); + + await settingsPasskeyAdd.passkeyAuth.fail( + async () => { + await signin.passkeySigninButton.click(); + }, + async () => { + // Banner renders with role="alert" for error type. Match by role + // and assert it contains *some* mention of the failed sign-in to + // avoid coupling to the exact localized string. + await expect(page.getByRole('alert')).toContainText( + /Sign-in with passkey failed/i + ); + } + ); + + // The hook keeps the user on the email-first page after a cancelled + // ceremony so they can retry or choose another method. + expect(page.url()).toBe(signinUrl); + }); + + test('shows the "passkey not recognized" banner when the server no longer knows the credential', async ({ + target, + pages: { page, settings, settingsPasskeyAdd, signin }, + testAccountTracker, + }) => { + const { email } = await setUpAccountWithPasskey({ + target, + page, + settings, + settingsPasskeyAdd, + signin, + testAccountTracker, + }); + + // Delete the passkey server-side; the polyfill keeps the credential in + // memory so a sign-in attempt still produces an assertion for the now + // unknown credentialId — exactly the divergence PASSKEY_NOT_FOUND covers + // (e.g. user deleted from another device, authenticator retained it). + await settings.deletePasskey(email); + await expect(settings.passkey.status).toHaveText('Not set'); + + await clearSession(page); + await page.goto(target.contentServerUrl); + + await settingsPasskeyAdd.passkeyAuth.assertion(async () => { + await signin.passkeySigninButton.click(); + await expect(page.getByRole('alert')).toContainText( + /Passkey not recognized/i + ); + }); + }); + }); +}); + +/** + * Signs up a fresh account, signs in with password, then registers a passkey + * via Settings. Leaves the user on the Settings page with a polyfill-minted + * credential available to subsequent sign-in attempts. + */ +async function setUpAccountWithPasskey({ + target, + page, + settings, + settingsPasskeyAdd, + signin, + testAccountTracker, +}: { + target: BaseTarget; + page: Page; + settings: SettingsPage; + settingsPasskeyAdd: SettingsPasskeyAddPage; + signin: SigninPage; + testAccountTracker: TestAccountTracker; +}): Promise { + const credentials = await testAccountTracker.signUp(); + await page.goto(target.contentServerUrl); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await page.waitForURL(/settings/); + await expect(settings.settingsHeading).toBeVisible(); + + await settingsPasskeyAdd.registerNewPasskey(settings, credentials.email); + + return credentials; +} + +/** + * Drops cookies and localStorage so the next visit to the content server is + * treated as fully signed-out. The page-level passkey polyfill (installed via + * `page.addInitScript`) survives the clear, so previously minted credentials + * remain discoverable in the next ceremony. + */ +async function clearSession(page: Page) { + await page.context().clearCookies(); + await page.evaluate(() => localStorage.clear()); +} diff --git a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts index 33dec5d671b..690c9233c65 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts @@ -132,9 +132,10 @@ describe('passkeys routes', () => { emailVerified: true, verifierSetAt: 1234567890, }), - createPasskeyVerifiedSessionToken: jest - .fn() - .mockResolvedValue({ id: 'new-session-token-id' }), + createPasskeyVerifiedSessionToken: jest.fn().mockResolvedValue({ + id: 'new-session-token-id', + data: 'new-session-token-data', + }), securityEvent: jest.fn().mockResolvedValue(undefined), }; @@ -1044,7 +1045,7 @@ describe('passkeys routes', () => { ); expect(result).toEqual({ uid: UID, - sessionToken: 'new-session-token-id', + sessionToken: 'new-session-token-data', verified: true, requiresPasswordForSync: false, hasPassword: true, @@ -1238,7 +1239,10 @@ describe('passkeys routes', () => { mockRequest as any ); - expect(result).toEqual({ id: 'new-session-token-id' }); + expect(result).toEqual({ + id: 'new-session-token-id', + data: 'new-session-token-data', + }); expect(statsd.increment).toHaveBeenCalledWith( 'passkeys.createSessionToken.success' ); diff --git a/packages/fxa-auth-server/lib/routes/passkeys.ts b/packages/fxa-auth-server/lib/routes/passkeys.ts index 78af26d912a..9d7e7ef0dc8 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.ts @@ -65,7 +65,7 @@ interface DB { uaOSVersion?: string; uaDeviceType?: string; uaFormFactor?: string; - }): Promise<{ id: string }>; + }): Promise<{ id: string; data: string }>; /** Records a security event in the audit log. */ securityEvent: (arg: any) => Promise; } @@ -429,7 +429,7 @@ export class PasskeyHandler { return { uid, - sessionToken: sessionToken.id, + sessionToken: sessionToken.data, verified: true, requiresPasswordForSync, hasPassword, diff --git a/packages/fxa-content-server/server/config/local.json-dist b/packages/fxa-content-server/server/config/local.json-dist index 26debf760cf..c50f253ff29 100644 --- a/packages/fxa-content-server/server/config/local.json-dist +++ b/packages/fxa-content-server/server/config/local.json-dist @@ -78,7 +78,7 @@ "paymentsNextSubscriptionManagement": true, "passkeysEnabled": true, "passkeyRegistrationEnabled": true, - "passkeyAuthenticationEnabled": false, + "passkeyAuthenticationEnabled": true, "passwordlessEnabled": true }, "darkMode": { diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 36a132686a2..b38a14469c1 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -572,6 +572,10 @@ const AuthAndAccountSetupRoutes = ({ path="/post_verify/third_party_auth/set_password/*" {...{ flowQueryParams, integration, useFxAStatusResult }} /> + ( export const CheckmarkCircleOutlineCurrent = () => ( ); +export const ChevronRight = () => ; export const Close = () => ; export const Code = () => ; export const ErrorOutlineCurrent = () => ; @@ -80,3 +85,6 @@ export const InformationCircleOutlineCurrent = () => ( ); export const Lightbulb = () => ; +export const LoadingArrow = () => ( + +); diff --git a/packages/fxa-settings/src/lib/passkeys/en.ftl b/packages/fxa-settings/src/lib/passkeys/en.ftl index 155efcb4a31..5c42930400c 100644 --- a/packages/fxa-settings/src/lib/passkeys/en.ftl +++ b/packages/fxa-settings/src/lib/passkeys/en.ftl @@ -62,3 +62,8 @@ passkey-authentication-error-not-readable = We couldn’t access the authenticat # Catch-all for unexpected errors during authentication (TypeError, DataError, EncodingError, ConstraintError, OperationError, UnknownError) passkey-authentication-error-unexpected = Something went wrong. Try again or choose another sign-in method. + +# Server returned 404 PASSKEY_NOT_FOUND — the assertion was for a credential +# that no longer exists on the account (e.g., the user deleted the passkey +# from their account but the authenticator still has the credential). +passkey-authentication-error-not-found = Passkey not recognized. Use another sign-in method. diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx new file mode 100644 index 00000000000..3f93aae269e --- /dev/null +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx @@ -0,0 +1,514 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import * as Sentry from '@sentry/browser'; +import { FtlMsgResolver } from 'fxa-react/lib/utils'; +import { + usePasskeySignIn, + type PasskeySignInAuthClient, + type PasskeySignInIntegration, +} from './signin-flow'; +import { getCredential, isWebAuthnLevel3Supported } from './webauthn'; +import { storeAccountData } from '../storage-utils'; +import { AuthUiErrors } from '../auth-errors/auth-errors'; +import { IntegrationType } from '../../models'; +import { + ensureCanLinkAcountOrRedirect, + handleNavigation, +} from '../../pages/Signin/utils'; + +jest.mock('./webauthn', () => ({ + __esModule: true, + ...jest.requireActual('./webauthn'), + isWebAuthnLevel3Supported: jest.fn(), + getCredential: jest.fn(), +})); + +jest.mock('../../pages/Signin/utils', () => ({ + __esModule: true, + ensureCanLinkAcountOrRedirect: jest.fn(), + handleNavigation: jest.fn(), +})); + +jest.mock('@sentry/browser', () => ({ + __esModule: true, + captureException: jest.fn(), +})); + +jest.mock('../storage-utils', () => ({ + __esModule: true, + storeAccountData: jest.fn(), +})); + +const SESSION_TOKEN = 'session-token'; +const UID = 'uid-123'; +const EMAIL = 'user@example.com'; +const CHALLENGE = 'mock-challenge'; +const MOCK_CREDENTIAL = { + id: 'cred-id', + rawId: 'cred-raw-id', + type: 'public-key', + response: {}, + clientExtensionResults: {}, +}; + +const buildArgs = ( + overrides: Partial[0]> = {} +) => { + const beginPasskeyAuthentication = jest + .fn() + .mockResolvedValue({ challenge: CHALLENGE, userVerification: 'required' }); + const completePasskeyAuthentication = jest.fn().mockResolvedValue({ + uid: UID, + sessionToken: SESSION_TOKEN, + verified: true, + requiresPasswordForSync: false, + hasPassword: true, + }); + const account = jest.fn().mockResolvedValue({ + emails: [{ email: EMAIL, isPrimary: true, verified: true }], + }); + // Cast through `unknown` — jest.fn() doesn't match Pick<>'s specific signatures. + const authClient = { + beginPasskeyAuthentication, + completePasskeyAuthentication, + account, + } as unknown as PasskeySignInAuthClient; + const integration = { + isSync: () => false, + isFirefoxNonSync: () => false, + getService: () => 'service-id', + type: IntegrationType.OAuthWeb, + data: {}, + } as unknown as PasskeySignInIntegration; + const finishOAuthFlowHandler = jest.fn(); + const ftlMsgResolver = { + getMsg: jest.fn((_id: string, fallback: string) => fallback), + } as unknown as FtlMsgResolver; + const navigateWithQuery = jest.fn(); + + return { + args: { + integration, + authClient, + finishOAuthFlowHandler, + ftlMsgResolver, + navigateWithQuery, + queryParams: '', + ...overrides, + }, + spies: { + beginPasskeyAuthentication, + completePasskeyAuthentication, + account, + finishOAuthFlowHandler, + ftlMsgResolver, + navigateWithQuery, + }, + }; +}; + +beforeEach(() => { + jest.clearAllMocks(); + (isWebAuthnLevel3Supported as jest.Mock).mockReturnValue(true); + (getCredential as jest.Mock).mockResolvedValue(MOCK_CREDENTIAL); + (ensureCanLinkAcountOrRedirect as jest.Mock).mockResolvedValue(true); + (handleNavigation as jest.Mock).mockResolvedValue({ error: undefined }); +}); + +describe('usePasskeySignIn', () => { + it('shows a banner and skips the ceremony when WebAuthn is unsupported', async () => { + (isWebAuthnLevel3Supported as jest.Mock).mockReturnValue(false); + const { args, spies } = buildArgs(); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + result.current.onClick(); + }); + + expect(spies.beginPasskeyAuthentication).not.toHaveBeenCalled(); + expect(result.current.errorBanner).toBeDefined(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + 'passkey-authentication-error-not-supported-v2', + 'Your browser or device doesn’t support passkeys.' + ); + }); + + it('completes the OAuth flow via handleNavigation when no Sync password is required', async () => { + const { args, spies } = buildArgs(); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(spies.beginPasskeyAuthentication).toHaveBeenCalledTimes(1); + expect(spies.completePasskeyAuthentication).toHaveBeenCalledWith( + MOCK_CREDENTIAL, + CHALLENGE, + { service: 'service-id' } + ); + expect(spies.account).toHaveBeenCalledWith(SESSION_TOKEN); + expect(handleNavigation).toHaveBeenCalledWith({ + email: EMAIL, + signinData: { + uid: UID, + sessionToken: SESSION_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: undefined, + verificationReason: undefined, + }, + integration: args.integration, + finishOAuthFlowHandler: spies.finishOAuthFlowHandler, + queryParams: '', + handleFxaLogin: true, + handleFxaOAuthLogin: true, + performNavigation: true, + }); + expect(storeAccountData).toHaveBeenCalledWith({ + email: EMAIL, + uid: UID, + lastLogin: expect.any(Number), + sessionToken: SESSION_TOKEN, + verified: true, + sessionVerified: true, + hasPassword: true, + }); + }); + + it('routes non-OAuth Web integrations through handleNavigation', async () => { + // Soft-navigate to /settings happens inside handleNavigation (same path + // as password sign-in). hardNavigate would cause a cached-signin flash. + const { args, spies } = buildArgs({ + integration: { + isSync: () => false, + isFirefoxNonSync: () => false, + getService: () => 'service-id', + type: IntegrationType.Web, + data: {}, + } as unknown as PasskeySignInIntegration, + }); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(storeAccountData).toHaveBeenCalled(); + expect(handleNavigation).toHaveBeenCalledWith({ + email: EMAIL, + signinData: { + uid: UID, + sessionToken: SESSION_TOKEN, + emailVerified: true, + sessionVerified: true, + verificationMethod: undefined, + verificationReason: undefined, + }, + integration: args.integration, + finishOAuthFlowHandler: spies.finishOAuthFlowHandler, + queryParams: '', + handleFxaLogin: true, + handleFxaOAuthLogin: true, + performNavigation: true, + }); + }); + + it('runs the Sync merge gate and aborts when the user cancels', async () => { + (ensureCanLinkAcountOrRedirect as jest.Mock).mockResolvedValue(false); + const { args, spies } = buildArgs({ + integration: { + isSync: () => true, + isFirefoxNonSync: () => false, + getService: () => 'sync', + type: 'oauth-native', + data: {}, + } as unknown as PasskeySignInIntegration, + }); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(ensureCanLinkAcountOrRedirect).toHaveBeenCalled(); + expect(handleNavigation).not.toHaveBeenCalled(); + expect(spies.navigateWithQuery).not.toHaveBeenCalled(); + // Merge gate runs before persistence — cancelling must not leave a + // ghost session in localStorage. + expect(storeAccountData).not.toHaveBeenCalled(); + }); + + it.each([ + [true, '/signin_passkey_fallback'], + [false, '/post_verify/passkey/set_password'], + ])( + 'routes Sync sign-ins (hasPassword=%s) to %s', + async (hasPassword, expectedPath) => { + const { args, spies } = buildArgs({ + integration: { + isSync: () => true, + isFirefoxNonSync: () => false, + getService: () => 'sync', + type: 'oauth-native', + data: {}, + } as unknown as PasskeySignInIntegration, + }); + spies.completePasskeyAuthentication.mockResolvedValue({ + uid: UID, + sessionToken: SESSION_TOKEN, + verified: true, + requiresPasswordForSync: true, + hasPassword, + }); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(spies.navigateWithQuery).toHaveBeenCalledWith(expectedPath, { + state: { + email: EMAIL, + uid: UID, + }, + }); + expect(handleNavigation).not.toHaveBeenCalled(); + } + ); + + // Locks the contract: each DOMException name maps to its expected FTL id. + // Drift means a categoriser regression lands silently. + it.each([ + ['NotAllowedError', 'passkey-authentication-error-not-allowed'], + ['AbortError', 'passkey-authentication-error-not-allowed'], + ['TimeoutError', 'passkey-authentication-error-timeout'], + ['NotSupportedError', 'passkey-authentication-error-not-supported-v2'], + ['SecurityError', 'passkey-authentication-error-security'], + ['InvalidStateError', 'passkey-authentication-error-invalid-state'], + ['NotReadableError', 'passkey-authentication-error-not-readable'], + ])( + 'categorises WebAuthn DOMException %s and surfaces %s', + async (errorName, expectedFtlId) => { + (getCredential as jest.Mock).mockRejectedValue( + new DOMException('cancelled', errorName) + ); + const { args, spies } = buildArgs(); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(handleNavigation).not.toHaveBeenCalled(); + expect(result.current.errorBanner).toBeDefined(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + expectedFtlId, + expect.any(String) + ); + } + ); + + it('treats beginPasskeyAuthentication rejection as a server error', async () => { + const { args, spies } = buildArgs(); + spies.beginPasskeyAuthentication.mockRejectedValue( + new Error('network down') + ); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(result.current.errorBanner).toBeDefined(); + expect(spies.completePasskeyAuthentication).not.toHaveBeenCalled(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + 'passkey-authentication-error-unexpected', + expect.any(String) + ); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'passkey-signin error' }), + { tags: { errno: 'none' } } + ); + }); + + it('re-throws non-WebAuthn errors from getCredential to the generic handler', async () => { + // Non-DOMException/TypeError errors must bubble to the outer catch, not + // the WebAuthn categoriser. + (getCredential as jest.Mock).mockRejectedValue( + new Error('unexpected sync failure') + ); + const { args, spies } = buildArgs(); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(result.current.errorBanner).toBeDefined(); + expect(spies.completePasskeyAuthentication).not.toHaveBeenCalled(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + 'passkey-authentication-error-unexpected', + expect.any(String) + ); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'passkey-signin error' }), + { tags: { errno: 'none' } } + ); + }); + + it('treats unknown thrown errors as a server error', async () => { + const { args, spies } = buildArgs(); + spies.completePasskeyAuthentication.mockRejectedValue(new Error('boom')); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(result.current.errorBanner).toBeDefined(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + 'passkey-authentication-error-unexpected', + expect.any(String) + ); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'passkey-signin error' }), + { tags: { errno: 'none' } } + ); + }); + + it('treats account rejection as a server error and skips persistence', async () => { + const { args, spies } = buildArgs(); + spies.account.mockRejectedValue(new Error('account fetch failed')); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(result.current.errorBanner).toBeDefined(); + expect(storeAccountData).not.toHaveBeenCalled(); + expect(handleNavigation).not.toHaveBeenCalled(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + 'passkey-authentication-error-unexpected', + expect.any(String) + ); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'passkey-signin error' }), + { tags: { errno: 'none' } } + ); + }); + + it('treats an account response with no primary email as a server error', async () => { + const { args, spies } = buildArgs(); + spies.account.mockResolvedValue({ emails: [] }); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(result.current.errorBanner).toBeDefined(); + expect(storeAccountData).not.toHaveBeenCalled(); + expect(handleNavigation).not.toHaveBeenCalled(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + 'passkey-authentication-error-unexpected', + expect.any(String) + ); + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'passkey-signin error' }), + { tags: { errno: 'none' } } + ); + }); + + it('surfaces a banner when handleNavigation returns an error', async () => { + const navError = new Error('OAuth completion failed'); + (handleNavigation as jest.Mock).mockResolvedValue({ error: navError }); + const { args, spies } = buildArgs(); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(result.current.errorBanner).toBeDefined(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + 'passkey-authentication-error-unexpected', + expect.any(String) + ); + // handleNavigation errors come from an internal helper, not the network, + // so no PII-sanitisation wrapper is applied. + expect(Sentry.captureException as jest.Mock).toHaveBeenCalledWith(navError); + }); + + it('shows the "passkey not recognized" banner on errno PASSKEY_NOT_FOUND', async () => { + const { args, spies } = buildArgs(); + spies.completePasskeyAuthentication.mockRejectedValue( + Object.assign(new Error('Passkey not found'), { + errno: AuthUiErrors.PASSKEY_NOT_FOUND.errno, + }) + ); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(result.current.errorBanner).toBeDefined(); + expect(spies.ftlMsgResolver.getMsg).toHaveBeenCalledWith( + 'passkey-authentication-error-not-found', + expect.any(String) + ); + // Should NOT be reported to Sentry — it's an expected divergence between + // server state and authenticator state. + expect(Sentry.captureException as jest.Mock).not.toHaveBeenCalled(); + }); + + it('ignores additional clicks while a ceremony is in flight', async () => { + type AccountResponse = { + emails: Array<{ email: string; isPrimary: boolean; verified: boolean }>; + }; + let resolveAccount: (v: AccountResponse) => void; + const accountPromise = new Promise((resolve) => { + resolveAccount = resolve; + }); + const { args, spies } = buildArgs(); + spies.account.mockReturnValue(accountPromise); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + result.current.onClick(); + result.current.onClick(); + }); + + expect(spies.beginPasskeyAuthentication).toHaveBeenCalledTimes(1); + await act(async () => { + resolveAccount!({ + emails: [{ email: EMAIL, isPrimary: true, verified: true }], + }); + await accountPromise; + }); + // After the in-flight ceremony resolves, downstream side effects must + // each fire exactly once — proving the second click was suppressed end + // to end, not just at the entry point. + expect(storeAccountData).toHaveBeenCalledTimes(1); + expect(handleNavigation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts new file mode 100644 index 00000000000..de41bdc5f46 --- /dev/null +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts @@ -0,0 +1,263 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import * as Sentry from '@sentry/browser'; +import type AuthClient from 'fxa-auth-client/browser'; +import { FtlMsgResolver } from 'fxa-react/lib/utils'; + +import Banner from '../../components/Banner'; +import { AuthUiErrors } from '../auth-errors/auth-errors'; +import { useNavigateWithQuery } from '../hooks/useNavigateWithQuery'; +import { FinishOAuthFlowHandler } from '../oauth/hooks'; +import { storeAccountData } from '../storage-utils'; +import { NavigationOptions } from '../../pages/Signin/interfaces'; +import { + ensureCanLinkAcountOrRedirect, + handleNavigation, +} from '../../pages/Signin/utils'; +import { + getCredential, + handleWebAuthnError, + isWebAuthnLevel3Supported, + type PublicKeyCredentialJSON, +} from './'; + +/** + * TODO(FXA-13099 follow-up): IndexIntegration widened to satisfy this. + * Narrowing it back needs handleNavigation's integration param to have + * its own structural type. + */ +export type PasskeySignInIntegration = NavigationOptions['integration']; + +/** Pick<> so tests can pass minimal mocks without `as any`. */ +export type PasskeySignInAuthClient = Pick< + AuthClient, + 'beginPasskeyAuthentication' | 'completePasskeyAuthentication' | 'account' +>; + +export interface UsePasskeySignInArgs { + integration: PasskeySignInIntegration; + authClient: PasskeySignInAuthClient; + finishOAuthFlowHandler: FinishOAuthFlowHandler; + ftlMsgResolver: FtlMsgResolver; + navigateWithQuery: ReturnType; + queryParams: string; +} + +export interface UsePasskeySignInResult { + isLoading: boolean; + errorBanner: React.ReactNode | undefined; + onClick: () => Promise; +} + +export function usePasskeySignIn({ + integration, + authClient, + finishOAuthFlowHandler, + ftlMsgResolver, + navigateWithQuery, + queryParams, +}: UsePasskeySignInArgs): UsePasskeySignInResult { + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const inFlight = useRef(false); + + const errorBanner = useMemo( + () => + errorMessage + ? React.createElement(Banner, { + type: 'error', + content: { localizedHeading: errorMessage }, + }) + : undefined, + [errorMessage] + ); + + const setLocalizedError = useCallback( + (ftlId: string, fallback: string) => { + setErrorMessage(ftlMsgResolver.getMsg(ftlId, fallback)); + }, + [ftlMsgResolver] + ); + + const onClick = useCallback(async () => { + if (inFlight.current) { + return; + } + setErrorMessage(undefined); + + // Button stays visible even without support — the user may have a passkey + // on another device and deserves an explicit error rather than a silently + // missing option. + if (!isWebAuthnLevel3Supported()) { + setLocalizedError( + 'passkey-authentication-error-not-supported-v2', + 'Your browser or device doesn’t support passkeys.' + ); + return; + } + + // Defensive: webchannel-driven OAuth may leave this component mounted on + // success; without finish() the button stays stuck in loading. + const finish = () => { + inFlight.current = false; + setIsLoading(false); + }; + const setUnexpectedError = () => + setLocalizedError( + 'passkey-authentication-error-unexpected', + 'Something went wrong. Try again or choose another sign-in method.' + ); + + inFlight.current = true; + setIsLoading(true); + + try { + // Discoverable credentials only — the Signin page's email field is + // intentionally ignored. The browser surfaces all credentials for the + // RP and the user picks one. + const challengeOptions = await authClient.beginPasskeyAuthentication(); + + // Isolated try/catch so a network-layer TypeError (e.g. fetch failure) + // from surrounding auth-client calls can't be miscategorised as a + // WebAuthn error. + let credential: PublicKeyCredentialJSON; + try { + credential = await getCredential(challengeOptions); + } catch (err) { + if (err instanceof DOMException || err instanceof TypeError) { + const categorized = handleWebAuthnError( + err, + 'authentication', + Sentry.captureException + ); + setLocalizedError(categorized.ftlId, categorized.fallbackText); + finish(); + return; + } + throw err; + } + + const service = integration.getService(); + const completion = await authClient.completePasskeyAuthentication( + credential, + challengeOptions.challenge, + service ? { service } : {} + ); + + // Server response intentionally omits email — fetch it here. Fail + // closed if missing; downstream code (storeAccountData, can_link_account + // WebChannel, handleNavigation) would silently corrupt with undefined. + const account = await authClient.account(completion.sessionToken); + const email = account?.emails.find((e: any) => e.isPrimary)?.email; + if (typeof email !== 'string') { + throw new Error('Authenticated account response missing email'); + } + + // Runs before storeAccountData so a dismissed merge dialog doesn't + // leave a ghost session that Index would re-evaluate as signed-in. + if (integration.isSync() || integration.isFirefoxNonSync()) { + const canLink = await ensureCanLinkAcountOrRedirect({ + email, + uid: completion.uid, + ftlMsgResolver, + navigateWithQuery, + }); + if (!canLink) { + // Defensive finish() — ensureCanLinkAcountOrRedirect navigates + // away, but Index → Index with prefill keeps this component + // mounted and the button needs to be clickable again. + finish(); + return; + } + } + + // Mirrors Signin/container.tsx's persist-after-sign-in pattern. + storeAccountData({ + email, + uid: completion.uid, + lastLogin: Date.now(), + sessionToken: completion.sessionToken, + verified: completion.verified, + sessionVerified: completion.verified, + hasPassword: completion.hasPassword, + }); + + if (completion.requiresPasswordForSync) { + // Sync still needs a password to derive keys. URL encodes which leg: + // hasPassword=true → verify existing password (FXA-13100) + // hasPassword=false → set a new password + // Skip finish() — component unmounts on navigation. + const fallbackPath = completion.hasPassword + ? '/signin_passkey_fallback' + : '/post_verify/passkey/set_password'; + navigateWithQuery(fallbackPath, { + state: { + email, + uid: completion.uid, + }, + }); + return; + } + + // Delegate to handleNavigation (same path as password sign-in). + // hardNavigate here would briefly render the cached-signin view + // between storeAccountData and the deferred window.location assignment. + const { error: navError } = await handleNavigation({ + email, + signinData: { + uid: completion.uid, + sessionToken: completion.sessionToken, + // AAL2 by definition; email had to be verified to register a passkey. + emailVerified: true, + sessionVerified: completion.verified, + verificationMethod: undefined, + verificationReason: undefined, + }, + integration, + finishOAuthFlowHandler, + queryParams, + handleFxaLogin: true, + handleFxaOAuthLogin: true, + performNavigation: true, + }); + + if (navError) { + Sentry.captureException(navError); + setUnexpectedError(); + } + finish(); + } catch (err) { + const errno = (err as { errno?: number })?.errno; + if (errno === AuthUiErrors.PASSKEY_NOT_FOUND.errno) { + // Expected divergence between server state and authenticator state + // (e.g. user deleted the passkey elsewhere). Not Sentry-worthy. + setLocalizedError( + 'passkey-authentication-error-not-found', + 'Passkey not recognized. Use another sign-in method.' + ); + } else { + // Drop upstream err.message — backend shapes may include identifiers + // (uid, email) we can't enforce from here. errno tag + Sentry stack + // are enough for triage. + Sentry.captureException(new Error('passkey-signin error'), { + tags: { errno: String(errno ?? 'none') }, + }); + setUnexpectedError(); + } + finish(); + } + }, [ + integration, + authClient, + finishOAuthFlowHandler, + ftlMsgResolver, + navigateWithQuery, + queryParams, + setLocalizedError, + ]); + + return { isLoading, errorBanner, onClick }; +} diff --git a/packages/fxa-settings/src/lib/passkeys/webauthn-errors.test.ts b/packages/fxa-settings/src/lib/passkeys/webauthn-errors.test.ts index cc996ab96be..0acdfc3ba10 100644 --- a/packages/fxa-settings/src/lib/passkeys/webauthn-errors.test.ts +++ b/packages/fxa-settings/src/lib/passkeys/webauthn-errors.test.ts @@ -22,6 +22,7 @@ const OPERATIONS: WebAuthnOperation[] = ['registration', 'authentication']; describe('categorizeWebAuthnError — user-action errors (no Sentry)', () => { const cases: [string, WebAuthnErrorType][] = [ ['NotAllowedError', WebAuthnErrorType.NotAllowed], + ['AbortError', WebAuthnErrorType.Abort], ['TimeoutError', WebAuthnErrorType.Timeout], ]; diff --git a/packages/fxa-settings/src/lib/passkeys/webauthn-errors.ts b/packages/fxa-settings/src/lib/passkeys/webauthn-errors.ts index 3ce54c40d88..55f7fd50c74 100644 --- a/packages/fxa-settings/src/lib/passkeys/webauthn-errors.ts +++ b/packages/fxa-settings/src/lib/passkeys/webauthn-errors.ts @@ -22,6 +22,7 @@ export enum WebAuthnErrorCategory { export enum WebAuthnErrorType { NotAllowed = 'NotAllowedError', + Abort = 'AbortError', Timeout = 'TimeoutError', NotSupported = 'NotSupportedError', Security = 'SecurityError', @@ -94,6 +95,22 @@ export const ERROR_MAP: Record = { 'Sign-in with passkey failed or is unavailable. Try again or choose another method.', }, }, + // Shares NotAllowedError wording — both mean the ceremony was cancelled. + AbortError: { + category: WebAuthnErrorCategory.UserAction, + errorType: WebAuthnErrorType.Abort, + logToSentry: false, + ftlId: { + registration: 'passkey-registration-error-not-allowed', + authentication: 'passkey-authentication-error-not-allowed', + }, + fallbackText: { + registration: + 'Passkey setup failed or is unavailable. Try again or choose another method.', + authentication: + 'Sign-in with passkey failed or is unavailable. Try again or choose another method.', + }, + }, TimeoutError: { category: WebAuthnErrorCategory.UserAction, errorType: WebAuthnErrorType.Timeout, diff --git a/packages/fxa-settings/src/pages/Index/container.tsx b/packages/fxa-settings/src/pages/Index/container.tsx index 37301f8641b..02f37bb353f 100644 --- a/packages/fxa-settings/src/pages/Index/container.tsx +++ b/packages/fxa-settings/src/pages/Index/container.tsx @@ -40,6 +40,7 @@ import { hardNavigate } from 'fxa-react/lib/utils'; import { isMobileDevice } from '../../lib/utilities'; import AppLayout from '../../components/AppLayout'; import { IntegrationType } from '../../models/integrations/integration'; +import { useFinishOAuthFlowHandler } from '../../lib/oauth/hooks'; const IndexContainer = ({ integration, @@ -55,6 +56,10 @@ const IndexContainer = ({ const location = useLocation() as ReturnType & { state?: LocationState; }; + const { finishOAuthFlowHandler } = useFinishOAuthFlowHandler( + authClient, + integration + ); const [errorBannerMessage, setErrorBannerMessage] = useState( location.state?.localizedErrorFromLocationState || '' @@ -62,8 +67,13 @@ const IndexContainer = ({ const [successBannerMessage, setSuccessBannerMessage] = useState(''); const [tooltipErrorMessage, setTooltipErrorMessage] = useState(''); - // This must be a 'ref' because we don't want to trigger a re-render when the state changes - const attemptedEmailAutoSubmit = useRef(false); + // Cancelled on first user interaction so an in-flight auto-submit can't + // override the navigation the user chose. Ref (not state) — flipping + // shouldn't trigger a re-render. + const shouldAttemptAutoSubmit = useRef(true); + const disableAutoSubmit = useCallback(() => { + shouldAttemptAutoSubmit.current = false; + }, []); const [isLoading, setIsLoading] = useState(true); const { queryParamModel, validationError } = useValidatedQueryParams( @@ -385,7 +395,7 @@ const IndexContainer = ({ if (isUnsupportedContext(integration.data.context)) { hardNavigate('/update_firefox', {}, true); - } else if (shouldTrySuggestedEmail && !attemptedEmailAutoSubmit.current) { + } else if (shouldTrySuggestedEmail && shouldAttemptAutoSubmit.current) { // Must be in an async function or else `setIsLoading(false)` can be called prematurely with // the next render, before async actions in `processEmailSubmission` have finished (async () => { @@ -399,7 +409,7 @@ const IndexContainer = ({ } // Without this, can_link_account can fire multiple times due to calling it in a `useEffect`, // causing multiple sync warnings to be displayed. This ensures it is called once. - attemptedEmailAutoSubmit.current = true; + shouldAttemptAutoSubmit.current = false; await processEmailSubmission(suggestedEmail, false); })(); } else { @@ -407,7 +417,7 @@ const IndexContainer = ({ } }, [ ftlMsgResolver, - attemptedEmailAutoSubmit, + shouldAttemptAutoSubmit, browserUserChecked, navigateWithQuery, processEmailSubmission, @@ -466,6 +476,9 @@ const IndexContainer = ({ isMobile, useFxAStatusResult, setCurrentSplitLayout, + authClient, + finishOAuthFlowHandler, + disableAutoSubmit, }} prefillEmail={initialPrefill} /> diff --git a/packages/fxa-settings/src/pages/Index/index.tsx b/packages/fxa-settings/src/pages/Index/index.tsx index 3084ea440f9..149ea93a8eb 100644 --- a/packages/fxa-settings/src/pages/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Index/index.tsx @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import React, { useEffect, useRef, useState } from 'react'; +import { useLocation } from '@reach/router'; import { useForm } from 'react-hook-form'; import { IndexFormData, IndexProps } from './interfaces'; import AppLayout from '../../components/AppLayout'; @@ -14,11 +15,17 @@ import InputText from '../../components/InputText'; import { FtlMsg } from 'fxa-react/lib/utils'; import AlternativeAuthOptions from '../../components/AlternativeAuthOptions'; import TermsPrivacyAgreement from '../../components/TermsPrivacyAgreement'; -import { isOAuthNativeIntegration, useConfig } from '../../models'; +import { + isOAuthNativeIntegration, + useConfig, + useFtlMsgResolver, +} from '../../models'; import GleanMetrics from '../../lib/glean'; import Banner from '../../components/Banner'; import CmsButtonWithFallback from '../../components/CmsButtonWithFallback'; import CmsLogo from '../../components/CmsLogo'; +import { useNavigateWithQuery } from '../../lib/hooks/useNavigateWithQuery'; +import { usePasskeySignIn } from '../../lib/passkeys/signin-flow'; export const Index = ({ integration, @@ -35,8 +42,14 @@ export const Index = ({ isMobile, useFxAStatusResult, setCurrentSplitLayout, + authClient, + finishOAuthFlowHandler, + disableAutoSubmit, }: IndexProps) => { const config = useConfig(); + const ftlMsgResolver = useFtlMsgResolver(); + const navigateWithQuery = useNavigateWithQuery(); + const location = useLocation(); const showPasskeySignin = !!( config.featureFlags?.passkeysEnabled && config.featureFlags?.passkeyAuthenticationEnabled @@ -46,6 +59,21 @@ export const Index = ({ const isFirefoxClientServiceRelay = integration.isFirefoxClientServiceRelay(); const [isSubmitting, setIsSubmitting] = useState(false); + const passkey = usePasskeySignIn({ + integration, + authClient, + finishOAuthFlowHandler, + ftlMsgResolver, + navigateWithQuery, + queryParams: location.search, + }); + const handlePasskeyClick = () => { + // Cancel any pending suggested-email auto-submit so it can't override + // our /settings navigation after the ceremony writes localStorage. + disableAutoSubmit(); + passkey.onClick(); + }; + const legalTerms = integration.getLegalTerms(); const emailEngageEventEmitted = useRef(false); @@ -193,6 +221,8 @@ export const Index = ({ {isSync ? ( + // TODO(FXA-13100): re-add AlternativeAuthOptions on Sync once the + // fallback page can derive Sync keys.

A Mozilla account also unlocks access to more privacy-protecting @@ -206,6 +236,12 @@ export const Index = ({ useFxAStatusResult.supportsKeysOptionalLogin } showPasskeySignin={showPasskeySignin} + passkeySignIn={ + showPasskeySignin + ? { isLoading: passkey.isLoading, onClick: handlePasskeyClick } + : undefined + } + errorBanner={showPasskeySignin ? passkey.errorBanner : undefined} viewName="index" flowQueryParams={flowQueryParams} /> diff --git a/packages/fxa-settings/src/pages/Index/interfaces.ts b/packages/fxa-settings/src/pages/Index/interfaces.ts index e8528b9b834..779bf56663b 100644 --- a/packages/fxa-settings/src/pages/Index/interfaces.ts +++ b/packages/fxa-settings/src/pages/Index/interfaces.ts @@ -2,24 +2,37 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import AuthClient from 'fxa-auth-client/browser'; import { MozServices } from '../../lib/types'; import { Integration } from '../../models'; import { QueryParams } from '../../index'; import { UseFxAStatusResult } from '../../lib/hooks/useFxAStatus'; +import { FinishOAuthFlowHandler } from '../../lib/oauth/hooks'; export type IndexIntegration = Pick< Integration, | 'type' | 'isSync' + | 'isDesktopSync' | 'getClientId' | 'getService' + | 'isFirefoxClient' | 'isFirefoxClientServiceRelay' | 'isFirefoxClientServiceSmartWindow' | 'isFirefoxClientServiceVpn' + | 'isFirefoxDesktopClient' + | 'isFirefoxMobileClient' | 'isFirefoxNonSync' | 'data' | 'getCmsInfo' + | 'getGrantedScopes' | 'getLegalTerms' + | 'getWebChannelServices' + | 'requiresKeys' + | 'wantsKeys' + | 'wantsKeysIfPasswordEntered' + | 'wantsLogin' + | 'wantsTwoStepAuthentication' >; export interface IndexContainerProps { @@ -51,6 +64,10 @@ export interface IndexProps extends LocationState { isMobile: boolean; useFxAStatusResult: UseFxAStatusResult; setCurrentSplitLayout?: (value: boolean) => void; + authClient: AuthClient; + finishOAuthFlowHandler: FinishOAuthFlowHandler; + /** Cancel pending auto-submit so it can't override a user-chosen navigation. */ + disableAutoSubmit: () => void; } export interface IndexFormData { diff --git a/packages/fxa-settings/src/pages/Index/mocks.tsx b/packages/fxa-settings/src/pages/Index/mocks.tsx index 1a73c4a8744..f8336b634f1 100644 --- a/packages/fxa-settings/src/pages/Index/mocks.tsx +++ b/packages/fxa-settings/src/pages/Index/mocks.tsx @@ -12,6 +12,7 @@ import { OAuthWebIntegration, RelierCmsInfo, } from '../../models'; +import type AuthClient from 'fxa-auth-client/browser'; import { IndexIntegration } from './interfaces'; import Index from '.'; import { MOCK_CLIENT_ID } from '../mocks'; @@ -66,17 +67,28 @@ export function createMockIndexOAuthNativeIntegration({ return { type: IntegrationType.OAuthNative, isSync: () => isSync, + isDesktopSync: () => isSync, getClientId: () => MOCK_CLIENT_ID, getService: () => MOCK_CLIENT_ID, + isFirefoxClient: () => true, isFirefoxClientServiceRelay: () => isFirefoxClientServiceRelay, isFirefoxClientServiceSmartWindow: () => isFirefoxClientServiceSmartWindow, isFirefoxClientServiceVpn: () => isFirefoxClientServiceVpn, + isFirefoxDesktopClient: () => false, + isFirefoxMobileClient: () => false, isFirefoxNonSync: () => isFirefoxClientServiceRelay || isFirefoxClientServiceSmartWindow || isFirefoxClientServiceVpn, getCmsInfo: () => cmsInfo, + getGrantedScopes: () => undefined, getLegalTerms: () => undefined, + getWebChannelServices: () => undefined, + requiresKeys: () => false, + wantsKeys: () => false, + wantsKeysIfPasswordEntered: () => false, + wantsLogin: () => false, + wantsTwoStepAuthentication: () => false, data: new OAuthIntegrationData( new GenericData({ context: Constants.OAUTH_WEBCHANNEL_CONTEXT, @@ -89,14 +101,25 @@ export function createMockIndexWebIntegration(): IndexIntegration { return { type: IntegrationType.Web, isSync: () => false, + isDesktopSync: () => false, getClientId: () => undefined, getService: () => undefined, + isFirefoxClient: () => false, isFirefoxClientServiceRelay: () => false, isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, + isFirefoxDesktopClient: () => false, + isFirefoxMobileClient: () => false, isFirefoxNonSync: () => false, getCmsInfo: () => undefined, + getGrantedScopes: () => undefined, getLegalTerms: () => undefined, + getWebChannelServices: () => undefined, + requiresKeys: () => false, + wantsKeys: () => false, + wantsKeysIfPasswordEntered: () => false, + wantsLogin: () => false, + wantsTwoStepAuthentication: () => false, data: new IntegrationData( new GenericData({ context: '', @@ -138,6 +161,20 @@ export const Subject = ({ {}} + disableAutoSubmit={() => {}} + authClient={ + { + beginPasskeyAuthentication: jest.fn(), + completePasskeyAuthentication: jest.fn(), + accountProfile: jest.fn(), + } as unknown as AuthClient + } + finishOAuthFlowHandler={async () => ({ + redirect: 'http://example.com', + code: 'mock-code', + state: 'mock-state', + error: undefined, + })} {...{ prefillEmail, integration, diff --git a/packages/fxa-settings/src/pages/Signin/index.tsx b/packages/fxa-settings/src/pages/Signin/index.tsx index 8da7914e0cb..3595c9f67bc 100644 --- a/packages/fxa-settings/src/pages/Signin/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.tsx @@ -26,8 +26,10 @@ import { isOAuthIntegration, isOAuthNativeIntegration, isOAuthWebIntegration, + useAuthClient, useConfig, } from '../../models'; +import { usePasskeySignIn } from '../../lib/passkeys/signin-flow'; import { SigninFormData, SigninProps } from './interfaces'; import { handleNavigation, ensureCanLinkAcountOrRedirect } from './utils'; import { useWebRedirect } from '../../lib/hooks/useWebRedirect'; @@ -69,6 +71,16 @@ const Signin = ({ const ftlMsgResolver = useFtlMsgResolver(); const webRedirectCheck = useWebRedirect(integration.data.redirectTo); const sensitiveDataClient = useSensitiveDataClient(); + const authClient = useAuthClient(); + + const passkey = usePasskeySignIn({ + integration, + authClient, + finishOAuthFlowHandler, + ftlMsgResolver, + navigateWithQuery, + queryParams: location.search, + }); const [localizedBannerError, setLocalizedBannerError] = useState( localizedErrorFromLocationState || '' @@ -104,10 +116,15 @@ const Signin = ({ const [hasCachedAccount, setHasCachedAccount] = useState(!!sessionToken); + // Hidden for cached signin: re-auth for the same account already has its + // own button. Hidden for Sync until FXA-13100 ships the real key-deriving + // fallback at /signin_passkey_fallback (today a stub that doesn't derive + // Sync keys). const showPasskeySignin = !!( config.featureFlags?.passkeysEnabled && config.featureFlags?.passkeyAuthenticationEnabled && - !hasCachedAccount + !hasCachedAccount && + !integration.isSync() ); // Relay browser service login launched in Firefox desktop 135, and the "keys optional" @@ -603,6 +620,12 @@ const Signin = ({ showThirdPartyAuth={!hideThirdPartyAuth} showPasskeySignin={showPasskeySignin} isStandalone={hasLinkedAccountAndNoPassword} + passkeySignIn={ + showPasskeySignin + ? { isLoading: passkey.isLoading, onClick: passkey.onClick } + : undefined + } + errorBanner={showPasskeySignin ? passkey.errorBanner : undefined} {...{ viewName, flowQueryParams }} />