From 12411c5de13d1d7cfd749b11cf4215074e06fe20 Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Wed, 8 Jul 2026 16:51:11 -0700 Subject: [PATCH] feat(settings): pre-keys keyless login for desktop passkey Sync sign-in Because: - A keys-required (Sync) passkey sign-in withheld both WebChannel messages until after the password step, so the browser reflected nothing as signed-in until keys were ready. - The keys-optional check lived in the fxa_status hook, conflating the raw browser capability with per-integration client requirements. This commit: - Fires a keyless fxaccounts:login right after passkey verification on desktop when the browser advertises keys_optional; defers only the keyed oauth_login until keys exist (Sync enables once keys arrive). - useFxAStatus now reports only the raw browser capability (browserSupportsKeysOptional). The per-login keys policy moves onto the integration model: requiresPasswordForLogin, supportsKeylessLogin, allowsPreKeysSyncLogin (unit-tested). - Renames the threaded supportsKeysOptionalLogin prop to browserSupportsKeysOptional across settings. - No functional change for non-Sync integrations: supportsKeylessLogin reproduces the prior derived value (isFirefoxNonSync implies isOAuthNative). Closes #FXA-14129 --- .../fxa-settings/src/components/App/index.tsx | 570 +++++++++++++----- .../src/lib/hooks/useFxAStatus/index.test.tsx | 27 +- .../src/lib/hooks/useFxAStatus/index.tsx | 22 +- .../src/lib/hooks/useFxAStatus/mocks.tsx | 6 +- .../src/lib/passkeys/signin-flow.test.tsx | 130 +++- .../src/lib/passkeys/signin-flow.ts | 41 +- .../models/integrations/integration.test.ts | 43 ++ .../src/models/integrations/integration.ts | 52 +- .../src/pages/Index/index.stories.tsx | 6 +- .../src/pages/Index/index.test.tsx | 8 +- .../fxa-settings/src/pages/Index/index.tsx | 6 +- .../src/pages/Index/interfaces.ts | 3 + .../fxa-settings/src/pages/Index/mocks.tsx | 20 +- .../PostVerify/SetPassword/container.test.tsx | 41 +- .../PostVerify/SetPassword/container.tsx | 35 +- .../PostVerify/SetPassword/interfaces.ts | 5 + .../ThirdPartyAuthCallback/index.test.tsx | 20 +- .../ThirdPartyAuthCallback/index.tsx | 7 +- .../SigninPasskeyFallback/container.test.tsx | 51 ++ .../SigninPasskeyFallback/container.tsx | 73 ++- .../pages/Signin/SigninPasskeyFallback/en.ftl | 2 + .../SigninPasskeyFallback/index.test.tsx | 16 + .../Signin/SigninPasskeyFallback/index.tsx | 52 +- .../SigninPasswordlessCode/index.test.tsx | 6 +- .../Signin/SigninPasswordlessCode/index.tsx | 4 +- .../Signin/SigninPasswordlessCode/mocks.tsx | 6 +- .../Signin/SigninRecoveryCode/container.tsx | 6 +- .../Signin/SigninRecoveryCode/index.test.tsx | 4 +- .../pages/Signin/SigninRecoveryCode/index.tsx | 6 +- .../Signin/SigninRecoveryCode/interfaces.ts | 2 +- .../Signin/SigninRecoveryPhone/container.tsx | 4 +- .../Signin/SigninRecoveryPhone/interfaces.ts | 2 +- .../Signin/SigninTotpCode/index.test.tsx | 28 +- .../src/pages/Signin/SigninTotpCode/index.tsx | 2 +- .../src/pages/Signin/SigninTotpCode/mocks.tsx | 14 +- .../src/pages/Signin/SigninUnblock/mocks.tsx | 7 + .../SigninAlternativeAuthOptions/index.tsx | 4 +- .../components/SigninCached/index.test.tsx | 10 +- .../Signin/components/SigninCached/index.tsx | 8 +- .../components/SigninDecider/index.test.tsx | 78 ++- .../Signin/components/SigninDecider/index.tsx | 43 +- .../src/pages/Signin/index.stories.tsx | 14 +- .../src/pages/Signin/index.test.tsx | 20 +- .../fxa-settings/src/pages/Signin/index.tsx | 7 +- .../src/pages/Signin/interfaces.ts | 34 +- .../fxa-settings/src/pages/Signin/mocks.tsx | 50 +- .../src/pages/Signin/utils.test.ts | 8 +- .../fxa-settings/src/pages/Signin/utils.ts | 14 +- .../src/pages/Signup/container.test.tsx | 5 +- .../src/pages/Signup/index.stories.tsx | 10 +- .../src/pages/Signup/index.test.tsx | 14 +- .../fxa-settings/src/pages/Signup/index.tsx | 8 +- .../src/pages/Signup/interfaces.ts | 2 + .../fxa-settings/src/pages/Signup/mocks.tsx | 18 +- 54 files changed, 1278 insertions(+), 396 deletions(-) diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index e792b940e60..873431f53f9 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -194,9 +194,7 @@ const ResetPasswordRecoveryPhoneContainer = lazy( const Settings = lazy(() => import('../Settings')); -export const App = ({ - flowQueryParams, -}: { flowQueryParams: QueryParams }) => { +export const App = ({ flowQueryParams }: { flowQueryParams: QueryParams }) => { const { data: isSignedInData } = useLocalSignedInQueryState(); // Configure Sentry before any other hooks that might throw. @@ -214,7 +212,9 @@ export const App = ({ // Register navigate so out-of-component code (e.g. AppContext errorHandler) // can perform client-side navigations with state. - useEffect(() => { registerNavigate(navigate); }, [navigate]); + useEffect(() => { + registerNavigate(navigate); + }, [navigate]); // GQL call for minimal metrics data const { loading: metricsLoading, data } = useInitialMetricsQueryState() ?? {}; @@ -572,12 +572,34 @@ const AuthAndAccountSetupRoutes = ({ <> {/* Index */} - - } /> - - } /> + + } + /> + + } + /> {/* Legal */} } /> @@ -595,151 +617,421 @@ const AuthAndAccountSetupRoutes = ({ } /> {/* Post verify */} - - } /> - - } /> + + } + /> + + } + /> {/* Legacy URL kept alive for in-flight redirects and bookmarks from the third-party-auth flow; all new navigations target /post_verify/set_password (FXA-13475). */} - - } /> - - } /> + + } + /> + } + /> {/* Reset password */} - - } /> - - } /> - } /> - - } /> - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> + + } + /> + } + /> + } + /> + } + /> + } + /> + + } + /> + } + /> + } + /> + } + /> + + } + /> + + } + /> {/* Signin */} - } /> + } + /> } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - } /> - } /> - - } /> - - } /> - - } /> - - } /> - - } /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + } + /> + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> } /> - - } /> - - } /> - - } /> - - } /> - - } /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> {/* Signup */} - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - - } /> - - } /> + + } + /> + + } + /> + + } + /> + + } + /> + } + /> + + } + /> + } + /> + + + } + /> + + } + /> {/* Pairing */} - } /> - } /> - } /> + } + /> + } + /> + } + /> } /> - } /> - } /> - } /> - } /> - } /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> } /> - } /> + } + /> } /> {/* This must be placed after the routes so it's rendered at the bottom of the DOM. */} diff --git a/packages/fxa-settings/src/lib/hooks/useFxAStatus/index.test.tsx b/packages/fxa-settings/src/lib/hooks/useFxAStatus/index.test.tsx index 09f4692669c..5c0e0eb7242 100644 --- a/packages/fxa-settings/src/lib/hooks/useFxAStatus/index.test.tsx +++ b/packages/fxa-settings/src/lib/hooks/useFxAStatus/index.test.tsx @@ -120,7 +120,7 @@ describe('useFxAStatus', () => { }); }); - it('returns supportsKeysOptionalLogin: true when Relay or SmartWindow service', async () => { + it('reports browserSupportsKeysOptional: true when the browser advertises keys_optional', async () => { const integration = { type: IntegrationType.OAuthNative, isSync: () => false, @@ -131,10 +131,13 @@ describe('useFxAStatus', () => { ); await waitForNextUpdate(); - expect(result.current.supportsKeysOptionalLogin).toBe(true); + expect(result.current.browserSupportsKeysOptional).toBe(true); }); - it('returns supportsKeysOptionalLogin: false for Sync', async () => { + // The hook reports the raw browser capability regardless of integration + // type; whether a Sync login may use it is decided on the integration + // model (see Integration.supportsKeylessLogin), not here. + it('reports browserSupportsKeysOptional: true for Sync when the browser advertises it', async () => { const integration = { type: IntegrationType.OAuthNative, isSync: () => true, @@ -144,7 +147,23 @@ describe('useFxAStatus', () => { useFxAStatus(integration) ); await waitForNextUpdate(); - expect(result.current.supportsKeysOptionalLogin).toBe(false); + expect(result.current.browserSupportsKeysOptional).toBe(true); + }); + + it('reports browserSupportsKeysOptional: false when the browser omits keys_optional', async () => { + (firefox.fxaStatus as jest.Mock).mockResolvedValue({ + capabilities: { engines: [] }, + }); + const integration = { + type: IntegrationType.OAuthNative, + isSync: () => false, + isFirefoxNonSync: () => true, + }; + const { result, waitForNextUpdate } = renderHook(() => + useFxAStatus(integration) + ); + await waitForNextUpdate(); + expect(result.current.browserSupportsKeysOptional).toBe(false); }); }); }); diff --git a/packages/fxa-settings/src/lib/hooks/useFxAStatus/index.tsx b/packages/fxa-settings/src/lib/hooks/useFxAStatus/index.tsx index f48cd7fcf60..536989ea363 100644 --- a/packages/fxa-settings/src/lib/hooks/useFxAStatus/index.tsx +++ b/packages/fxa-settings/src/lib/hooks/useFxAStatus/index.tsx @@ -41,8 +41,10 @@ export function useFxAStatus(integration: FxAStatusIntegration) { typeof syncEngineConfigs | undefined >(); const [declinedSyncEngines, setDeclinedSyncEngines] = useState([]); - const [supportsKeysOptionalLogin, setSupportsKeysOptionalLogin] = - useState(false); + // Raw browser `keys_optional` capability. Per-integration policy (whether a + // given login may skip keys) lives on the Integration model, not here. + const [browserSupportsKeysOptional, setBrowserSupportsKeysOptional] = + useState(false); const [supportsCanLinkAccountUid, setSupportsCanLinkAccountUid] = useState< boolean | undefined >(undefined); @@ -77,17 +79,9 @@ export function useFxAStatus(integration: FxAStatusIntegration) { setWebChannelEngines(capabilities.engines); } } - // Check if third party auth (passwordless) log in to the browser is supported, - // currently only Firefox desktop 147+ as of Q1 2026 - if ( - capabilities.keys_optional && - isOAuthNative && - integration.isFirefoxNonSync() - ) { - setSupportsKeysOptionalLogin(true); - } else { - setSupportsKeysOptionalLogin(false); - } + // Report the raw browser `keys_optional` capability (Fx desktop 147+ as + // of Q1 2026). Consumers decide per-integration whether it applies. + setBrowserSupportsKeysOptional(!!capabilities.keys_optional); if (capabilities.can_link_account_uid) { setSupportsCanLinkAccountUid(true); } else { @@ -154,7 +148,7 @@ export function useFxAStatus(integration: FxAStatusIntegration) { offeredSyncEngineConfigs, declinedSyncEngines, selectedEnginesForGlean, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, supportsCanLinkAccountUid, }; } diff --git a/packages/fxa-settings/src/lib/hooks/useFxAStatus/mocks.tsx b/packages/fxa-settings/src/lib/hooks/useFxAStatus/mocks.tsx index e820e37e19a..fa10517e83c 100644 --- a/packages/fxa-settings/src/lib/hooks/useFxAStatus/mocks.tsx +++ b/packages/fxa-settings/src/lib/hooks/useFxAStatus/mocks.tsx @@ -7,11 +7,11 @@ import type { UseFxAStatusResult } from '.'; export function mockUseFxAStatus({ offeredSyncEnginesOverride, - supportsKeysOptionalLogin = false, + browserSupportsKeysOptional = false, supportsCanLinkAccountUid, }: { offeredSyncEnginesOverride?: ReturnType; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; supportsCanLinkAccountUid?: boolean | undefined; } = {}) { const offeredSyncEngineConfigs = syncEngineConfigs; @@ -35,7 +35,7 @@ export function mockUseFxAStatus({ offeredSyncEngineConfigs, declinedSyncEngines, selectedEnginesForGlean, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, supportsCanLinkAccountUid, } satisfies UseFxAStatusResult; } diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx index 9296a53c526..6feba1cc14f 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx @@ -16,6 +16,7 @@ import { import { getCredential, isWebAuthnSupported } from './webauthn'; import { storeAccountData } from '../storage-utils'; import { AuthUiErrors } from '../auth-errors/auth-errors'; +import firefox from '../channels/firefox'; import GleanMetrics from '../glean'; import { queryParamsToMetricsContext } from '../metrics'; import type { QueryParams } from '../..'; @@ -49,6 +50,12 @@ jest.mock('../storage-utils', () => ({ storeAccountData: jest.fn(), })); +jest.mock('../channels/firefox', () => ({ + __esModule: true, + ...jest.requireActual('../channels/firefox'), + default: { fxaLogin: jest.fn() }, +})); + jest.mock('../glean', () => ({ __esModule: true, default: { @@ -154,6 +161,7 @@ const buildArgs = ( getService: () => undefined, getClientId: () => 'service-id', isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.OAuthWeb, data: {}, wantsTwoStepAuthentication: () => false, @@ -277,6 +285,7 @@ describe('usePasskeySignIn', () => { getService: () => 'sync', getClientId: () => 'client-id-should-not-be-used', isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.OAuthNative, data: {}, wantsTwoStepAuthentication: () => false, @@ -306,6 +315,7 @@ describe('usePasskeySignIn', () => { getService: () => undefined, getClientId: () => 'service-id', isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.Web, data: {}, } as unknown as PasskeySignInIntegration, @@ -377,7 +387,7 @@ describe('usePasskeySignIn', () => { [ true, '/signin_passkey_fallback', - { state: { passkeySurface: 'emailfirst' } }, + { state: { passkeySurface: 'emailfirst', syncPreKeysLoginSent: false } }, ], [ false, @@ -386,6 +396,7 @@ describe('usePasskeySignIn', () => { state: { passwordCreationReason: 'passkey', passkeySurface: 'emailfirst', + syncPreKeysLoginSent: false, }, }, ], @@ -397,6 +408,7 @@ describe('usePasskeySignIn', () => { isSync: () => true, isFirefoxNonSync: () => false, requiresPasswordForLogin: () => true, + allowsPreKeysSyncLogin: () => false, getService: () => 'sync', type: IntegrationType.OAuthNative, data: {}, @@ -423,6 +435,97 @@ describe('usePasskeySignIn', () => { } ); + describe('POC: pre-keys keyless login for desktop Sync', () => { + const buildSyncKeysRequiredArgs = ( + integrationOverrides: Record = {}, + argOverrides: Partial[0]> = {} + ) => + buildArgs({ + integration: { + isSync: () => true, + isFirefoxNonSync: () => false, + requiresPasswordForLogin: () => true, + getService: () => 'sync', + isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, + getWebChannelServices: () => ({ sync: {} }), + type: IntegrationType.OAuthNative, + data: {}, + ...integrationOverrides, + } as unknown as PasskeySignInIntegration, + ...argOverrides, + }); + + it('sends a keyless fxaLogin before the password step when browserSupportsKeysOptional is true', async () => { + const { args } = buildSyncKeysRequiredArgs(undefined, { + browserSupportsKeysOptional: true, + }); + + const { result } = renderHook(() => usePasskeySignIn(args), { wrapper }); + await act(async () => { + await result.current.onClick(); + }); + + expect(firefox.fxaLogin as jest.Mock).toHaveBeenCalledWith({ + email: EMAIL, + sessionToken: SESSION_TOKEN, + uid: UID, + verified: true, + services: { sync: {} }, + }); + }); + + it('does not send fxaLogin when browserSupportsKeysOptional is false', async () => { + const { args } = buildSyncKeysRequiredArgs(); + + const { result } = renderHook(() => usePasskeySignIn(args), { wrapper }); + await act(async () => { + await result.current.onClick(); + }); + + expect(firefox.fxaLogin as jest.Mock).not.toHaveBeenCalled(); + }); + + it('does not send fxaLogin on Firefox mobile even when browserSupportsKeysOptional is true', async () => { + // Drive the predicate off the same mobile flag (capability AND not-mobile) + // so this exercises the mobile exclusion instead of restating the + // capability-off case above with a constant-false stub. The full + // capability/Sync/mobile rule is unit-tested in integration.test.ts. + const isMobile = true; + const { args } = buildSyncKeysRequiredArgs( + { + isFirefoxMobileClient: () => isMobile, + allowsPreKeysSyncLogin: (b: boolean) => b && !isMobile, + }, + { browserSupportsKeysOptional: true } + ); + + const { result } = renderHook(() => usePasskeySignIn(args), { wrapper }); + await act(async () => { + await result.current.onClick(); + }); + + expect(firefox.fxaLogin as jest.Mock).not.toHaveBeenCalled(); + }); + + it('still routes to the password step after sending the keyless login', async () => { + const { args, spies } = buildSyncKeysRequiredArgs(undefined, { + browserSupportsKeysOptional: true, + }); + + const { result } = renderHook(() => usePasskeySignIn(args), { wrapper }); + await act(async () => { + await result.current.onClick(); + }); + + expect(spies.navigateWithQuery).toHaveBeenCalledWith( + '/signin_passkey_fallback', + { state: { passkeySurface: 'emailfirst', syncPreKeysLoginSent: true } } + ); + expect(handleNavigation).not.toHaveBeenCalled(); + }); + }); + it('routes a non-Sync sign-in to set_password when requiresPasswordForLogin is true', async () => { const { args, spies } = buildArgs({ integration: { @@ -432,6 +535,7 @@ describe('usePasskeySignIn', () => { getService: () => 'vpn', getClientId: () => 'service-id', isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.OAuthNative, data: {}, } as unknown as PasskeySignInIntegration, @@ -458,13 +562,14 @@ describe('usePasskeySignIn', () => { state: { passwordCreationReason: 'passkey', passkeySurface: 'emailfirst', + syncPreKeysLoginSent: false, }, } ); expect(handleNavigation).not.toHaveBeenCalled(); }); - it('forwards supportsKeysOptionalLogin to integration.requiresPasswordForLogin', async () => { + it('forwards browserSupportsKeysOptional to integration.requiresPasswordForLogin', async () => { const requiresPasswordForLogin = jest.fn().mockReturnValue(false); const { args } = buildArgs({ integration: { @@ -474,10 +579,11 @@ describe('usePasskeySignIn', () => { getService: () => 'vpn', getClientId: () => 'service-id', isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.OAuthNative, data: {}, } as unknown as PasskeySignInIntegration, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); const { result } = renderHook(() => usePasskeySignIn(args), { wrapper }); @@ -498,6 +604,7 @@ describe('usePasskeySignIn', () => { requiresPasswordForLogin: () => false, getService: () => undefined, isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.OAuthNative, data: {}, } as unknown as PasskeySignInIntegration, @@ -526,6 +633,7 @@ describe('usePasskeySignIn', () => { getService: () => 'vpn', getClientId: () => 'service-id', isFirefoxMobileClient: () => true, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.OAuthNative, data: {}, } as unknown as PasskeySignInIntegration, @@ -546,13 +654,13 @@ describe('usePasskeySignIn', () => { 'emailfirst' as const, true, '/signin_passkey_fallback', - { state: { passkeySurface: 'emailfirst' } }, + { state: { passkeySurface: 'emailfirst', syncPreKeysLoginSent: false } }, ], [ 'login' as const, true, '/signin_passkey_fallback', - { state: { passkeySurface: 'signin' } }, + { state: { passkeySurface: 'signin', syncPreKeysLoginSent: false } }, ], // No-password surfaces (otplogin, alternative_auth) route to set-password // (createdpassword); they never reach the existing-password fallback. @@ -564,6 +672,7 @@ describe('usePasskeySignIn', () => { state: { passwordCreationReason: 'passkey', passkeySurface: 'otplogin', + syncPreKeysLoginSent: false, }, }, ], @@ -575,6 +684,7 @@ describe('usePasskeySignIn', () => { state: { passwordCreationReason: 'passkey', passkeySurface: 'alternative_auth', + syncPreKeysLoginSent: false, }, }, ], @@ -587,6 +697,7 @@ describe('usePasskeySignIn', () => { isSync: () => true, isFirefoxNonSync: () => false, requiresPasswordForLogin: () => true, + allowsPreKeysSyncLogin: () => false, getService: () => 'sync', type: IntegrationType.OAuthNative, data: {}, @@ -960,7 +1071,9 @@ describe('usePasskeySignIn', () => { 'fires passkey.auth_success with reason=%s on the no-Sync-password branch (surface=%s)', async (surface, expectedReason) => { const { args } = buildArgs({ surface }); - const { result } = renderHook(() => usePasskeySignIn(args), { wrapper }); + const { result } = renderHook(() => usePasskeySignIn(args), { + wrapper, + }); await act(async () => { await result.current.onClick(); }); @@ -978,6 +1091,7 @@ describe('usePasskeySignIn', () => { isSync: () => true, isFirefoxNonSync: () => false, requiresPasswordForLogin: () => true, + allowsPreKeysSyncLogin: () => false, getService: () => 'sync', type: IntegrationType.OAuthNative, data: {}, @@ -997,7 +1111,7 @@ describe('usePasskeySignIn', () => { expect(spies.navigateWithQuery).toHaveBeenCalledWith( '/signin_passkey_fallback', - { state: { passkeySurface: 'emailfirst' } } + { state: { passkeySurface: 'emailfirst', syncPreKeysLoginSent: false } } ); expect(GleanMetrics.passkey.authSuccess).not.toHaveBeenCalled(); }); @@ -1061,6 +1175,7 @@ describe('usePasskeySignIn', () => { getService: () => undefined, getClientId: () => 'service-id', isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.OAuthWeb, data: {}, wantsTwoStepAuthentication: () => true, @@ -1150,6 +1265,7 @@ describe('usePasskeySignIn', () => { getService: () => undefined, getClientId: () => 'service-id', isFirefoxMobileClient: () => false, + allowsPreKeysSyncLogin: (b: boolean) => b, type: IntegrationType.OAuthWeb, data: {}, wantsTwoStepAuthentication: () => false, diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts index c39145001cf..ab67ebcac26 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts @@ -15,6 +15,7 @@ import { FtlMsgResolver } from 'fxa-react/lib/utils'; import Banner from '../../components/Banner'; import { AuthUiErrors } from '../auth-errors/auth-errors'; +import firefox from '../channels/firefox'; import GleanMetrics from '../glean'; import { useNavigate } from 'react-router'; import { useNavigateWithQuery } from '../hooks/useNavigateWithQuery'; @@ -216,7 +217,12 @@ export interface UsePasskeySignInArgs { flowQueryParams?: QueryParams; surface: PasskeySignInSurface; isButtonVisible?: boolean; - supportsKeysOptionalLogin?: boolean; + // Raw browser `keys_optional` capability (see useFxAStatus). When true on + // desktop, a Sync passkey sign-in sends a pre-keys keyless login so the browser + // reflects "signed in" before the password step (Sync enables later at + // oauth_login). The per-integration policy lives on the integration model + // (requiresPasswordForLogin / allowsPreKeysSyncLogin). + browserSupportsKeysOptional?: boolean; } export interface UsePasskeySignInResult { @@ -235,7 +241,7 @@ export function usePasskeySignIn({ flowQueryParams, surface, isButtonVisible = false, - supportsKeysOptionalLogin, + browserSupportsKeysOptional = false, }: UsePasskeySignInArgs): UsePasskeySignInResult { const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(); @@ -335,7 +341,7 @@ export function usePasskeySignIn({ const metricsContext = queryParamsToMetricsContext(flowQueryParams); const keysRequired = integration.requiresPasswordForLogin( - supportsKeysOptionalLogin + browserSupportsKeysOptional ); const completion = await authClient.completePasskeyAuthentication( credential, @@ -395,20 +401,35 @@ export function usePasskeySignIn({ }); if (keysRequired) { - // A password is needed to derive scoped keys before the browser - // login/OAuth messages are sent. An existing-password account re-enters - // its password; a passwordless account creates one. + // On desktop with the keys_optional capability, sign the account into the + // browser now with a keyless login; the keyed oauth_login (scoped keys) + // follows after the password step. syncPreKeysLoginSent tells the + // destination page not to re-send the login. + const syncPreKeysLoginSent = integration.allowsPreKeysSyncLogin( + browserSupportsKeysOptional + ); + if (syncPreKeysLoginSent) { + firefox.fxaLogin({ + email, + sessionToken: completion.sessionToken, + uid: completion.uid, + verified: completion.verified, + services: integration.getWebChannelServices(), + }); + } const fallbackPath = completion.hasPassword ? '/signin_passkey_fallback' : '/post_verify/set_password'; - // Thread the passkey context so the destination page can tag its Glean - // events with the originating surface. navigateWithQuery(fallbackPath, { state: completion.hasPassword - ? { passkeySurface: toPasskeyMetricsSurface(surface) } + ? { + passkeySurface: toPasskeyMetricsSurface(surface), + syncPreKeysLoginSent, + } : { passwordCreationReason: 'passkey' as const, passkeySurface: toPasskeyMetricsSurface(surface), + syncPreKeysLoginSent, }, }); return; @@ -493,7 +514,7 @@ export function usePasskeySignIn({ flowQueryParams, setLocalizedError, surface, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, ]); return { isLoading, errorBanner, onClick }; diff --git a/packages/fxa-settings/src/models/integrations/integration.test.ts b/packages/fxa-settings/src/models/integrations/integration.test.ts index bc2f4f709e1..7f12cc5b5eb 100644 --- a/packages/fxa-settings/src/models/integrations/integration.test.ts +++ b/packages/fxa-settings/src/models/integrations/integration.test.ts @@ -73,6 +73,49 @@ describe('Integration Model', function () { }); }); + describe('supportsKeylessLogin', function () { + it('is true for a non-Sync client when the browser supports keys-optional login', () => { + jest.spyOn(model, 'isFirefoxNonSync').mockReturnValue(true); + expect(model.supportsKeylessLogin(true)).toBe(true); + }); + + it('is false for a Sync client even when the browser supports keys-optional login', () => { + jest.spyOn(model, 'isFirefoxNonSync').mockReturnValue(false); + expect(model.supportsKeylessLogin(true)).toBe(false); + }); + + it('is false when the browser does not support keys-optional login', () => { + jest.spyOn(model, 'isFirefoxNonSync').mockReturnValue(true); + expect(model.supportsKeylessLogin(false)).toBe(false); + }); + }); + + describe('allowsPreKeysSyncLogin', function () { + it('is true for a desktop Sync client when the browser supports keys-optional login', () => { + jest.spyOn(model, 'isSync').mockReturnValue(true); + jest.spyOn(model, 'isFirefoxMobileClient').mockReturnValue(false); + expect(model.allowsPreKeysSyncLogin(true)).toBe(true); + }); + + it('is false for a non-Sync client', () => { + jest.spyOn(model, 'isSync').mockReturnValue(false); + jest.spyOn(model, 'isFirefoxMobileClient').mockReturnValue(false); + expect(model.allowsPreKeysSyncLogin(true)).toBe(false); + }); + + it('is false on Firefox mobile', () => { + jest.spyOn(model, 'isSync').mockReturnValue(true); + jest.spyOn(model, 'isFirefoxMobileClient').mockReturnValue(true); + expect(model.allowsPreKeysSyncLogin(true)).toBe(false); + }); + + it('is false when the browser does not support keys-optional login', () => { + jest.spyOn(model, 'isSync').mockReturnValue(true); + jest.spyOn(model, 'isFirefoxMobileClient').mockReturnValue(false); + expect(model.allowsPreKeysSyncLogin(false)).toBe(false); + }); + }); + describe('isTrusted', function () { it('returns `true`', () => { expect(model.isTrusted()).toBeTruthy(); diff --git a/packages/fxa-settings/src/models/integrations/integration.ts b/packages/fxa-settings/src/models/integrations/integration.ts index 8efcf737377..d4f205a554b 100644 --- a/packages/fxa-settings/src/models/integrations/integration.ts +++ b/packages/fxa-settings/src/models/integrations/integration.ts @@ -200,26 +200,44 @@ export class GenericIntegration< } /** - * Whether a passwordless account must set a password because scoped keys - * must be derived and sent to the browser. - * - * - Sync always requires keys (`requiresKeys`), so a passwordless Sync sign-in - * must set a password first. - * - A non-Sync Firefox service that wants keys (`wantsKeysIfPasswordEntered`) - * only requires a password when the browser has NOT decoupled Sync. When the - * browser has the "keys optional" capability set (Fx desktop 147+, TBD in mobile), - * it can complete login without keys, so no password is required. - * - * When this is true (and the caller has also confirmed the account has no - * password), when the user has authenticated, route them to - * `/post_verify/set_password` and defer the fxaLogin/fxaOAuthLogin web channel - * messages until keys are available — otherwise the browser receives a keyless - * OAuth login. + * Whether a passwordless account must set/enter a password to derive scoped + * keys. Sync always requires keys; a non-Sync Firefox service that wants keys + * needs a password only when the browser has not advertised `keys_optional`. + * `browserSupportsKeysOptional` is the raw browser capability (from + * `useFxAStatus`); this method applies the per-integration policy. */ - requiresPasswordForLogin(supportsKeysOptionalLogin = false): boolean { + requiresPasswordForLogin(browserSupportsKeysOptional = false): boolean { return ( this.requiresKeys() || - (!supportsKeysOptionalLogin && this.wantsKeysIfPasswordEntered()) + this.nonSyncKeysRequirePassword(browserSupportsKeysOptional) + ); + } + + /** + * The non-Sync half of `requiresPasswordForLogin`, exposed on its own so + * callers (e.g. SigninDecider) reuse it instead of re-open-coding the clause. + */ + nonSyncKeysRequirePassword(browserSupportsKeysOptional = false): boolean { + return !browserSupportsKeysOptional && this.wantsKeysIfPasswordEntered(); + } + + /** + * Whether a non-Sync Firefox service may complete login without keys (browser + * advertised `keys_optional`, so Sync is decoupled from the service). + */ + supportsKeylessLogin(browserSupportsKeysOptional = false): boolean { + return browserSupportsKeysOptional && this.isFirefoxNonSync(); + } + + /** + * Whether a Sync sign-in may send a pre-keys keyless `fxaccounts:login` before + * the password step (desktop only; mobile ignores the login message). + */ + allowsPreKeysSyncLogin(browserSupportsKeysOptional = false): boolean { + return ( + browserSupportsKeysOptional && + this.isSync() && + !this.isFirefoxMobileClient() ); } diff --git a/packages/fxa-settings/src/pages/Index/index.stories.tsx b/packages/fxa-settings/src/pages/Index/index.stories.tsx index c88d4d72552..63d8bcbe9c8 100644 --- a/packages/fxa-settings/src/pages/Index/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Index/index.stories.tsx @@ -43,7 +43,7 @@ const storyWithProps = ({ initialErrorBanner?: string; initialSuccessBanner?: string; initialTooltipMessage?: string; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; } = {}) => { const story = () => ; return story; @@ -62,7 +62,7 @@ export const WithThirdPartyAuthServiceRelayIntegration = storyWithProps({ isFirefoxClientServiceRelay: true, isSync: false, }), - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); export const WithThirdPartyAuthServiceSmartWindowIntegration = storyWithProps({ @@ -70,7 +70,7 @@ export const WithThirdPartyAuthServiceSmartWindowIntegration = storyWithProps({ isFirefoxClientServiceSmartWindow: true, isSync: false, }), - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); export const WithPrefilledEmail = storyWithProps({ diff --git a/packages/fxa-settings/src/pages/Index/index.test.tsx b/packages/fxa-settings/src/pages/Index/index.test.tsx index d1c4b8d1c5a..fa3f48d8533 100644 --- a/packages/fxa-settings/src/pages/Index/index.test.tsx +++ b/packages/fxa-settings/src/pages/Index/index.test.tsx @@ -100,14 +100,14 @@ describe('Index page', () => { thirdPartyAuthNotRendered(); }); - it('renders third party auth with service=relay when supportsKeysOptionalLogin is true', () => { + it('renders third party auth with service=relay when browserSupportsKeysOptional is true', () => { renderWithLocalizationProvider( ); @@ -115,14 +115,14 @@ describe('Index page', () => { thirdPartyAuthWithSeparatorRendered(); }); - it('renders third party auth with service=smartwindow when supportsKeysOptionalLogin is true', () => { + it('renders third party auth with service=smartwindow when browserSupportsKeysOptional is true', () => { renderWithLocalizationProvider( ); diff --git a/packages/fxa-settings/src/pages/Index/index.tsx b/packages/fxa-settings/src/pages/Index/index.tsx index 16c838946ea..a86cbba5dbe 100644 --- a/packages/fxa-settings/src/pages/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Index/index.tsx @@ -56,6 +56,8 @@ export const Index = ({ const isSync = integration.isSync(); const isFirefoxClientServiceRelay = integration.isFirefoxClientServiceRelay(); const [isSubmitting, setIsSubmitting] = useState(false); + const browserSupportsKeysOptional = + useFxAStatusResult.browserSupportsKeysOptional; const passkey = usePasskeySignIn({ integration, @@ -67,7 +69,7 @@ export const Index = ({ flowQueryParams, surface: 'emailfirst', isButtonVisible: showPasskeySignin, - supportsKeysOptionalLogin: useFxAStatusResult.supportsKeysOptionalLogin, + browserSupportsKeysOptional, }); const handlePasskeyClick = () => { // Cancel any pending suggested-email auto-submit so it can't override @@ -244,7 +246,7 @@ export const Index = ({ ; diff --git a/packages/fxa-settings/src/pages/Index/mocks.tsx b/packages/fxa-settings/src/pages/Index/mocks.tsx index a323abdb026..09df3d1c2eb 100644 --- a/packages/fxa-settings/src/pages/Index/mocks.tsx +++ b/packages/fxa-settings/src/pages/Index/mocks.tsx @@ -80,6 +80,13 @@ export function createMockIndexOAuthNativeIntegration({ isFirefoxClientServiceRelay || isFirefoxClientServiceSmartWindow || isFirefoxClientServiceVpn, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, + allowsPreKeysSyncLogin: () => false, + nonSyncKeysRequirePassword(b: boolean) { + return !b && this.wantsKeysIfPasswordEntered(); + }, getCmsInfo: () => cmsInfo, getLegalTerms: () => undefined, getWebChannelServices: () => undefined, @@ -111,6 +118,13 @@ export function createMockIndexWebIntegration(): IndexIntegration { isFirefoxDesktopClient: () => false, isFirefoxMobileClient: () => false, isFirefoxNonSync: () => false, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, + allowsPreKeysSyncLogin: () => false, + nonSyncKeysRequirePassword(b: boolean) { + return !b && this.wantsKeysIfPasswordEntered(); + }, getCmsInfo: () => undefined, getLegalTerms: () => undefined, getWebChannelServices: () => undefined, @@ -136,7 +150,7 @@ export const Subject = ({ initialSuccessBanner = '', initialTooltipMessage = '', isMobile = false, - supportsKeysOptionalLogin = false, + browserSupportsKeysOptional = false, }: { integration?: IndexIntegration; serviceName?: MozServices; @@ -145,7 +159,7 @@ export const Subject = ({ initialSuccessBanner?: string; initialTooltipMessage?: string; isMobile?: boolean; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; }) => { const [errorBannerMessage, setErrorBannerMessage] = React.useState(initialErrorBanner); @@ -155,7 +169,7 @@ export const Subject = ({ initialTooltipMessage ); const mockUseFxAStatusResult = mockUseFxAStatus({ - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }); return ( diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx index 8ee168b3569..8ffe78c1f45 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.test.tsx @@ -91,8 +91,8 @@ function mockModelsModule() { }); mockAuthClient.accountEmails = jest.fn().mockResolvedValue({ primary: MOCK_EMAIL, - original: MOCK_EMAIL - }) + original: MOCK_EMAIL, + }); mockAuthClient.sessionReauthWithAuthPW = jest .fn() .mockResolvedValue({ keyFetchToken: MOCK_KEY_FETCH_TOKEN }); @@ -147,10 +147,10 @@ function applyDefaultMocks() { function render( integration = mockSyncDesktopV3Integration(), { - supportsKeysOptionalLogin = false, - }: { supportsKeysOptionalLogin?: boolean } = {} + browserSupportsKeysOptional = false, + }: { browserSupportsKeysOptional?: boolean } = {} ) { - const useFxAStatusResult = mockUseFxAStatus({ supportsKeysOptionalLogin }); + const useFxAStatusResult = mockUseFxAStatus({ browserSupportsKeysOptional }); renderWithLocalizationProvider( false, wantsKeysIfPasswordEntered: () => true, wantsKeys: () => true, - requiresPasswordForLogin(supportsKeysOptionalLogin = false) { + requiresPasswordForLogin(browserSupportsKeysOptional = false) { return ( this.requiresKeys() || - (!supportsKeysOptionalLogin && this.wantsKeysIfPasswordEntered()) + (!browserSupportsKeysOptional && this.wantsKeysIfPasswordEntered()) ); }, data: { service: 'vpn' }, @@ -271,7 +271,7 @@ describe('SetPassword-container', () => { // 147, and Mobile as of Firefox 153. it('renders for the non-Sync VPN flow that needs keys when keys are not optional', async () => { render(mockVpnOAuthNativeIntegration(), { - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); await waitFor(() => { expect(SetPasswordModule.default).toHaveBeenCalled(); @@ -281,7 +281,7 @@ describe('SetPassword-container', () => { it('redirects to signin for the non-Sync VPN flow when the browser supports keys-optional login', async () => { render(mockVpnOAuthNativeIntegration(), { - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('/signin', { replace: true }); @@ -421,6 +421,29 @@ describe('SetPassword-container', () => { }); }); + it('does not re-send the keyless fxaLogin when the pre-keys login was already sent', async () => { + mockLocation.state = { syncPreKeysLoginSent: true }; + render(mockOAuthNativeIntegration()); + + await waitFor(() => { + expect(currentSetPasswordProps?.createPasswordHandler).toBeDefined(); + }); + await act(async () => { + await currentSetPasswordProps?.createPasswordHandler(MOCK_PASSWORD); + }); + + // The pre-keys keyless login already fired at passkey time; the + // set-password step must send only the keyed oauth_login. + expect(fxaLoginSpy).not.toHaveBeenCalled(); + expect(firefox.fxaOAuthLogin).toHaveBeenCalledWith({ + action: 'signin', + code: MOCK_OAUTH_FLOW_HANDLER_RESPONSE.code, + redirect: MOCK_OAUTH_FLOW_HANDLER_RESPONSE.redirect, + state: MOCK_OAUTH_FLOW_HANDLER_RESPONSE.state, + scope: MOCK_OAUTH_FLOW_HANDLER_RESPONSE.scope, + }); + }); + it('handleNavigation does not navigate when integration isFirefoxMobileClient', async () => { render(mockOAuthNativeIntegration({ isFirefoxMobileClient: true })); diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx index 82b3885fc45..0fa803526ce 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/container.tsx @@ -34,7 +34,7 @@ const SetPasswordContainer = ({ offeredSyncEngineConfigs, declinedSyncEngines, selectedEnginesForGlean, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }, }: { integration: Integration; @@ -52,6 +52,7 @@ const SetPasswordContainer = ({ const location = useLocation() as ReturnType & { state?: SetPasswordLocationState; }; + const syncPreKeysLoginSent = location.state?.syncPreKeysLoginSent ?? false; // All known navigation paths set this explicitly (`getSyncNavigate` for // third-party-auth, `SigninPasswordlessCode` for OTP, `signin-flow` for // passkey). The default is a safety net for direct URL hits or page @@ -185,7 +186,7 @@ const SetPasswordContainer = ({ integration, finishOAuthFlowHandler, queryParams: location.search, - handleFxaLogin: true, + handleFxaLogin: !syncPreKeysLoginSent, handleFxaOAuthLogin: true, showSignupConfirmedSync: true, origin: 'post-verify-set-password', @@ -222,9 +223,35 @@ const SetPasswordContainer = ({ gleanReason, offeredSyncEngines, location.search, + syncPreKeysLoginSent, ] ); + // Redirect to /signin when this page does not apply: missing auth, keys not + // required for this login, or the account already has a password. Run in an + // effect (not during render) to avoid React Router's navigate-during-render. + useEffect(() => { + const missingAuthOrNoPasswordNeeded = + !email || + !sessionToken || + !uid || + !integration.requiresPasswordForLogin(browserSupportsKeysOptional); + const alreadyHasPassword = + !passwordStatus.isLoading && passwordStatus.hasPassword; + if (missingAuthOrNoPasswordNeeded || alreadyHasPassword) { + navigateWithQuery('/signin', { replace: true }); + } + }, [ + email, + sessionToken, + uid, + integration, + browserSupportsKeysOptional, + passwordStatus.isLoading, + passwordStatus.hasPassword, + navigateWithQuery, + ]); + // Users must be already authenticated on this page. // This page only applies applies to flows where a passwordless account must // set a password for key derivation, including non-Sync Firefox flows that @@ -234,9 +261,8 @@ const SetPasswordContainer = ({ !email || !sessionToken || !uid || - !integration.requiresPasswordForLogin(supportsKeysOptionalLogin) + !integration.requiresPasswordForLogin(browserSupportsKeysOptional) ) { - navigateWithQuery('/signin', { replace: true }); return ; } if (oAuthDataError) { @@ -251,7 +277,6 @@ const SetPasswordContainer = ({ // Already has a password (re-entry): sign in instead. if (passwordStatus.hasPassword) { - navigateWithQuery('/signin', { replace: true }); return ; } diff --git a/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts b/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts index 44b9ad344f4..593b801dfd0 100644 --- a/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts +++ b/packages/fxa-settings/src/pages/PostVerify/SetPassword/interfaces.ts @@ -55,4 +55,9 @@ export interface SetPasswordLocationState { * `passkey.auth_success` reasons with the surface. */ passkeySurface?: PasskeyMetricsSurface; + /** + * True when the passkey flow already sent the keyless `fxaccounts:login`; the + * set-password step then sends only the keyed `oauth_login`. + */ + syncPreKeysLoginSent?: boolean; } diff --git a/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.test.tsx b/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.test.tsx index 8a718009230..4cf4c8343e2 100644 --- a/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.test.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.test.tsx @@ -142,7 +142,7 @@ function mockOAuthNativeIntegration({ function renderWith(props?: { flowQueryParams?: QueryParams; integration: ModelsModule.Integration; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; }) { const { flowQueryParams = { @@ -150,9 +150,9 @@ function renderWith(props?: { flowBeginTime: 1734112296874, }, integration = mockThirdPartyAuthCallbackIntegration(), - supportsKeysOptionalLogin = false, + browserSupportsKeysOptional = false, } = props ?? {}; - const useFxAStatusResult = mockUseFxAStatus({ supportsKeysOptionalLogin }); + const useFxAStatusResult = mockUseFxAStatus({ browserSupportsKeysOptional }); return renderWithLocalizationProvider( { handleFxaOAuthLogin: false, integration: integration, isSignInWithThirdPartyAuth: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, queryParams: '?', redirectTo: redirectTo, authClient: useAuthClient(), @@ -344,7 +344,7 @@ describe('ThirdPartyAuthCallback component', () => { }); renderWith({ integration, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); await waitFor(() => { expect(handleNavigation).toHaveBeenCalledWith( @@ -352,7 +352,7 @@ describe('ThirdPartyAuthCallback component', () => { handleFxaLogin: false, handleFxaOAuthLogin: false, isSignInWithThirdPartyAuth: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }) ); }); @@ -365,7 +365,7 @@ describe('ThirdPartyAuthCallback component', () => { }); renderWith({ integration, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); await waitFor(() => { expect(handleNavigation).toHaveBeenCalledWith( @@ -373,7 +373,7 @@ describe('ThirdPartyAuthCallback component', () => { handleFxaLogin: true, handleFxaOAuthLogin: true, isSignInWithThirdPartyAuth: true, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }) ); }); @@ -388,7 +388,7 @@ describe('ThirdPartyAuthCallback component', () => { }); renderWith({ integration, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); await waitFor(() => { expect(handleNavigation).toHaveBeenCalledWith( @@ -396,7 +396,7 @@ describe('ThirdPartyAuthCallback component', () => { handleFxaLogin: false, handleFxaOAuthLogin: false, isSignInWithThirdPartyAuth: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }) ); }); diff --git a/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx b/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx index 9b62a0d913b..a608c95284e 100644 --- a/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx +++ b/packages/fxa-settings/src/pages/PostVerify/ThirdPartyAuthCallback/index.tsx @@ -94,7 +94,7 @@ const ThirdPartyAuthCallback = ({ // when Sync is not decoupled) need to defer the browser login/OAuth messages // and let handleNavigation route to set_password. const deferKeysUntilPasswordSet = integration.requiresPasswordForLogin( - useFxAStatusResult.supportsKeysOptionalLogin + useFxAStatusResult.browserSupportsKeysOptional ); if (integration.isFirefoxNonSync() || integration.isSync()) { @@ -132,7 +132,8 @@ const ThirdPartyAuthCallback = ({ finishOAuthFlowHandler, queryParams: location.search, isSignInWithThirdPartyAuth: true, - supportsKeysOptionalLogin: useFxAStatusResult.supportsKeysOptionalLogin, + browserSupportsKeysOptional: + useFxAStatusResult.browserSupportsKeysOptional, // For non‑Sync browser services where Sync has been decoupled, we can // sign in to the browser immediately because those integrations do not require keys // (e.g. a password entered). For passwordless accounts, we must @@ -158,7 +159,7 @@ const ThirdPartyAuthCallback = ({ navigateWithQuery, webRedirectCheck, ftlMsgResolver, - useFxAStatusResult.supportsKeysOptionalLogin, + useFxAStatusResult.browserSupportsKeysOptional, authClient, ] ); diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx index d24a5eaf776..142270b2339 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx @@ -222,6 +222,57 @@ describe('SigninPasskeyFallback container', () => { ); }); + it('suppresses the keyless login (handleFxaLogin false) when the pre-keys login was already sent', async () => { + mockLocationState = { + ...MOCK_LOCATION_STATE, + syncPreKeysLoginSent: true, + }; + const { getByTestId } = render(); + submitPassword(getByTestId); + + await waitFor(() => { + expect(mockHandleNavigation).toHaveBeenCalledWith( + expect.objectContaining({ + handleFxaLogin: false, + handleFxaOAuthLogin: true, + }) + ); + }); + }); + + it('discards the stale session and restarts sign-in on INVALID_TOKEN reauth', async () => { + const discardSpy = jest + .spyOn(CacheModule, 'discardSessionToken') + .mockImplementation(() => {}); + mockSessionReauth.mockRejectedValueOnce({ + errno: AuthUiErrors.INVALID_TOKEN.errno, + }); + const { getByTestId } = render(); + submitPassword(getByTestId); + + await waitFor(() => { + expect(discardSpy).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + expect( + GleanMetrics.passkeyEnterPassword.submitFrontendError + ).not.toHaveBeenCalled(); + }); + + it('for reason=resume, sends verificationMethod undefined and no passkey metrics', async () => { + mockLocationState = { ...MOCK_LOCATION_STATE, reason: 'resume' }; + const { getByTestId } = render(); + submitPassword(getByTestId); + + await waitFor(() => { + expect(mockHandleNavigation).toHaveBeenCalled(); + }); + const navArg = mockHandleNavigation.mock.calls[0][0]; + expect(navArg.signinData.verificationMethod).toBeUndefined(); + expect(GleanMetrics.passkeyEnterPassword.success).not.toHaveBeenCalled(); + expect(GleanMetrics.passkey.authSuccess).not.toHaveBeenCalled(); + }); + it('passes the flow metricsContext to sessionReauth so the deferred account.login is correlated', async () => { const { getByTestId } = render(); submitPassword(getByTestId); diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx index 2e73c849a19..7aff6a854d7 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx @@ -14,6 +14,7 @@ import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors'; import { useFinishOAuthFlowHandler } from '../../../lib/oauth/hooks'; import { PROFILE_OAUTH_TOKEN_TTL_SECONDS } from '../../../lib/oauth'; import { useNavigateWithQuery } from '../../../lib/hooks/useNavigateWithQuery'; +import { discardSessionToken } from '../../../lib/cache'; import { getLocalizedErrorMessage } from '../../../lib/error-utils'; import GleanMetrics from '../../../lib/glean'; import { AccountAvatar } from '../../../lib/interfaces'; @@ -57,6 +58,11 @@ const SigninPasskeyFallbackContainer = ({ const email = signinState?.email; const uid = signinState?.uid; const passkeySurface = location.state?.passkeySurface ?? 'emailfirst'; + const syncPreKeysLoginSent = location.state?.syncPreKeysLoginSent ?? false; + // 'passkey' keeps the passkey copy + Glean; 'resume' (Turn-on-Sync of a + // keyless account) shows generic copy and skips the passkey-scoped metrics. + const reason = location.state?.reason ?? 'passkey'; + const isPasskey = reason === 'passkey'; const metricsContext = useMemo( () => queryParamsToMetricsContext(flowQueryParams), [flowQueryParams] @@ -136,13 +142,26 @@ const SigninPasskeyFallbackContainer = ({ )); } catch (err) { const errno = (err as { errno?: number })?.errno; + // A dead cached session (e.g. after signing out from the browser menu: + // the server session is gone but local storage kept the token) reauths + // with INVALID_TOKEN. Clear the stale local session and restart a fresh + // sign-in rather than dead-ending on an error here. + if (errno === AuthUiErrors.INVALID_TOKEN.errno) { + discardSessionToken(); + navigateWithQuery('/'); + return; + } const isIncorrectPassword = errno === AuthUiErrors.INCORRECT_PASSWORD.errno; - GleanMetrics.passkeyEnterPassword.submitFrontendError({ - event: { - reason: isIncorrectPassword ? 'incorrect_password' : 'server_error', - }, - }); + if (isPasskey) { + GleanMetrics.passkeyEnterPassword.submitFrontendError({ + event: { + reason: isIncorrectPassword + ? 'incorrect_password' + : 'server_error', + }, + }); + } setLocalizedErrorMessage(getLocalizedErrorMessage(ftlMsgResolver, err)); return; } @@ -155,14 +174,16 @@ const SigninPasskeyFallbackContainer = ({ sessionToken, emailVerified: true, sessionVerified: true, - verificationMethod: VerificationMethods.PASSKEY, + verificationMethod: isPasskey + ? VerificationMethods.PASSKEY + : undefined, keyFetchToken, }, unwrapBKey, integration, finishOAuthFlowHandler, queryParams: location.search, - handleFxaLogin: true, + handleFxaLogin: !syncPreKeysLoginSent, handleFxaOAuthLogin: true, // On Firefox mobile, the browser finishes Sync sign-in via WebChannel // messages; navigating the WebView away would interrupt it and leave @@ -171,26 +192,33 @@ const SigninPasskeyFallbackContainer = ({ authClient, }); if (navError) { - GleanMetrics.passkeyEnterPassword.submitFrontendError({ - event: { reason: 'server_error' }, - }); + if (isPasskey) { + GleanMetrics.passkeyEnterPassword.submitFrontendError({ + event: { reason: 'server_error' }, + }); + } setLocalizedErrorMessage( getLocalizedErrorMessage(ftlMsgResolver, navError) ); return; } - GleanMetrics.passkeyEnterPassword.success({ - event: { reason: passkeySurface }, - }); - // Consolidated terminal-success signal for Looker funnels — fires - // only once the full Sync sign-in (passkey + existing-password - // reauth) has completed without error. - GleanMetrics.passkey.authSuccess({ - event: { - reason: buildPasskeyAuthSuccessReason(passkeySurface, 'withpassword'), - }, - }); + if (isPasskey) { + GleanMetrics.passkeyEnterPassword.success({ + event: { reason: passkeySurface }, + }); + // Consolidated terminal-success signal for Looker funnels — fires + // only once the full Sync sign-in (passkey + existing-password + // reauth) has completed without error. + GleanMetrics.passkey.authSuccess({ + event: { + reason: buildPasskeyAuthSuccessReason( + passkeySurface, + 'withpassword' + ), + }, + }); + } }, [ authClient, @@ -205,6 +233,8 @@ const SigninPasskeyFallbackContainer = ({ uid, passkeySurface, metricsContext, + syncPreKeysLoginSent, + isPasskey, ] ); @@ -233,6 +263,7 @@ const SigninPasskeyFallbackContainer = ({ avatarData, avatarLoading, passkeySurface, + reason, }} /> ); diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/en.ftl b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/en.ftl index b79c41a5612..af7be5eaacc 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/en.ftl +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/en.ftl @@ -4,5 +4,7 @@ signin-passkey-fallback-header = Finish sign in signin-passkey-fallback-heading = Enter your password to sync signin-passkey-fallback-body = To keep your data safe, you need to enter your password when you use this passkey. +# Shown instead of -body for non-passkey flows that need keys later (e.g. the Turn-on-Sync resume of a keyless account). +signin-fallback-enter-password-body = Your password encrypts your synced data so only you can access it. signin-passkey-fallback-password-label = Password signin-passkey-fallback-continue = Continue diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx index fff6a7f3013..fb849e627b6 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.test.tsx @@ -57,6 +57,22 @@ describe('SigninPasskeyFallback', () => { expect(screen.getByText('Incorrect password')).toBeInTheDocument(); }); + it('for reason=resume, renders generic copy without the eyebrow or passkey metrics', () => { + renderWithRouter( + + ); + expect( + screen.getByText( + 'Your password encrypts your synced data so only you can access it.' + ) + ).toBeInTheDocument(); + expect( + screen.queryByText(/when you use this passkey/) + ).not.toBeInTheDocument(); + expect(screen.queryByText('Finish sign in')).not.toBeInTheDocument(); + expect(GleanMetrics.passkeyEnterPassword.view).not.toHaveBeenCalled(); + }); + describe('Glean events', () => { it('fires view with the default surface reason on mount', () => { renderWithRouter(); diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx index 4fbf529d244..4cf0113df37 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/index.tsx @@ -14,6 +14,7 @@ import InputPassword from '../../../components/InputPassword'; import GleanMetrics from '../../../lib/glean'; import { AccountAvatar } from '../../../lib/interfaces'; import { PasskeyMetricsSurface } from '../../../lib/passkeys/signin-flow'; +import { EnterPasswordReason } from '../interfaces'; export type SigninPasskeyFallbackProps = { email?: string; @@ -22,6 +23,11 @@ export type SigninPasskeyFallbackProps = { avatarData?: { account: { avatar: AccountAvatar } }; avatarLoading?: boolean; passkeySurface?: PasskeyMetricsSurface; + /** + * Why this page is shown. `passkey` (default) keeps the passkey copy + Glean + * events; `resume` (Turn-on-Sync) shows generic copy and skips passkey metrics. + */ + reason?: EnterPasswordReason; }; type FormData = { @@ -37,6 +43,7 @@ const SigninPasskeyFallback = ({ avatarData, avatarLoading, passkeySurface = 'emailfirst', + reason = 'passkey', }: SigninPasskeyFallbackProps) => { const [passwordErrorText, setPasswordErrorText] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -48,30 +55,33 @@ const SigninPasskeyFallback = ({ }); useEffect(() => { + if (reason !== 'passkey') return; GleanMetrics.passkeyEnterPassword.view({ event: { reason: passkeySurface }, }); - }, [passkeySurface]); + }, [reason, passkeySurface]); // Fire engage on the first keystroke into the password field. Mirrors the // pattern used in PostVerify/SetPassword. const [hasEngaged, setHasEngaged] = useState(false); const passwordValue = watch('password'); useEffect(() => { - if (!hasEngaged && passwordValue) { + if (reason === 'passkey' && !hasEngaged && passwordValue) { setHasEngaged(true); GleanMetrics.passkeyEnterPassword.engage({ event: { reason: passkeySurface }, }); } - }, [hasEngaged, passwordValue, passkeySurface]); + }, [reason, hasEngaged, passwordValue, passkeySurface]); const handleContinue = useCallback( async (data: FormData) => { if (!onContinue) return; - GleanMetrics.passkeyEnterPassword.submit({ - event: { reason: passkeySurface }, - }); + if (reason === 'passkey') { + GleanMetrics.passkeyEnterPassword.submit({ + event: { reason: passkeySurface }, + }); + } setIsSubmitting(true); try { await onContinue(data.password); @@ -79,7 +89,7 @@ const SigninPasskeyFallback = ({ setIsSubmitting(false); } }, - [onContinue, passkeySurface] + [onContinue, passkeySurface, reason] ); return ( @@ -91,20 +101,30 @@ const SigninPasskeyFallback = ({ /> )} - -

Finish sign in

-
+ {reason === 'passkey' && ( + +

Finish sign in

+
+ )}

Enter your password to sync

- -

- To keep your data safe, you need to enter your password when you use - this passkey. -

-
+ {reason === 'passkey' ? ( + +

+ To keep your data safe, you need to enter your password when you use + this passkey. +

+
+ ) : ( + +

+ Your password encrypts your synced data so only you can access it. +

+
+ )} {email && (
diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx index 3673cb522c2..fb468e7a472 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx @@ -144,7 +144,7 @@ function resetMockAuthClient() { function render( props: Partial & { isSignup?: boolean; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; } = {} ) { if (!props.integration) { @@ -849,7 +849,7 @@ describe('SigninPasswordlessCode page', () => { render({ integration, isSignup: false, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); await submitCode(); @@ -878,7 +878,7 @@ describe('SigninPasswordlessCode page', () => { render({ integration, isSignup: false, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); await submitCode(); diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx index b5f01c99ca3..fb5d6cedbb2 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx @@ -101,7 +101,7 @@ const SigninPasswordlessCode = ({ flowQueryParams, surface: 'login_otp', isButtonVisible: showPasskeySignin, - supportsKeysOptionalLogin: useFxAStatusResult.supportsKeysOptionalLogin, + browserSupportsKeysOptional: useFxAStatusResult.browserSupportsKeysOptional, }); const [localizedErrorBannerMessage, setLocalizedErrorBannerMessage] = @@ -328,7 +328,7 @@ const SigninPasswordlessCode = ({ // since /password/create requires a verifiedSessionToken. if ( integration.requiresPasswordForLogin( - useFxAStatusResult.supportsKeysOptionalLogin + useFxAStatusResult.browserSupportsKeysOptional ) ) { const accountData = { diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/mocks.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/mocks.tsx index d2dd1bb9213..a985fb3c78f 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/mocks.tsx @@ -95,9 +95,9 @@ export const Subject = ({ hasPasskey = undefined, isSignedIntoFirefox = false, sendError = null, - supportsKeysOptionalLogin = false, + browserSupportsKeysOptional = false, }: Partial & { - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; }) => { return ( @@ -112,7 +112,7 @@ export const Subject = ({ hasPasskey, isSignedIntoFirefox, sendError, - useFxAStatusResult: mockUseFxAStatus({ supportsKeysOptionalLogin }), + useFxAStatusResult: mockUseFxAStatus({ browserSupportsKeysOptional }), }} /> diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx index 8ef57846081..25aca5aa674 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/container.tsx @@ -32,13 +32,13 @@ type SigninRecoveryCodeLocationState = { export type SigninRecoveryCodeContainerProps = { integration: Integration; setCurrentSplitLayout?: (value: boolean) => void; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; }; export const SigninRecoveryCodeContainer = ({ integration, setCurrentSplitLayout, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }: SigninRecoveryCodeContainerProps) => { const authClient = useAuthClient(); const { finishOAuthFlowHandler, oAuthDataError } = useFinishOAuthFlowHandler( @@ -154,7 +154,7 @@ export const SigninRecoveryCodeContainer = ({ unwrapBKey, loading: sendingPhoneCode, setCurrentSplitLayout, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }} /> ); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.test.tsx index ab80242bfdd..ffd2700c2ce 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.test.tsx @@ -334,7 +334,7 @@ describe('PageSigninRecoveryCode', () => { navigateToRecoveryPhone={jest.fn()} signinState={{ ...mockSigninLocationState, [marker]: true }} submitRecoveryCode={submitSuccess()} - supportsKeysOptionalLogin={false} + browserSupportsKeysOptional={false} /> ); @@ -374,7 +374,7 @@ describe('PageSigninRecoveryCode', () => { isPasswordlessOtpSignin: true, }} submitRecoveryCode={submitSuccess()} - supportsKeysOptionalLogin={true} + browserSupportsKeysOptional={true} /> ); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx index 6c9fe8612c4..0ccdcf22d80 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/index.tsx @@ -45,7 +45,7 @@ const SigninRecoveryCode = ({ unwrapBKey, loading = false, setCurrentSplitLayout, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }: SigninRecoveryCodeProps) => { useEffect(() => { GleanMetrics.loginBackupCode.view(); @@ -104,7 +104,7 @@ const SigninRecoveryCode = ({ if ( (signinState.isPasswordlessOtpSignin || signinState.isSignInWithThirdPartyAuth) && - integration.requiresPasswordForLogin(supportsKeysOptionalLogin) + integration.requiresPasswordForLogin(browserSupportsKeysOptional) ) { navigateWithQuery('/post_verify/set_password', { replace: true, @@ -162,7 +162,7 @@ const SigninRecoveryCode = ({ ftlMsgResolver, authClient, signinState, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, navigateWithQuery, ]); diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/interfaces.ts b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/interfaces.ts index f00042670c4..8a1c13ead0d 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryCode/interfaces.ts @@ -16,7 +16,7 @@ export type SigninRecoveryCodeProps = { lastFourPhoneDigits?: string; loading?: boolean; setCurrentSplitLayout?: (value: boolean) => void; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; } & SensitiveData.AuthData; export type SubmitRecoveryCode = ( diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx index 204e14f3811..d995d36ebd7 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/container.tsx @@ -35,7 +35,7 @@ import { SigninLocationState } from '../interfaces'; const SigninRecoveryPhoneContainer = ({ integration, setCurrentSplitLayout, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }: SigninRecoveryPhoneContainerProps) => { const alertBar = useAlertBar(); const authClient = useAuthClient(); @@ -116,7 +116,7 @@ const SigninRecoveryPhoneContainer = ({ // defer the success alert and browser login messages. if ( isPasswordlessSignin && - integration.requiresPasswordForLogin(supportsKeysOptionalLogin) + integration.requiresPasswordForLogin(browserSupportsKeysOptional) ) { navigateWithQuery('/post_verify/set_password', { replace: true, diff --git a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/interfaces.ts b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/interfaces.ts index 0a76c19482c..398615d1ad0 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signin/SigninRecoveryPhone/interfaces.ts @@ -10,7 +10,7 @@ import { SigninIntegration, SigninLocationState } from '../interfaces'; export interface SigninRecoveryPhoneContainerProps { integration: Integration; setCurrentSplitLayout?: (value: boolean) => void; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; } export interface SigninRecoveryPhoneLocationState extends SigninLocationState { diff --git a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.test.tsx index 26998191ea3..83cd6479fd8 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTotpCode/index.test.tsx @@ -95,7 +95,11 @@ describe('Sign in with TOTP code page', () => { }); it('renders as expected', () => { - renderWithLocalizationProvider(); + renderWithLocalizationProvider( + + + + ); const headingEl = screen.getByRole('heading', { level: 2 }); expect(headingEl).toHaveTextContent('Enter two-step authentication code'); @@ -108,7 +112,11 @@ describe('Sign in with TOTP code page', () => { }); it('enables submit button when code entered', async () => { - renderWithLocalizationProvider(); + renderWithLocalizationProvider( + + + + ); const inputEl = screen.getByLabelText('Enter 6-digit code'); await waitFor(() => userEvent.type(inputEl, '123456')); @@ -159,7 +167,11 @@ describe('Sign in with TOTP code page', () => { }); it('emits a metrics event on render', () => { - renderWithLocalizationProvider(); + renderWithLocalizationProvider( + + + + ); expect(GleanMetrics.totpForm.view).toHaveBeenCalledTimes(1); expect(GleanMetrics.totpForm.submit).toHaveBeenCalledTimes(0); expect(GleanMetrics.totpForm.success).toHaveBeenCalledTimes(0); @@ -205,7 +217,9 @@ describe('Sign in with TOTP code page', () => { expect(GleanMetrics.totpForm.view).toHaveBeenCalledTimes(1); expect(GleanMetrics.totpForm.submit).toHaveBeenCalledTimes(1); expect(GleanMetrics.totpForm.success).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith('/settings', { replace: false }); + expect(mockNavigate).toHaveBeenCalledWith('/settings', { + replace: false, + }); }); describe('fxaLogin webchannel message', () => { @@ -522,7 +536,7 @@ describe('Sign in with TOTP code page', () => { { { false, wantsKeysIfPasswordEntered: () => false, wantsKeys: () => false, - requiresPasswordForLogin(supportsKeysOptionalLogin = false) { + requiresPasswordForLogin(browserSupportsKeysOptional = false) { return ( this.requiresKeys() || - (!supportsKeysOptionalLogin && this.wantsKeysIfPasswordEntered()) + (!browserSupportsKeysOptional && this.wantsKeysIfPasswordEntered()) ); }, isFirefoxClientServiceRelay: () => false, @@ -61,10 +61,10 @@ export const mockOAuthNativeSigninIntegration = ( requiresKeys: () => false, wantsKeysIfPasswordEntered: () => false, wantsKeys: () => false, - requiresPasswordForLogin(supportsKeysOptionalLogin = false) { + requiresPasswordForLogin(browserSupportsKeysOptional = false) { return ( this.requiresKeys() || - (!supportsKeysOptionalLogin && this.wantsKeysIfPasswordEntered()) + (!browserSupportsKeysOptional && this.wantsKeysIfPasswordEntered()) ); }, isFirefoxClientServiceRelay: () => isRelay, @@ -112,9 +112,9 @@ export const Subject = ({ serviceName = MozServices.Default, signinState = MOCK_TOTP_LOCATION_STATE, submitTotpCode = mockSubmitTotpCode, - supportsKeysOptionalLogin = false, + browserSupportsKeysOptional = false, }: Partial & { - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; }) => { return ( @@ -125,7 +125,7 @@ export const Subject = ({ serviceName, signinState, submitTotpCode, - useFxAStatusResult: mockUseFxAStatus({ supportsKeysOptionalLogin }), + useFxAStatusResult: mockUseFxAStatus({ browserSupportsKeysOptional }), }} /> diff --git a/packages/fxa-settings/src/pages/Signin/SigninUnblock/mocks.tsx b/packages/fxa-settings/src/pages/Signin/SigninUnblock/mocks.tsx index 06e7097658a..93dd22fb81a 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninUnblock/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninUnblock/mocks.tsx @@ -84,6 +84,13 @@ export function createMockSigninWebSyncIntegration() { isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, isFirefoxNonSync: () => false, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, + allowsPreKeysSyncLogin: () => false, + nonSyncKeysRequirePassword(b: boolean) { + return !b && this.wantsKeysIfPasswordEntered(); + }, getWebChannelServices: mockGetWebChannelServices({ isSync: true }), wantsLogin: () => false, getCmsInfo: () => undefined, diff --git a/packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx b/packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx index a8766ae4fdd..30519f8b1a0 100644 --- a/packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/components/SigninAlternativeAuthOptions/index.tsx @@ -43,7 +43,7 @@ const SigninAlternativeAuthOptions = ({ localizedSuccessBannerDescription, isSignedIntoFirefox = false, setCurrentSplitLayout, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }: SigninAlternativeAuthOptionsProps) => { const config = useConfig(); const authClient = useAuthClient(); @@ -66,7 +66,7 @@ const SigninAlternativeAuthOptions = ({ flowQueryParams, surface: 'alternative_auth', isButtonVisible: showPasskeySignin, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }); const { diff --git a/packages/fxa-settings/src/pages/Signin/components/SigninCached/index.test.tsx b/packages/fxa-settings/src/pages/Signin/components/SigninCached/index.test.tsx index facd761d813..4844574d037 100644 --- a/packages/fxa-settings/src/pages/Signin/components/SigninCached/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/components/SigninCached/index.test.tsx @@ -128,7 +128,7 @@ describe('SigninCached', () => { }), hasPassword: false, hasLinkedAccount: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, cachedSigninHandler: mockCachedSigninSuccess(), }); @@ -140,7 +140,7 @@ describe('SigninCached', () => { isSignInWithThirdPartyAuth: true, handleFxaLogin: false, handleFxaOAuthLogin: false, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }) ); }); @@ -155,7 +155,7 @@ describe('SigninCached', () => { }), hasPassword: false, hasLinkedAccount: true, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, cachedSigninHandler: mockCachedSigninSuccess(), }); @@ -167,7 +167,7 @@ describe('SigninCached', () => { isSignInWithThirdPartyAuth: false, handleFxaLogin: true, handleFxaOAuthLogin: true, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }) ); }); @@ -182,7 +182,7 @@ describe('SigninCached', () => { }), hasPassword: true, isSignedIntoFirefox: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, cachedSigninHandler: mockCachedSigninSuccess(), }); diff --git a/packages/fxa-settings/src/pages/Signin/components/SigninCached/index.tsx b/packages/fxa-settings/src/pages/Signin/components/SigninCached/index.tsx index 1d2b66c9798..1123c4ae4a4 100644 --- a/packages/fxa-settings/src/pages/Signin/components/SigninCached/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/components/SigninCached/index.tsx @@ -46,7 +46,7 @@ const SigninCached = ({ isSignedIntoFirefox = false, setCurrentSplitLayout, onSessionExpired, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }: SigninCachedProps) => { const authClient = useAuthClient(); const config = useConfig(); @@ -63,7 +63,7 @@ const SigninCached = ({ // and let handleNavigation route to set_password. const deferKeysUntilPasswordSet = !hasPassword && - integration.requiresPasswordForLogin(supportsKeysOptionalLogin); + integration.requiresPasswordForLogin(browserSupportsKeysOptional); const { clientId, @@ -166,7 +166,7 @@ const SigninCached = ({ // auth or OTP) defer web channel messages until after password creation. handleFxaLogin: !deferKeysUntilPasswordSet, handleFxaOAuthLogin: !deferKeysUntilPasswordSet, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, // Redirect these passwordless users to set_password after session // verification. isSignInWithThirdPartyAuth: deferKeysUntilPasswordSet, @@ -203,7 +203,7 @@ const SigninCached = ({ integration, finishOAuthFlowHandler, deferKeysUntilPasswordSet, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, location.search, webRedirectCheck, isServiceWithEmailVerification, diff --git a/packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.test.tsx b/packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.test.tsx index 9dcdc423e1f..0b469445868 100644 --- a/packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.test.tsx @@ -15,7 +15,8 @@ import { createMockSigninOAuthNativeIntegration, createMockSigninOAuthNativeSyncIntegration, } from '../../mocks'; -import { MOCK_EMAIL, MOCK_SESSION_TOKEN } from '../../../mocks'; +import { MOCK_EMAIL, MOCK_SESSION_TOKEN, MOCK_UID } from '../../../mocks'; +import * as CacheModule from '../../../../lib/cache'; jest.mock('../../../../lib/storage-utils', () => ({ storeAccountData: jest.fn(), @@ -67,7 +68,7 @@ describe('SigninDecider routing', () => { }); describe('cached vs password based on keys-optional', () => { - it('routes to cached signin for service=relay when supportsKeysOptionalLogin is true', () => { + it('routes to cached signin for service=relay when browserSupportsKeysOptional is true', () => { const integration = createMockSigninOAuthNativeIntegration({ service: OAuthNativeServices.Relay, isSync: false, @@ -75,13 +76,13 @@ describe('SigninDecider routing', () => { render({ integration, sessionToken: MOCK_SESSION_TOKEN, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); passwordInputNotRendered(); }); - it('routes to cached signin for service=smartwindow when supportsKeysOptionalLogin is true', () => { + it('routes to cached signin for service=smartwindow when browserSupportsKeysOptional is true', () => { const integration = createMockSigninOAuthNativeIntegration({ service: OAuthNativeServices.SmartWindow, isSync: false, @@ -89,13 +90,13 @@ describe('SigninDecider routing', () => { render({ integration, sessionToken: MOCK_SESSION_TOKEN, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); passwordInputNotRendered(); }); - it('routes to password signin for service=relay when supportsKeysOptionalLogin is false', () => { + it('routes to password signin for service=relay when browserSupportsKeysOptional is false', () => { const integration = createMockSigninOAuthNativeIntegration({ service: OAuthNativeServices.Relay, isSync: false, @@ -103,7 +104,7 @@ describe('SigninDecider routing', () => { render({ integration, sessionToken: MOCK_SESSION_TOKEN, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, isSignedIntoFirefox: false, }); @@ -125,7 +126,7 @@ describe('SigninDecider routing', () => { integration, sessionToken: MOCK_SESSION_TOKEN, isSignedIntoFirefox: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); passwordInputNotRendered(); @@ -142,7 +143,7 @@ describe('SigninDecider routing', () => { integration, sessionToken: MOCK_SESSION_TOKEN, isSignedIntoFirefox: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); passwordInputNotRendered(); @@ -158,7 +159,7 @@ describe('SigninDecider routing', () => { integration, sessionToken: MOCK_SESSION_TOKEN, isSignedIntoFirefox: false, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); passwordInputRendered(); @@ -174,7 +175,7 @@ describe('SigninDecider routing', () => { integration, sessionToken: MOCK_SESSION_TOKEN, isSignedIntoFirefox: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); passwordInputRendered(); @@ -320,4 +321,59 @@ describe('SigninDecider routing', () => { }); }); }); + + describe('resume reroute to enter-password fallback', () => { + const storedAccount = { + uid: MOCK_UID, + email: MOCK_EMAIL, + sessionToken: MOCK_SESSION_TOKEN, + verified: true, + }; + + it('reroutes a cached has-password Sync account signed into Firefox to /signin_passkey_fallback', async () => { + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(storedAccount); + render({ + integration: createMockSigninOAuthNativeSyncIntegration(), + sessionToken: MOCK_SESSION_TOKEN, + isSignedIntoFirefox: true, + }); + + await waitFor(() => { + expect(mockPasswordlessNavigate).toHaveBeenCalledWith( + '/signin_passkey_fallback', + { state: { reason: 'resume' } } + ); + }); + }); + + it('does NOT reroute when the browser is not signed in (no session to resume)', () => { + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(storedAccount); + render({ + integration: createMockSigninOAuthNativeSyncIntegration(), + sessionToken: MOCK_SESSION_TOKEN, + isSignedIntoFirefox: false, + }); + + expect(mockPasswordlessNavigate).not.toHaveBeenCalledWith( + '/signin_passkey_fallback', + expect.anything() + ); + passwordInputRendered(); + }); + + it('does NOT reroute when there is no stored account to hydrate the fallback', () => { + jest.spyOn(CacheModule, 'currentAccount').mockReturnValue(undefined); + render({ + integration: createMockSigninOAuthNativeSyncIntegration(), + sessionToken: MOCK_SESSION_TOKEN, + isSignedIntoFirefox: true, + }); + + expect(mockPasswordlessNavigate).not.toHaveBeenCalledWith( + '/signin_passkey_fallback', + expect.anything() + ); + passwordInputRendered(); + }); + }); }); diff --git a/packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.tsx b/packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.tsx index f5579fd23b0..2d8452e75b9 100644 --- a/packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/components/SigninDecider/index.tsx @@ -12,6 +12,7 @@ import { UseFxAStatusResult } from '../../../../lib/hooks/useFxAStatus'; import { MozServices } from '../../../../lib/types'; import { useFinishOAuthFlowHandler } from '../../../../lib/oauth/hooks'; import { useNavigateWithQuery } from '../../../../lib/hooks/useNavigateWithQuery'; +import { currentAccount } from '../../../../lib/cache'; import type { QueryParams } from '../../../..'; import { AvatarResponse, @@ -128,19 +129,48 @@ export const SigninDecider = ({ hasPasskey, ]); + // Turn-on-Sync (and similar resumes) for an already-signed-in account that + // has a password and needs Sync keys: send them to the enter-password + // fallback to reauth for keys, instead of a full re-sign-in. Desktop only. + // Gated on `isSignedIntoFirefox` — App validates the browser's signed-in + // session server-side before setting it, so this both confirms there's a + // session to resume AND avoids rerouting onto a stale local-storage token + // (e.g. after signing out from the browser menu). The stored-uid check keeps + // the fallback able to hydrate from local storage. + const enterPasswordForSyncKeys = + hasCachedSession && + hasPassword && + integration.requiresKeys() && + !integration.isFirefoxMobileClient() && + !!isSignedIntoFirefox && + !!currentAccount()?.uid; + + useEffect(() => { + if (enterPasswordForSyncKeys) { + navigateWithQuery('/signin_passkey_fallback', { + state: { reason: 'resume' }, + }); + } + }, [enterPasswordForSyncKeys, navigateWithQuery]); + if (shouldRedirectToPasswordless) { return null; } + if (enterPasswordForSyncKeys) { + return null; + } + const isOAuth = isOAuthIntegration(integration); + const browserSupportsKeysOptional = + useFxAStatusResult.browserSupportsKeysOptional; // Relay browser service login launched in Firefox desktop 135, and the "keys optional" // capability (Sync decoupling) launched in Fx desktop 147, meaning all Relay service users // in those Fx versions require a password. // This also covers Mobile until Sync is decoupled except for the authorization state below. const syncNotDecoupledRequiresPassword = - !useFxAStatusResult.supportsKeysOptionalLogin && - integration.wantsKeysIfPasswordEntered(); + integration.nonSyncKeysRequirePassword(browserSupportsKeysOptional); // In Firefox Android 153, Sync is not decoupled, but we need to show cached sign-in // for signed-in users (e.g., signed into Sync) that are authorizing VPN. @@ -164,7 +194,8 @@ export const SigninDecider = ({ // Do we have a session token, and can we defer the key fetch because sync is decoupled? const keysOptional = - hasCachedSession && useFxAStatusResult.supportsKeysOptionalLogin; + hasCachedSession && + integration.supportsKeylessLogin(browserSupportsKeysOptional); // Determine whether to show the cached view (no password input). Keys always // require a password for derivation, but we can skip it when: @@ -202,8 +233,7 @@ export const SigninDecider = ({ isSignedIntoFirefox, setCurrentSplitLayout, onSessionExpired, - supportsKeysOptionalLogin: - useFxAStatusResult.supportsKeysOptionalLogin, + browserSupportsKeysOptional, }} /> ); @@ -237,8 +267,7 @@ export const SigninDecider = ({ flowQueryParams, isSignedIntoFirefox, setCurrentSplitLayout, - supportsKeysOptionalLogin: - useFxAStatusResult.supportsKeysOptionalLogin, + browserSupportsKeysOptional, }} /> ); diff --git a/packages/fxa-settings/src/pages/Signin/index.stories.tsx b/packages/fxa-settings/src/pages/Signin/index.stories.tsx index aa0368fbca9..bb187eb4642 100644 --- a/packages/fxa-settings/src/pages/Signin/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.stories.tsx @@ -83,7 +83,7 @@ export const NonCachedNonSyncBrowserServiceBrowserHasPasswordlessCapability: Sto service: OAuthNativeServices.SmartWindow, isSync: false, }), - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }), name: 'Non-Cached > Non-Sync browser service > Browser has Sync keys optional capability', }; @@ -96,7 +96,7 @@ export const NonCachedNonSyncBrowserServiceBrowserDoesNotHavePasswordlessCapabil service: OAuthNativeServices.Relay, isSync: false, }), - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }), name: 'Non-Cached > Non-Sync browser service > Browser does not have Sync keys optional capability', }; @@ -147,7 +147,7 @@ export const CachedNonSyncBrowserServiceWithoutPasswordlessCapability: Story = { service: OAuthNativeServices.SmartWindow, isSync: false, }), - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }), name: 'Cached > Non-Sync browser service > Browser does not have Sync keys optional capability', }; @@ -161,7 +161,7 @@ export const CachedNonSyncBrowserServiceWithPasswordlessCapabilitySignedIntoDesk service: OAuthNativeServices.SmartWindow, isSync: false, }), - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, isSignedIntoFirefox: true, }), name: 'Cached > Non-Sync browser service > Browser has Sync keys optional capability > Account is signed into Firefox Desktop', @@ -178,7 +178,7 @@ export const CachedNonSyncBrowserServiceWithPasswordlessCapabilityNotSignedIntoD service: OAuthNativeServices.SmartWindow, isSync: false, }), - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }), name: 'Cached > Non-Sync browser service > Browser has Sync keys optional capability > Account is not signed into Firefox Desktop', }; @@ -192,7 +192,7 @@ export const CachedNonSyncBrowserServiceMobileAuthorizationFlow: Story = { isSync: false, isMobile: true, }), - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, isSignedIntoFirefox: true, }), name: 'Cached > Non-Sync browser service > Browser does not have keys optional capability > Account is signed into Firefox Mobile', @@ -207,7 +207,7 @@ export const CachedNonSyncBrowserServiceMobileNotSignedIn: Story = { isSync: false, isMobile: true, }), - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, isSignedIntoFirefox: false, }), name: 'Cached > Non-Sync browser service > Browser does not have keys optional capability > Account is not signed into Firefox Mobile', diff --git a/packages/fxa-settings/src/pages/Signin/index.test.tsx b/packages/fxa-settings/src/pages/Signin/index.test.tsx index 94ae7ef73d5..33e243f6e1f 100644 --- a/packages/fxa-settings/src/pages/Signin/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.test.tsx @@ -308,14 +308,14 @@ describe('Signin component', () => { }); }); - it('does not render third party auth when service=relay and supportsKeysOptionalLogin is false', () => { + it('does not render third party auth when service=relay and browserSupportsKeysOptional is false', () => { const integration = createMockSigninOAuthNativeIntegration({ service: OAuthNativeServices.Relay, isSync: false, }); render({ integration, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, }); thirdPartyAuthNotRendered(); @@ -324,14 +324,14 @@ describe('Signin component', () => { }); }); - it('renders third party auth when service=relay and supportsKeysOptionalLogin is true', () => { + it('renders third party auth when service=relay and browserSupportsKeysOptional is true', () => { const integration = createMockSigninOAuthNativeIntegration({ service: OAuthNativeServices.Relay, isSync: false, }); render({ integration, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); expect( @@ -345,14 +345,14 @@ describe('Signin component', () => { }); }); - it('renders third party auth when service=smartwindow and supportsKeysOptionalLogin is true', () => { + it('renders third party auth when service=smartwindow and browserSupportsKeysOptional is true', () => { const integration = createMockSigninOAuthNativeIntegration({ service: OAuthNativeServices.SmartWindow, isSync: false, }); render({ integration, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); expect( @@ -1027,7 +1027,7 @@ describe('Signin component', () => { passwordInputNotRendered(); }); - it('sends webchannel message if cached signin for service=relay when supportsKeysOptionalLogin is true', async () => { + it('sends webchannel message if cached signin for service=relay when browserSupportsKeysOptional is true', async () => { const fxaLoginSpy = jest.spyOn(firefox, 'fxaLogin'); const integration = createMockSigninOAuthNativeIntegration({ @@ -1037,7 +1037,7 @@ describe('Signin component', () => { render({ integration, sessionToken: MOCK_SESSION_TOKEN, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, }); await submit(); await waitFor(() => { @@ -1679,7 +1679,7 @@ describe('Signin component', () => { integration={integration} sessionToken={MOCK_SESSION_TOKEN} isSignedIntoFirefox={true} - supportsKeysOptionalLogin={true} + browserSupportsKeysOptional={true} /> ); @@ -1931,7 +1931,7 @@ describe('Signin component', () => { cmsInfo: cachedCmsInfo, }), isSignedIntoFirefox: true, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, sessionToken: MOCK_SESSION_TOKEN, hasPassword: true, }); diff --git a/packages/fxa-settings/src/pages/Signin/index.tsx b/packages/fxa-settings/src/pages/Signin/index.tsx index a9e9db6d6bd..e5cc7fbb2ed 100644 --- a/packages/fxa-settings/src/pages/Signin/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/index.tsx @@ -54,7 +54,7 @@ const Signin = ({ localizedSuccessBannerHeading, localizedSuccessBannerDescription, flowQueryParams, - useFxAStatusResult: { supportsKeysOptionalLogin }, + useFxAStatusResult: { browserSupportsKeysOptional }, isSignedIntoFirefox = false, setCurrentSplitLayout, }: SigninProps) => { @@ -84,7 +84,7 @@ const Signin = ({ flowQueryParams, surface: 'login', isButtonVisible: showPasskeySignin, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }); const [localizedBannerError, setLocalizedBannerError] = useState( @@ -140,7 +140,8 @@ const Signin = ({ // Show for all other cases. const hideThirdPartyAuth = isSync ? hasPassword - : isOAuthNative && !supportsKeysOptionalLogin; + : isOAuthNative && + !integration.supportsKeylessLogin(browserSupportsKeysOptional); useEffect(() => { GleanMetrics.login.view({ diff --git a/packages/fxa-settings/src/pages/Signin/interfaces.ts b/packages/fxa-settings/src/pages/Signin/interfaces.ts index e9a12c776c4..b958ac383f6 100644 --- a/packages/fxa-settings/src/pages/Signin/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signin/interfaces.ts @@ -34,6 +34,9 @@ export type SigninUnblockIntegration = Pick< | 'wantsKeysIfPasswordEntered' | 'wantsKeys' | 'requiresPasswordForLogin' + | 'nonSyncKeysRequirePassword' + | 'supportsKeylessLogin' + | 'allowsPreKeysSyncLogin' | 'data' | 'isDesktopSync' | 'isFirefoxClientServiceRelay' @@ -60,6 +63,9 @@ export type SigninIntegration = | 'wantsKeysIfPasswordEntered' | 'wantsKeys' | 'requiresPasswordForLogin' + | 'nonSyncKeysRequirePassword' + | 'supportsKeylessLogin' + | 'allowsPreKeysSyncLogin' | 'data' | 'isDesktopSync' | 'isFirefoxClientServiceRelay' @@ -86,6 +92,9 @@ export type SigninOAuthIntegration = Pick< | 'wantsKeysIfPasswordEntered' | 'wantsKeys' | 'requiresPasswordForLogin' + | 'nonSyncKeysRequirePassword' + | 'supportsKeylessLogin' + | 'allowsPreKeysSyncLogin' | 'wantsLogin' | 'data' | 'isDesktopSync' @@ -144,11 +153,11 @@ export interface SigninCachedProps extends SigninSharedProps { sessionToken: hexstring; cachedSigninHandler: CachedSigninHandler; onSessionExpired: (localizedErrorMessage: string) => void; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; } export type SigninAlternativeAuthOptionsProps = SigninSharedProps & { - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; }; export type BeginSigninHandler = ( @@ -275,7 +284,7 @@ export interface NavigationOptions { // Fx desktop 147+). When false, a non-Sync Firefox client that wants keys // (e.g. Android VPN) must set a password before keys can be derived. Used together // with `requiresPasswordForLogin` to decide the set_password redirect. - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; handleFxaLogin?: boolean; handleFxaOAuthLogin?: boolean; syncEngines?: { @@ -301,6 +310,14 @@ export interface OAuthSigninResult { shouldHardNavigate: string; } +/** + * Why the user reached the enter-password-for-keys page + * (`SigninPasskeyFallback`): `passkey` after a passkey sign-in, or `resume` + * when turning on Sync for an already-signed-in keyless account. Drives the + * page copy, Glean events, and `verificationMethod`. + */ +export type EnterPasswordReason = 'passkey' | 'resume'; + export interface SigninLocationState { email: string; uid: hexstring; @@ -320,4 +337,15 @@ export interface SigninLocationState { * events. */ passkeySurface?: PasskeyMetricsSurface; + /** + * True when the passkey flow already sent the keyless `fxaccounts:login` at + * verification time (desktop pre-keys Sync login). The destination page then + * sends only the keyed `oauth_login`, avoiding a duplicate login message. + */ + syncPreKeysLoginSent?: boolean; + /** + * Why the enter-password-for-keys page was shown (default `passkey`); set to + * `resume` for the Turn-on-Sync resume of a keyless account. + */ + reason?: EnterPasswordReason; } diff --git a/packages/fxa-settings/src/pages/Signin/mocks.tsx b/packages/fxa-settings/src/pages/Signin/mocks.tsx index f4c86adb80e..2049f5971cd 100644 --- a/packages/fxa-settings/src/pages/Signin/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signin/mocks.tsx @@ -99,10 +99,10 @@ export function createMockSigninWebIntegration({ requiresKeys: () => false, wantsKeysIfPasswordEntered: () => false, wantsKeys: () => false, - requiresPasswordForLogin(supportsKeysOptionalLogin = false) { + requiresPasswordForLogin(browserSupportsKeysOptional = false) { return ( this.requiresKeys() || - (!supportsKeysOptionalLogin && this.wantsKeysIfPasswordEntered()) + (!browserSupportsKeysOptional && this.wantsKeysIfPasswordEntered()) ); }, data: new IntegrationData(new GenericData({})), @@ -111,6 +111,13 @@ export function createMockSigninWebIntegration({ isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, isFirefoxNonSync: () => false, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, + allowsPreKeysSyncLogin: () => false, + nonSyncKeysRequirePassword(b: boolean) { + return !b && this.wantsKeysIfPasswordEntered(); + }, getWebChannelServices: mockGetWebChannelServices(), wantsLogin: () => false, wantsTwoStepAuthentication: () => false, @@ -137,10 +144,10 @@ export function createMockSigninOAuthNativeSyncIntegration({ requiresKeys: () => isSync, wantsKeysIfPasswordEntered: () => !isSync, wantsKeys: () => true, - requiresPasswordForLogin(supportsKeysOptionalLogin = false) { + requiresPasswordForLogin(browserSupportsKeysOptional = false) { return ( this.requiresKeys() || - (!supportsKeysOptionalLogin && this.wantsKeysIfPasswordEntered()) + (!browserSupportsKeysOptional && this.wantsKeysIfPasswordEntered()) ); }, getService: () => MozServices.FirefoxSync, @@ -151,6 +158,13 @@ export function createMockSigninOAuthNativeSyncIntegration({ isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, isFirefoxNonSync: () => !isSync && !isMobile, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, + allowsPreKeysSyncLogin: () => false, + nonSyncKeysRequirePassword(b: boolean) { + return !b && this.wantsKeysIfPasswordEntered(); + }, getWebChannelServices: mockGetWebChannelServices({ isSync }), wantsLogin: () => false, wantsTwoStepAuthentication: () => false, @@ -181,10 +195,10 @@ export function createMockSigninOAuthIntegration({ requiresKeys: () => false, wantsKeysIfPasswordEntered: () => false, wantsKeys: () => false, - requiresPasswordForLogin(supportsKeysOptionalLogin = false) { + requiresPasswordForLogin(browserSupportsKeysOptional = false) { return ( this.requiresKeys() || - (!supportsKeysOptionalLogin && this.wantsKeysIfPasswordEntered()) + (!browserSupportsKeysOptional && this.wantsKeysIfPasswordEntered()) ); }, wantsLogin: () => false, @@ -195,6 +209,13 @@ export function createMockSigninOAuthIntegration({ isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, isFirefoxNonSync: () => false, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, + allowsPreKeysSyncLogin: () => false, + nonSyncKeysRequirePassword(b: boolean) { + return !b && this.wantsKeysIfPasswordEntered(); + }, getWebChannelServices: mockGetWebChannelServices({ isSync }), getCmsInfo: () => cmsInfo, isFirefoxClient: () => false, @@ -225,10 +246,10 @@ export function createMockSigninOAuthNativeIntegration({ requiresKeys: () => isSync, wantsKeysIfPasswordEntered: () => isRelay || isSmartWindow || isVpn, wantsKeys: () => true, - requiresPasswordForLogin(supportsKeysOptionalLogin = false) { + requiresPasswordForLogin(browserSupportsKeysOptional = false) { return ( this.requiresKeys() || - (!supportsKeysOptionalLogin && this.wantsKeysIfPasswordEntered()) + (!browserSupportsKeysOptional && this.wantsKeysIfPasswordEntered()) ); }, wantsLogin: () => false, @@ -239,6 +260,13 @@ export function createMockSigninOAuthNativeIntegration({ isFirefoxClientServiceSmartWindow: () => isSmartWindow, isFirefoxClientServiceVpn: () => isVpn, isFirefoxNonSync: () => isRelay || isSmartWindow || isVpn, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, + allowsPreKeysSyncLogin: () => false, + nonSyncKeysRequirePassword(b: boolean) { + return !b && this.wantsKeysIfPasswordEntered(); + }, getWebChannelServices: mockGetWebChannelServices({ isSync, isRelay, @@ -367,7 +395,7 @@ export const mockSendUnblockEmailHandler: SendUnblockEmailHandler = () => export type SubjectProps = Partial & { sessionToken?: hexstring; cachedSigninHandler?: CachedSigninHandler; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; passwordlessSupported?: boolean; skipPasswordlessRedirect?: boolean; /** @@ -395,11 +423,11 @@ export const Subject = ({ sendUnblockEmailHandler = mockSendUnblockEmailHandler, finishOAuthFlowHandler = mockFinishOAuthFlowHandler, isSignedIntoFirefox = false, - supportsKeysOptionalLogin = false, + browserSupportsKeysOptional = false, passkeyEnabled = false, ...props // overrides }: SubjectProps = {}) => { - const useFxAStatusResult = mockUseFxAStatus({ supportsKeysOptionalLogin }); + const useFxAStatusResult = mockUseFxAStatus({ browserSupportsKeysOptional }); const contextValue = mockAppContext(); if (passkeyEnabled && contextValue.config) { contextValue.config.featureFlags = { diff --git a/packages/fxa-settings/src/pages/Signin/utils.test.ts b/packages/fxa-settings/src/pages/Signin/utils.test.ts index 954cbf71e01..44939748345 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.test.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.test.ts @@ -397,7 +397,9 @@ describe('Signin utils', () => { const result = await handleNavigation(navigationOptions); expect(result.error).toBeUndefined(); - expect(sessionResendVerifyCode).toHaveBeenCalledWith(MOCK_SESSION_TOKEN); + expect(sessionResendVerifyCode).toHaveBeenCalledWith( + MOCK_SESSION_TOKEN + ); expect(mockNavigate).toHaveBeenCalledWith( '/confirm_signup_code', expect.any(Object) @@ -521,7 +523,7 @@ describe('Signin utils', () => { service: OAuthNativeServices.Vpn, }), isSignInWithThirdPartyAuth: true, - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, performNavigation: true, handleFxaOAuthLogin: false, }); @@ -552,7 +554,7 @@ describe('Signin utils', () => { service: OAuthNativeServices.Vpn, }), isSignInWithThirdPartyAuth: true, - supportsKeysOptionalLogin: true, + browserSupportsKeysOptional: true, performNavigation: true, handleFxaOAuthLogin: true, }); diff --git a/packages/fxa-settings/src/pages/Signin/utils.ts b/packages/fxa-settings/src/pages/Signin/utils.ts index 9ad5c3a5032..95d45851822 100644 --- a/packages/fxa-settings/src/pages/Signin/utils.ts +++ b/packages/fxa-settings/src/pages/Signin/utils.ts @@ -298,7 +298,8 @@ export async function handleNavigation(navigationOptions: NavigationOptions) { // unverified email) and we know their session isn't fully verified, then send them // an otp code. Sending here couples the email with the actual navigation action. if ( - (to?.includes('signin_token_code') || to?.includes('confirm_signup_code')) && + (to?.includes('signin_token_code') || + to?.includes('confirm_signup_code')) && navigationOptions.signinData.sessionToken && navigationOptions.signinData.verificationMethod === VerificationMethods.EMAIL_OTP @@ -332,7 +333,8 @@ export async function handleNavigation(navigationOptions: NavigationOptions) { return { error }; } if (to) { - performNavigation({ navigate, + performNavigation({ + navigate, to, locationState, shouldHardNavigate, @@ -368,7 +370,8 @@ export async function handleNavigation(navigationOptions: NavigationOptions) { if (navigationOptions.performNavigation !== false) { const { to, locationState, shouldHardNavigate } = await getNonOAuthNavigationTarget(navigationOptions); - performNavigation({ navigate, + performNavigation({ + navigate, to, locationState, shouldHardNavigate, @@ -409,7 +412,8 @@ export async function handleNavigation(navigationOptions: NavigationOptions) { if (to === '/post_verify/service_welcome') { navigate(to, { state: { origin: 'signin' }, replace: true }); } else { - performNavigation({ navigate, + performNavigation({ + navigate, to, locationState, shouldHardNavigate, @@ -574,7 +578,7 @@ const getOAuthNavigationTarget = async ( if ( navigationOptions.isSignInWithThirdPartyAuth && navigationOptions.integration.requiresPasswordForLogin( - navigationOptions.supportsKeysOptionalLogin + navigationOptions.browserSupportsKeysOptional ) ) { const syncNav = getSyncNavigate(navigationOptions.queryParams, { diff --git a/packages/fxa-settings/src/pages/Signup/container.test.tsx b/packages/fxa-settings/src/pages/Signup/container.test.tsx index c9b3d75f770..7ac1868497b 100644 --- a/packages/fxa-settings/src/pages/Signup/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signup/container.test.tsx @@ -61,6 +61,9 @@ function mockIntegration() { isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, isFirefoxNonSync: () => false, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, getWebChannelServices: mockGetWebChannelServices({ isSync: true }), getCmsInfo: () => undefined, getLegalTerms: () => undefined, @@ -192,7 +195,7 @@ async function render(text?: string) { offeredSyncEngineConfigs: [], selectedEnginesForGlean: {}, declinedSyncEngines: [], - supportsKeysOptionalLogin: false, + browserSupportsKeysOptional: false, supportsCanLinkAccountUid: false, }} flowQueryParams={{ flowId: MOCK_FLOW_ID }} diff --git a/packages/fxa-settings/src/pages/Signup/index.stories.tsx b/packages/fxa-settings/src/pages/Signup/index.stories.tsx index e985f0073d2..47efa43161f 100644 --- a/packages/fxa-settings/src/pages/Signup/index.stories.tsx +++ b/packages/fxa-settings/src/pages/Signup/index.stories.tsx @@ -31,16 +31,16 @@ const StoryWithProps = ({ integration = createMockSignupOAuthWebIntegration(), isMobile = false, offeredSyncEnginesOverride, - supportsKeysOptionalLogin = false, + browserSupportsKeysOptional = false, }: { integration?: SignupIntegration; offeredSyncEnginesOverride?: ReturnType; isMobile?: boolean; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; }) => { const useFxAStatusResult = mockUseFxAStatus({ offeredSyncEnginesOverride, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }); return ( @@ -91,7 +91,7 @@ export const WithThirdPartyAuthServiceRelayIntegration = () => ( OAuthNativeServices.Relay, false )} - supportsKeysOptionalLogin={true} + browserSupportsKeysOptional={true} /> ); @@ -101,7 +101,7 @@ export const WithThirdPartyAuthServiceSmartWindowIntegration = () => ( OAuthNativeServices.SmartWindow, false )} - supportsKeysOptionalLogin={true} + browserSupportsKeysOptional={true} /> ); diff --git a/packages/fxa-settings/src/pages/Signup/index.test.tsx b/packages/fxa-settings/src/pages/Signup/index.test.tsx index 794224d8381..4cdbb0d03d4 100644 --- a/packages/fxa-settings/src/pages/Signup/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signup/index.test.tsx @@ -113,7 +113,7 @@ const user = userEvent.setup(); // Password confirmation is required only when the password derives encryption // keys: Sync (keys mandatory), and non-Sync Firefox services (Relay/VPN/ // SmartWindow) when the browser lacks the keys-optional capability -// (`supportsKeysOptionalLogin` false). Plain web/OAuth-web signups omit it. +// (`browserSupportsKeysOptional` false). Plain web/OAuth-web signups omit it. describe('Signup page', () => { beforeEach(() => { @@ -205,7 +205,7 @@ describe('Signup page', () => { ); }); - it('renders third party auth when service=relay and supportsKeysOptionalLogin is true', async () => { + it('renders third party auth when service=relay and browserSupportsKeysOptional is true', async () => { await act(() => { renderWithLocalizationProvider( { OAuthNativeServices.Relay, false )} - supportsKeysOptionalLogin={true} + browserSupportsKeysOptional={true} /> ); }); @@ -226,7 +226,7 @@ describe('Signup page', () => { ).toBeInTheDocument(); }); - it('renders third party auth when service=smartwindow and supportsKeysOptionalLogin is true', async () => { + it('renders third party auth when service=smartwindow and browserSupportsKeysOptional is true', async () => { await act(() => { renderWithLocalizationProvider( { OAuthNativeServices.SmartWindow, false )} - supportsKeysOptionalLogin={true} + browserSupportsKeysOptional={true} /> ); }); @@ -255,7 +255,7 @@ describe('Signup page', () => { OAuthNativeServices.Vpn, false )} - supportsKeysOptionalLogin={false} + browserSupportsKeysOptional={false} /> ); }); @@ -275,7 +275,7 @@ describe('Signup page', () => { // password, so the confirmation field is dropped. /* requiresPasswordForLogin */ false )} - supportsKeysOptionalLogin={true} + browserSupportsKeysOptional={true} /> ); }); diff --git a/packages/fxa-settings/src/pages/Signup/index.tsx b/packages/fxa-settings/src/pages/Signup/index.tsx index 3ef9d95cbdd..e9ee95d92db 100644 --- a/packages/fxa-settings/src/pages/Signup/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/index.tsx @@ -50,7 +50,7 @@ export const Signup = ({ offeredSyncEngineConfigs, declinedSyncEngines, selectedEnginesForGlean, - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }, flowQueryParams, isMobile, @@ -72,7 +72,7 @@ export const Signup = ({ const paymentMethodsWillSync = isSync && checkPaymentMethodsWillSync(offeredSyncEngines); const showThirdPartyAuth = isOAuthNative - ? supportsKeysOptionalLogin + ? integration.supportsKeylessLogin(browserSupportsKeysOptional) : !isSync; const legalTerms = integration.getLegalTerms(); @@ -341,10 +341,10 @@ export const Signup = ({ // Require password confirmation whenever the password is used for key // derivation — Sync (keys mandatory) and non-Sync Firefox services // that derive keys when the browser can't complete a keyless login - // (`supportsKeysOptionalLogin` false). + // (`browserSupportsKeysOptional` false). requirePasswordConfirmation: integration.isSync() || - integration.requiresPasswordForLogin(supportsKeysOptionalLogin), + integration.requiresPasswordForLogin(browserSupportsKeysOptional), setSelectedNewsletterSlugs, cmsButton: { text: cmsInfo?.SignupSetPasswordPage.primaryButtonText, diff --git a/packages/fxa-settings/src/pages/Signup/interfaces.ts b/packages/fxa-settings/src/pages/Signup/interfaces.ts index a2f35e9b88b..22d3cf8ecfa 100644 --- a/packages/fxa-settings/src/pages/Signup/interfaces.ts +++ b/packages/fxa-settings/src/pages/Signup/interfaces.ts @@ -55,6 +55,7 @@ export type SignupOAuthIntegration = Pick< | 'requiresKeys' | 'wantsKeys' | 'requiresPasswordForLogin' + | 'supportsKeylessLogin' | 'getClientId' | 'getCmsInfo' | 'getLegalTerms' @@ -73,6 +74,7 @@ export type SignupBaseIntegration = Pick< | 'requiresKeys' | 'wantsKeys' | 'requiresPasswordForLogin' + | 'supportsKeylessLogin' | 'getClientId' | 'getCmsInfo' | 'getLegalTerms' diff --git a/packages/fxa-settings/src/pages/Signup/mocks.tsx b/packages/fxa-settings/src/pages/Signup/mocks.tsx index 45d813f251b..ae3e5671957 100644 --- a/packages/fxa-settings/src/pages/Signup/mocks.tsx +++ b/packages/fxa-settings/src/pages/Signup/mocks.tsx @@ -36,6 +36,9 @@ export function createMockSignupWebIntegration(): SignupBaseIntegration { isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, isFirefoxNonSync: () => false, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, getWebChannelServices: mockGetWebChannelServices(), requiresKeys: () => false, wantsKeys: () => false, @@ -55,6 +58,9 @@ export function createMockSignupSyncDesktopV3Integration(): SignupBaseIntegratio isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, isFirefoxNonSync: () => false, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, getWebChannelServices: mockGetWebChannelServices({ isSync: true }), requiresKeys: () => true, wantsKeys: () => true, @@ -80,6 +86,9 @@ export function createMockSignupOAuthWebIntegration( isFirefoxClientServiceSmartWindow: () => false, isFirefoxClientServiceVpn: () => false, isFirefoxNonSync: () => false, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, getWebChannelServices: mockGetWebChannelServices(), requiresKeys: () => false, wantsKeys: () => false, @@ -109,6 +118,9 @@ export function createMockSignupOAuthNativeIntegration( isFirefoxClientServiceSmartWindow: () => isSmartWindow, isFirefoxClientServiceVpn: () => isVpn, isFirefoxNonSync: () => isRelay || isSmartWindow || isVpn, + supportsKeylessLogin(b: boolean) { + return b && this.isFirefoxNonSync(); + }, getWebChannelServices: mockGetWebChannelServices({ isSync, isRelay, @@ -172,16 +184,16 @@ export const Subject = ({ beginSignupHandler = mockBeginSignupHandler, email = MOCK_EMAIL, isMobile = false, - supportsKeysOptionalLogin = false, + browserSupportsKeysOptional = false, }: { email?: string; integration?: SignupIntegration; beginSignupHandler?: BeginSignupHandler; isMobile?: boolean; - supportsKeysOptionalLogin?: boolean; + browserSupportsKeysOptional?: boolean; }) => { const mockUseFxAStatusResult = mockUseFxAStatus({ - supportsKeysOptionalLogin, + browserSupportsKeysOptional, }); return (