From 1430ef558088afc6ab3aa4e3ec6d1301f4a75c6f Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Thu, 11 Jun 2026 11:25:48 -0700 Subject: [PATCH] fix(fxa-settings): route passkey Sync sign-in to password fallback on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Because: - On mobile Firefox, signing in to Sync via a passkey left the browser in a "syncing paused" state with no redirect to the password fallback. Mobile commonly omits the service=sync URL param (defaulting to Sync via clientId), so getService() returned undefined and the server never set requiresPasswordForSync. This commit: - Sends service=sync for Sync integrations using isSync() — the signal already trusted for the can-link check — instead of the often-absent service URL param, so the password fallback redirect fires for both standard and passwordless accounts. - Skips navigation on mobile in the passkey password fallback so Firefox drives Sync completion via WebChannel messages rather than navigating the WebView away. Closes #FXA-13911 --- .../src/lib/passkeys/signin-flow.test.tsx | 26 ++++++++++++++++++ .../src/lib/passkeys/signin-flow.ts | 4 ++- .../SigninPasskeyFallback/container.test.tsx | 27 +++++++++++++++++++ .../SigninPasskeyFallback/container.tsx | 3 +++ 4 files changed, 59 insertions(+), 1 deletion(-) 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 4c821d136a8..7a2728c986a 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.test.tsx @@ -360,6 +360,32 @@ describe('usePasskeySignIn', () => { } ); + it('sends service=sync for Sync integrations even when the service URL param is absent', async () => { + // Mobile Firefox omits service=sync and defaults via clientId, so + // getService() returns undefined; isSync() must still drive service=sync. + const { args, spies } = buildArgs({ + integration: { + isSync: () => true, + isFirefoxNonSync: () => false, + getService: () => undefined, + type: IntegrationType.OAuthNative, + data: {}, + } as unknown as PasskeySignInIntegration, + }); + + const { result } = renderHook(() => usePasskeySignIn(args)); + + await act(async () => { + await result.current.onClick(); + }); + + expect(spies.completePasskeyAuthentication).toHaveBeenCalledWith( + MOCK_CREDENTIAL, + CHALLENGE, + { service: 'sync' } + ); + }); + // Locks the contract: each DOMException name maps to its expected FTL id // AND its fallback string. Drift in either lands silently otherwise. // The fallback substrings are stable phrases pulled from webauthn-errors.ts diff --git a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts index 0cb055f864f..f43813a8420 100644 --- a/packages/fxa-settings/src/lib/passkeys/signin-flow.ts +++ b/packages/fxa-settings/src/lib/passkeys/signin-flow.ts @@ -277,7 +277,9 @@ export function usePasskeySignIn({ throw err; } - const service = integration.getService(); + // Sync clients (notably mobile) often omit `service=sync` and default via + // clientId, leaving getService() undefined; isSync() is the reliable signal. + const service = integration.isSync() ? 'sync' : integration.getService(); const completion = await authClient.completePasskeyAuthentication( credential, challengeOptions.challenge, 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 6307f1db58c..a46fa5fe4ec 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.test.tsx @@ -37,6 +37,7 @@ function createMockSyncIntegration() { type: IntegrationType.OAuthNative, getService: () => MozServices.FirefoxSync, isSync: () => true, + isFirefoxMobileClient: () => false, requiresKeys: () => true, wantsKeys: () => true, getCmsInfo: () => undefined, @@ -199,6 +200,32 @@ describe('SigninPasskeyFallback container', () => { ); }); + it('navigates after reauth on desktop (performNavigation true)', async () => { + const { getByTestId } = render(); + submitPassword(getByTestId); + + await waitFor(() => { + expect(mockHandleNavigation).toHaveBeenCalledWith( + expect.objectContaining({ performNavigation: true }) + ); + }); + }); + + it('skips navigation after reauth on mobile so Firefox drives Sync completion', async () => { + const mobileIntegration = { + ...createMockSyncIntegration(), + isFirefoxMobileClient: () => true, + } as unknown as Integration; + const { getByTestId } = render(mobileIntegration); + submitPassword(getByTestId); + + await waitFor(() => { + expect(mockHandleNavigation).toHaveBeenCalledWith( + expect.objectContaining({ performNavigation: false }) + ); + }); + }); + it('renders an error banner when sessionReauth throws', async () => { mockSessionReauth.mockRejectedValueOnce({ errno: 103, diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx index 5eb2e67d6aa..6a0e23003fd 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasskeyFallback/container.tsx @@ -149,6 +149,9 @@ const SigninPasskeyFallbackContainer = ({ queryParams: location.search, handleFxaLogin: true, handleFxaOAuthLogin: true, + // On mobile, navigating the WebView away leaves Sync paused; let Firefox + // drive completion via WebChannel instead. + performNavigation: !integration.isFirefoxMobileClient(), }); if (navError) { GleanMetrics.passkeyEnterPassword.submitFrontendError({