From bf511d12164c82be0efa23e67072bfb2f06239a4 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Mon, 24 Aug 2026 14:40:30 -0400 Subject: [PATCH] feat(pairing): complete the v2 pairing flow and add functional tests Because: - FXA-13870 needs functional tests for the v2 pairing flow, and the v2 supplicant flow did not complete end to end on Firefox for Android. - /pair never negotiated v2, so Settings "Connect a device" and the post-signin handoffs stranded v2-capable pairs on the v1 choice screen. This commit: - wires the Pair2 authority and supplicant containers to the pairing channel and the pair_oauth_start / pair_oauth_finish web channels. - negotiates pairing version 2 on both /connect_another_device and /pair, and forwards the v2 supplicant entry from /pair and /pair/supp. - falls back to the known mobile pairing client id when fxa_status omits it, which Firefox for Android does. - adds v2 functional tests for desktop, Android and the pair choice screen, covering every entry path into v2 and the version gate. - resolves the Marionette authority to Firefox Nightly, which carries the v2 chrome commands, so the v2 specs skip rather than fail without it. --- .../lib/android-supplicant.ts | 72 +++- .../functional-tests/lib/firefox-binary.ts | 50 +++ .../functional-tests/lib/fixtures/pairing.ts | 7 +- .../lib/marionette-firefox.ts | 21 +- packages/functional-tests/lib/marionette.ts | 10 +- .../functional-tests/lib/pairing-constants.ts | 29 ++ .../functional-tests/lib/pairing-helpers.ts | 328 +++++++++++++----- .../lib/pairing-supplicant-harness.ts | 165 +++++++++ .../functional-tests/tests/pairing/CLAUDE.md | 11 +- .../tests/pairing/pairChoice.spec.ts | 190 +++++++++- .../tests/pairing/pairingFlow.spec.ts | 11 + .../tests/pairing/pairingFlowV2.spec.ts | 265 ++++++++++++++ .../pairing/pairingFlowV2Android.spec.ts | 265 ++++++++++++++ .../tests/pairing/pairingFlowiOS.spec.ts | 6 - .../fxa-settings/src/components/App/index.tsx | 31 +- .../fxa-settings/src/lib/channels/firefox.ts | 128 ++++--- .../src/lib/channels/pairing-channel.ts | 1 + .../src/lib/channels/pairing-flow.ts | 193 +++++++++++ .../pairing-authority-integration.test.ts | 3 + .../pairing-authority-integration.ts | 240 ++++++++++++- .../src/pages/ConnectAnotherDevice/index.tsx | 3 + .../src/pages/Pair/Index/index.test.tsx | 118 +++++-- .../src/pages/Pair/Index/index.tsx | 49 ++- .../src/pages/Pair/Supp/index.tsx | 10 + .../Authority/ApproveSignIn/container.tsx | 102 ++++++ .../Pair2/Authority/ApproveSignIn/index.tsx | 1 + .../Authority/ContinueOnMobile/container.tsx | 39 +++ .../Pair2/Authority/ScanQR/container.test.tsx | 28 +- .../Pair2/Authority/ScanQR/container.tsx | 35 +- .../pages/Pair2/Authority/ScanQR/index.tsx | 4 +- .../Authority/TimeoutAndCancel/container.tsx | 38 ++ .../Supplicant/ApproveSignIn/container.tsx | 195 +++++++++++ .../Pair2/Supplicant/ApproveSignIn/index.tsx | 72 ++-- .../ConnectThisDevice/container.tsx | 89 +++++ .../Supplicant/ConnectThisDevice/index.tsx | 77 ++-- .../Pair2/Supplicant/SyncSuccess/index.tsx | 67 ++-- packages/fxa-settings/src/react-app-env.d.ts | 4 + 37 files changed, 2601 insertions(+), 356 deletions(-) create mode 100644 packages/functional-tests/lib/firefox-binary.ts create mode 100644 packages/functional-tests/lib/pairing-supplicant-harness.ts create mode 100644 packages/functional-tests/tests/pairing/pairingFlowV2.spec.ts create mode 100644 packages/functional-tests/tests/pairing/pairingFlowV2Android.spec.ts create mode 100644 packages/fxa-settings/src/lib/channels/pairing-flow.ts create mode 100644 packages/fxa-settings/src/pages/Pair2/Authority/ApproveSignIn/container.tsx create mode 100644 packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx create mode 100644 packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx create mode 100644 packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx create mode 100644 packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx diff --git a/packages/functional-tests/lib/android-supplicant.ts b/packages/functional-tests/lib/android-supplicant.ts index 085e504ad05..a94dbf7e124 100644 --- a/packages/functional-tests/lib/android-supplicant.ts +++ b/packages/functional-tests/lib/android-supplicant.ts @@ -68,6 +68,8 @@ const KEY_PAIRING_URL = 'pref_key_sync_debug_pairing_url'; interface UiNode { text: string; desc: string; + /** Empty for GeckoView web content; set for native Android widgets. */ + resourceId: string; cx: number; cy: number; } @@ -163,6 +165,20 @@ export class AndroidSupplicant { this.swipe(width / 2, height * 0.5, width / 2, height * 0.48, 100); } + /** + * Wipe all app data so pairing starts from a signed-out, first-run state. + * + * `forceStop` only kills the process; the FxA account in `shared_prefs` + * survives it, and a Fenix that already has an account takes a re-auth web + * flow instead of the pairing flow. Call this before `ensureReady`, which + * re-grants the permissions and rewrites the server override that + * `pm clear` removes. + */ + resetToColdState(): void { + this.adb(['shell', 'pm', 'clear', this.pkg]); + debug('Cleared app data; supplicant is in a cold, signed-out state'); + } + /** * Verify a device is attached and the Fenix debug build is installed. Grants * camera/notification permissions and points the FxA server override at the @@ -246,6 +262,43 @@ export class AndroidSupplicant { return url; } + /** + * Open the pairing URL as a normal tab, the way a native QR scan does on a + * v2 device: `VIEW ` with no OAuth params in the URL. The page then + * asks the browser for them over the web channel (`fxaccounts:pair_oauth_start`). + * + * The Sync Debug hook takes the other branch - app-services runs OAuth-start + * itself and hands the page a URL that already carries the params - so this + * is the path that exercises the v2 web-channel command. The web channel is + * live in normal tabs: `FxaWebChannelIntegration` is installed by + * `BaseBrowserFragment`, the parent of both the browser and custom-tab + * fragments. + * + * Returns the pairing URL as seen in logcat once the tab has loaded it. + */ + async openPairingUrl( + pairingUrl: string, + timeoutMs = 60_000 + ): Promise { + this.forceStop(); + this.adb(['logcat', '-c']); + // `adb shell` re-tokenizes argv and the URL carries `#` and `&`, so the + // device command goes over as one single-quoted string. + this.adb([ + 'shell', + `am start -a android.intent.action.VIEW -d '${pairingUrl}' ${this.pkg}`, + ]); + await this.waitForProcess(30_000); + await sleep(6_000); // first-run init before dialogs are dismissable + this.dismissBlockingDialogs(); + + const line = await this.waitForLog(/url=[^,]*\/pair(\?|#|,|\s)/, timeoutMs); + debug( + `Supplicant opened the pairing URL in a normal tab: ${line.slice(0, 120)}` + ); + return line; + } + /** * Wait until the supplicant custom tab has loaded its pairing page and is * connecting to the channel. Distinguishes the real pairing flow (/pair/supp @@ -272,12 +325,15 @@ export class AndroidSupplicant { * authority approves the new device, then the supplicant confirms here. * (uiautomator can read GeckoView web content.) */ - async confirmPairing(timeoutMs = 45_000): Promise { + async confirmPairing( + timeoutMs = 45_000, + // v1 uses "Confirm"/"Confirm pairing"; the v2 supplicant card uses "Connect". + re = /^Confirm pairing$|^Confirm$/i + ): Promise { // The confirm button lives in GeckoView web content, read via uiautomator's // accessibility tree — which GeckoView populates lazily, so a dump can miss // it transiently. Poll, and periodically nudge the page with a tiny scroll // to force the a11y tree to repopulate. - const re = /^Confirm pairing$|^Confirm$/i; const deadline = Date.now() + timeoutMs; let attempt = 0; while (Date.now() < deadline) { @@ -418,10 +474,15 @@ export class AndroidSupplicant { ]; for (let pass = 0; pass < 3; pass++) { const nodes = this.dumpUi(); - const hit = nodes.find((n) => dismissers.some((re) => re.test(n.text))); + // Native widgets only. uiautomator also reads GeckoView web content, and + // the v2 supplicant card has its own Cancel button - tapping that would + // close the pairing channel and abort the flow on both sides. + const hit = nodes.find( + (n) => n.resourceId && dismissers.some((re) => re.test(n.text)) + ); if (!hit) return; this.tap(hit.cx, hit.cy); - debug(`Dismissed dialog button: ${hit.text}`); + debug(`Dismissed dialog button: ${hit.text} (${hit.resourceId})`); } } @@ -471,12 +532,13 @@ export class AndroidSupplicant { const tag = m[0]; const text = attr(tag, 'text'); const desc = attr(tag, 'content-desc'); + const resourceId = attr(tag, 'resource-id'); const bounds = attr(tag, 'bounds'); const b = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); if (!b) continue; const cx = Math.floor((Number(b[1]) + Number(b[3])) / 2); const cy = Math.floor((Number(b[2]) + Number(b[4])) / 2); - nodes.push({ text, desc, cx, cy }); + nodes.push({ text, desc, resourceId, cx, cy }); } return nodes; } diff --git a/packages/functional-tests/lib/firefox-binary.ts b/packages/functional-tests/lib/firefox-binary.ts new file mode 100644 index 00000000000..72e4b91d4fc --- /dev/null +++ b/packages/functional-tests/lib/firefox-binary.ts @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Resolves the desktop Firefox that pairing tests drive over Marionette. + * + * The v2 chrome commands ship in Nightly, so the v2 specs need it. Playwright + * launches only its own build and never comes through here. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { firefox } from 'playwright'; + +/** Default install locations for Firefox Nightly, by platform. */ +const NIGHTLY_PATHS: Record = { + darwin: [ + '/Applications/Firefox Nightly.app/Contents/MacOS/firefox', + path.join( + os.homedir(), + 'Applications/Firefox Nightly.app/Contents/MacOS/firefox' + ), + ], + linux: [ + '/usr/bin/firefox-nightly', + '/usr/local/bin/firefox-nightly', + '/opt/firefox-nightly/firefox', + path.join(os.homedir(), 'firefox-nightly/firefox'), + ], + win32: [ + 'C:\\Program Files\\Firefox Nightly\\firefox.exe', + 'C:\\Program Files (x86)\\Firefox Nightly\\firefox.exe', + ], +}; + +function findFirefoxNightly(): string | undefined { + return (NIGHTLY_PATHS[process.platform] || []).find((p) => fs.existsSync(p)); +} + +/** A v2-capable Firefox, or undefined. FIREFOX_BINARY wins, so a local build works. */ +export function findV2AuthorityBinary(): string | undefined { + return process.env.FIREFOX_BINARY || findFirefoxNightly(); +} + +/** Binary for the `marionetteAuthority` fixture. The v1 specs run on either. */ +export function resolveAuthorityBinary(): string { + return findV2AuthorityBinary() || firefox.executablePath(); +} diff --git a/packages/functional-tests/lib/fixtures/pairing.ts b/packages/functional-tests/lib/fixtures/pairing.ts index 6a4ba8d93c2..c0ebcf26a30 100644 --- a/packages/functional-tests/lib/fixtures/pairing.ts +++ b/packages/functional-tests/lib/fixtures/pairing.ts @@ -10,7 +10,7 @@ * (desktop) side of the pairing flow. */ -import { firefox } from 'playwright'; +import { resolveAuthorityBinary } from '../firefox-binary'; import { MarionetteFirefox } from '../marionette-firefox'; import { test as standardTest, TestOptions } from './standard'; @@ -20,10 +20,7 @@ export type PairingTestOptions = TestOptions & { export const test = standardTest.extend({ marionetteAuthority: async ({ target }, use, testInfo) => { - // Use Playwright's bundled Firefox by default — it's already downloaded - // in CI and locally. Override with FIREFOX_BINARY env if needed. - const firefoxBinary = - process.env.FIREFOX_BINARY || firefox.executablePath(); + const firefoxBinary = resolveAuthorityBinary(); const channelServerUri = process.env.CHANNEL_SERVER_URI || (await fetchChannelServerUri(target.contentServerUrl)); diff --git a/packages/functional-tests/lib/marionette-firefox.ts b/packages/functional-tests/lib/marionette-firefox.ts index 474ad066c25..31b7472e636 100644 --- a/packages/functional-tests/lib/marionette-firefox.ts +++ b/packages/functional-tests/lib/marionette-firefox.ts @@ -98,10 +98,18 @@ export class MarionetteFirefox { args.push('--headless'); } + // Diagnostic: set MARIONETTE_LOG to capture the authority's stdout (content + // console.log, via devtools.console.stdout.content) to a file. + const logPath = process.env.MARIONETTE_LOG; const proc = spawn(firefoxBinary, args, { - stdio: 'ignore', + stdio: logPath ? ['ignore', 'pipe', 'pipe'] : 'ignore', detached: false, }); + if (logPath && proc.stdout && proc.stderr) { + const ws = fs.createWriteStream(logPath, { flags: 'a' }); + proc.stdout.pipe(ws); + proc.stderr.pipe(ws); + } try { // Wait for Marionette port to become available @@ -216,10 +224,19 @@ function buildPrefs( // Auto-handle unexpected dialogs (dismiss by default) 'marionette.prefs.recommended': true, - // Pairing + // Pairing. version=2 lets chrome accept the v2 pair_oauth_start / + // pair_oauth_finish web-channel commands (gated by _ensurePairingEnabled). 'identity.fxaccounts.pairing.enabled': true, + 'identity.fxaccounts.pairing.version': 2, 'identity.fxaccounts.remote.pairing.uri': channelServerUri, + // Diagnostic: route content console.log to the process stdout so the + // authority's [pair2] logs can be captured (see MARIONETTE_LOG). + 'devtools.console.stdout.content': true, + // Diagnostic: FxAccounts chrome logs (pairing + oauth) to dump/stdout. + 'identity.fxaccounts.loglevel': 'Trace', + 'identity.fxaccounts.log.appender.dump': 'Trace', + // Browser chrome — suppress UI that interferes with automation 'datareporting.policy.dataSubmissionEnabled': false, 'toolkit.telemetry.reportingpolicy.firstRun': false, diff --git a/packages/functional-tests/lib/marionette.ts b/packages/functional-tests/lib/marionette.ts index 26dcea7ed84..ac2a45dcd5c 100644 --- a/packages/functional-tests/lib/marionette.ts +++ b/packages/functional-tests/lib/marionette.ts @@ -366,12 +366,14 @@ export class MarionetteClient { } /** - * Take a screenshot of the current page. - * Returns the screenshot as a base64-encoded PNG string. + * Screenshot a single element, returned as a base64-encoded PNG. + * + * Scoped to the element rather than the viewport so the caller gets just + * that node's pixels, which is what an image decoder needs. */ - async takeScreenshot(): Promise { + async screenshotElement(elementId: string): Promise { const result = await this.sendCommandWithRetry('WebDriver:TakeScreenshot', { - full: true, + id: elementId, }); return this.extractValue(result) as string; } diff --git a/packages/functional-tests/lib/pairing-constants.ts b/packages/functional-tests/lib/pairing-constants.ts index ad40b2ee503..656ba3ce4eb 100644 --- a/packages/functional-tests/lib/pairing-constants.ts +++ b/packages/functional-tests/lib/pairing-constants.ts @@ -23,6 +23,35 @@ export const TIMEOUTS = { POLL_INTERVAL_MAX: 2_000, } as const; +// ---- v2 pairing (FXA-12855) ---- +// The v2 flow moves the authority into FxA web content. These constants cover +// the v2 routes and QR URL format; v1 constants above are unchanged. +// +// Route source of truth is App/index.tsx (code takes precedence over the ticket +// prose, which uses "/pair2/..." and "approve_sign_in"). NOTE: the content-server +// route list currently misspells the timeout page as "timeout_and_cacnel", so the +// two timeout routes 404 until that is fixed (tracked with the negative-path work). +export const PAIR_V2_ROUTES = { + AUTHORITY_SCAN_QR: '/pair/authority/scan_qr', + AUTHORITY_APPROVE_SIGNIN: '/pair/authority/approve_signin', + AUTHORITY_CONTINUE_ON_MOBILE: '/pair/authority/continue_on_mobile', + AUTHORITY_SYNC_SUCCESS: '/pair/authority/sync_success', + AUTHORITY_TIMEOUT_AND_CANCEL: '/pair/authority/timeout_and_cancel', + SUPPLICANT_APPROVE_SIGNIN: '/pair/supplicant/approve_signin', + SUPPLICANT_CONNECT_THIS_DEVICE: '/pair/supplicant/connect_this_device', + SUPPLICANT_READY_TO_SCAN: '/pair/supplicant/ready_to_scan', + SUPPLICANT_SYNC_SUCCESS: '/pair/supplicant/sync_success', + SUPPLICANT_TIMEOUT_AND_CANCEL: '/pair/supplicant/timeout_and_cancel', +} as const; + +// v2 QR URL format, confirmed by FXA-13868 AC: +// https:///pair#channel_id=&channel_key=&v=2 +// i.e. the v1 fragment plus the v2 marker. +export const PAIR_V2_URL_MARKER = 'v=2'; + +/** Browser pref that decides the pairing version Firefox reports and accepts. */ +export const PAIRING_VERSION_PREF = 'identity.fxaccounts.pairing.version'; + export const SELECTORS = { EMAIL_INPUT: [ 'input[type="email"]', diff --git a/packages/functional-tests/lib/pairing-helpers.ts b/packages/functional-tests/lib/pairing-helpers.ts index 07ed9edaa22..3f0c3912c4a 100644 --- a/packages/functional-tests/lib/pairing-helpers.ts +++ b/packages/functional-tests/lib/pairing-helpers.ts @@ -13,15 +13,20 @@ * characters (e.g. em dashes), causing parse failures. */ +import { writeFileSync } from 'fs'; import crypto from 'crypto'; -import { Browser, expect, Page } from '@playwright/test'; +import jsQR from 'jsqr'; +import UPNG from 'upng-js'; +import { Browser, expect, Page, TestInfo } from '@playwright/test'; import { ConfigPage } from '../pages/config'; import { BaseTarget } from './targets/base'; import { MarionetteClient } from './marionette'; import { + PAIR_V2_ROUTES, PAIRING_CLIENT_ID, PAIRING_REDIRECT_URI, PAIRING_SCOPE, + PAIRING_VERSION_PREF, SELECTORS, TIMEOUTS, } from './pairing-constants'; @@ -74,7 +79,8 @@ export async function isPairRoutesReact( async function pollUntil( check: () => Promise, timeoutMs: number, - label: string + // A thunk lets the caller include state it only knows after the last poll. + label: string | (() => string) ): Promise { const start = Date.now(); let interval: number = TIMEOUTS.POLL_INTERVAL; @@ -92,7 +98,8 @@ async function pollUntil( } const suffix = lastError ? ` Last error: ${lastError.message}` : ''; - throw new Error(`${label} after ${timeoutMs}ms.${suffix}`); + const text = typeof label === 'function' ? label() : label; + throw new Error(`${text} after ${timeoutMs}ms.${suffix}`); } /** @@ -103,13 +110,15 @@ export async function waitForUrlContaining( substring: string, timeoutMs: number = TIMEOUTS.AUTHORITY_COMPLETE ): Promise { + let lastUrl = ''; return pollUntil( async () => { - const url = await client.getUrl(); - return url.includes(substring) ? url : undefined; + lastUrl = await client.getUrl(); + return lastUrl.includes(substring) ? lastUrl : undefined; }, timeoutMs, - `URL did not contain "${substring}"` + // The URL it stalled on is the first thing you need when this fails. + () => `URL did not contain "${substring}" (last seen: ${lastUrl})` ); } @@ -151,10 +160,14 @@ export async function waitForSignedInState( /** * Sign in to FxA Sync via the content server web UI using Marionette. * - * Uses Firefox's internal beginOAuthFlow() to generate PKCE + keys_jwk - * and register the OAuth flow, then signs in through the content server UI. - * After sign-in, Firefox processes the fxaccounts:oauth_login WebChannel - * message and completes the key exchange automatically. + * Drives /pair, which asks Firefox for the OAuth params over the web channel + * and redirects to the sign-in form carrying them, then fills that form. After + * sign-in, Firefox processes the fxaccounts:oauth_login WebChannel message and + * completes the key exchange automatically. + * + * Every step runs in content, so Marionette is only the remoting protocol here. + * Playwright cannot replace it while the authority must be a custom Firefox + * build: Playwright drives its own Juggler-patched builds only. */ export async function signInAuthorityViaMarionette( client: MarionetteClient, @@ -164,61 +177,13 @@ export async function signInAuthorityViaMarionette( totpSecret?: string, useReact = false ): Promise { - // Use Firefox's internal beginOAuthFlow() to generate PKCE + keys_jwk - // and register the OAuth flow so Firefox can complete the key exchange - // when the content server sends fxaccounts:oauth_login via WebChannel. - // Requires Playwright's bundled Firefox (125+). - await client.setContext('chrome'); - const oauthResult = await client.executeAsyncScript( - ` - const [resolve] = arguments; - (async () => { - try { - const { getFxAccountsSingleton } = ChromeUtils.importESModule( - "resource://gre/modules/FxAccounts.sys.mjs" - ); - const fxAccounts = getFxAccountsSingleton(); - const scopes = ["profile", "https://identity.mozilla.com/apps/oldsync"]; - const result = await fxAccounts._internal.beginOAuthFlow(scopes); - resolve(JSON.stringify({ success: true, ...result })); - } catch (e) { - resolve(JSON.stringify({ - success: false, - error: e.message, - stack: (e.stack || "").substring(0, 500), - })); - } - })(); - `, - { sandbox: 'system', timeoutMs: TIMEOUTS.ASYNC_SCRIPT } - ); - - if (typeof oauthResult !== 'string') { - throw new Error( - `Expected string from beginOAuthFlow, got ${typeof oauthResult}` - ); - } - const oauthData = JSON.parse(oauthResult); - if (!oauthData.success) { - throw new Error(`beginOAuthFlow failed: ${oauthData.error}`); - } - - // Build the sign-in URL from the OAuth params Firefox generated - const params = new URLSearchParams({ - context: 'oauth_webchannel_v1', - entrypoint: 'fxa_discoverability_native', - action: 'email', - service: 'sync', - client_id: oauthData.client_id, - scope: oauthData.scope || PAIRING_SCOPE, - state: oauthData.state, - code_challenge: oauthData.code_challenge, - code_challenge_method: oauthData.code_challenge_method || 'S256', - keys_jwk: oauthData.keys_jwk, - access_type: 'offline', - response_type: 'code', - }); - const signinUrl = `${contentServerUrl}/?${params}${useReact ? '&showReactApp=true' : ''}`; + // Navigating to /pair is enough to start a Sync sign-in: the page sends + // fxaccounts:oauth_flow_begin over the web channel, Firefox answers with the + // OAuth params, and the page redirects to the sign-in form already carrying + // them. Building that URL here from a chrome-context beginOAuthFlow() call + // duplicated what the page does, and was the only part of this flow that + // needed chrome privileges. + const signinUrl = `${contentServerUrl}/pair${useReact ? '?showReactApp=true' : ''}`; try { await client.setContext('content'); @@ -472,6 +437,123 @@ export function extractChannelId(pairUrl: string): string { return channelId; } +/** + * Build the supplicant navigation URL for the v2 pairing flow. + * + * v2 differs from v1: the supplicant's OAuth params (state, scope, + * code_challenge, keys_jwk) are NOT carried in the URL. They are produced by the + * `fxaccounts:pair_oauth_start` web-channel command and sent to the authority + * over the pairing channel as `pair:supp:request`. So the only thing the URL + * carries is the channel fragment, exactly what a native camera scan of the v2 + * QR opens: `/pair#channel_id=&channel_key=&v=2`. FxA forwards a v=2 + * URL to `/pair/supplicant/approve_signin` (see FXA-13865). + * + * This validates the fragment and rebases it on the test's content server, so a + * QR minted against one origin can be opened against localhost. + */ +export function buildSupplicantUrlV2( + contentServerUrl: string, + pairUrl: string +): string { + const fragment = pairUrl.split('#')[1]; + if (!fragment) { + throw new Error(`v2 pair URL has no fragment: ${pairUrl}`); + } + const params = new URLSearchParams(fragment); + const channelId = params.get('channel_id'); + const channelKey = params.get('channel_key'); + if (!channelId || !channelKey) { + throw new Error( + `v2 pair URL fragment missing channel_id or channel_key: ${fragment}` + ); + } + if (params.get('v') !== '2') { + throw new Error(`v2 pair URL fragment missing v=2 marker: ${fragment}`); + } + + const hashParams = new URLSearchParams({ + channel_id: channelId, + channel_key: channelKey, + v: '2', + }); + return `${contentServerUrl}/pair#${hashParams}`; +} + +/** + * Drive a signed-in Marionette authority through the v2 entrypoint to the + * scan_qr page and return the encoded pairing URL from the rendered QR. + * + * v2 has no chrome-side pairing flow (contrast v1 `startPairingFlow`, which calls + * `FxAccountsPairingFlow.start()`). The authority mints the channel in web + * content on `/pair/authority/scan_qr` (FXA-13868) and encodes it into the QR. + * The test screenshots that QR and decodes it, so it reads the same value a + * user's phone would, not one the page reports about itself. + * + * CONTRACT: scan_qr must render the code inside `data-testid="pairing-qr"`, and + * that element must include the quiet zone around the code. Decoding fails + * without the quiet zone. + * + * Requires an eligible entrypoint (Sync context + a pairing entrypoint), so the + * caller passes the same query string the real Firefox menu entrypoint sends. + */ +export async function startPairingFlowV2( + client: MarionetteClient, + contentServerUrl: string, + eligibleEntrypointQs: string +): Promise { + await client.setContext('content'); + await client.navigate( + `${contentServerUrl}/connect_another_device?${eligibleEntrypointQs}&v=2` + ); + await waitForUrlContaining(client, '/pair/authority/scan_qr'); + + // Decode the QR the page actually renders, rather than reading the value + // out of a test-only attribute. A side channel would still agree with the + // page when the rendered code encodes something else, which is the one + // thing this card can get wrong. + // + // Shoot the wrapper, not the svg inside it: the wrapper carries the quiet + // zone the QR spec requires, and jsQR cannot find the finder patterns + // without it. + return pollUntil( + async () => { + const el = await client + .findElement('css selector', '[data-testid="pairing-qr"]') + .catch(() => undefined); + if (!el) return undefined; + const png = Buffer.from(await client.screenshotElement(el), 'base64'); + const img = UPNG.decode(png); + const decoded = jsQR( + new Uint8ClampedArray(UPNG.toRGBA8(img)[0]), + img.width, + img.height + ); + return decoded?.data.includes('channel_id=') ? decoded.data : undefined; + }, + TIMEOUTS.ASYNC_SCRIPT, + 'scan_qr did not render a decodable v2 pairing QR' + ); +} + +/** + * Extract channel_id from a v2 pairing QR URL, asserting the v=2 marker is + * present. Use this over {@link extractChannelId} when the test must prove the + * QR is a v2 QR and not a v1 one. + */ +export function extractChannelIdV2(pairUrl: string): string { + const hash = pairUrl.split('#')[1]; + if (!hash) throw new Error('No fragment in v2 pair URL'); + + const params = new URLSearchParams(hash); + if (params.get('v') !== '2') { + throw new Error(`v2 pair URL missing v=2 marker: ${hash}`); + } + const channelId = params.get('channel_id'); + if (!channelId) throw new Error('No channel_id in v2 pair URL'); + + return channelId; +} + /** * Build the authority OAuth URL that navigates the authority to the * pairing approval page. @@ -659,33 +741,6 @@ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** - * Take a screenshot of the authority browser via Marionette and write it - * to the artifacts directory for debugging. Only writes when PAIRING_DEBUG=1. - */ -export async function screenshotAuthority( - client: MarionetteClient, - prefix: string, - step: string -): Promise { - if (!process.env.PAIRING_DEBUG) { - return; - } - try { - const fs = await import('fs'); - const path = await import('path'); - const base64 = await client.takeScreenshot(); - const dir = path.join(process.cwd(), '..', '..', 'artifacts', 'functional'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, `${prefix}-${step}.png`), - Buffer.from(base64, 'base64') - ); - } catch { - // best-effort - } -} - /** * Verify the /pair index choice screen renders correctly after sign-in. * @@ -819,3 +874,92 @@ export async function completeSupplicantApproval( ); expect(finalAuthUrl).not.toContain('pair/failure'); } + +/** + * Set the pairing version pref in the running browser. + * + * `FxAccountsWebChannel` reads this through `defineLazyPreferenceGetter`, which + * observes changes, so flipping it at runtime takes effect on the next command + * and there is no need for a second browser with different prefs. + */ +export async function setPairingVersion( + client: MarionetteClient, + version: number +): Promise { + await client.setContext('chrome'); + await client.executeScript( + `Services.prefs.setIntPref(arguments[0], arguments[1]);`, + { sandbox: 'system', args: [PAIRING_VERSION_PREF, version] } + ); +} + +/** + * Read `config.pairing.version` as the content server serves it. + * + * The value comes from convict (`PAIRING_VERSION`) and is baked into the page + * at server boot, so a test cannot change it. Tests that need version 2 read + * it to decide whether to run. Uses a real page for the same WAF reason as + * `isPairRoutesReact`; defaults to 1 when the config omits the value. + */ +export async function getServedPairingVersion( + browser: Browser, + target: BaseTarget +): Promise { + const extraHTTPHeaders: Record = {}; + target.ciHeader?.forEach((value, key) => { + extraHTTPHeaders[key] = value; + }); + const context = await browser.newContext({ extraHTTPHeaders }); + const page = await context.newPage(); + try { + const config = await new ConfigPage(page, target).getConfig(); + return config?.pairing?.version ?? 1; + } finally { + await context.close(); + } +} + +/** + * Attach the authority's URL, a full-page screenshot and the page's own + * `[pair2]` console trace when a test fails. + * + * The authority runs in a Marionette-driven Firefox, so Playwright's own + * failure artifacts capture nothing from it — without this a failure only says + * which URL it did not reach, not what the page was doing. + */ +export async function attachAuthorityDiagnostics( + client: MarionetteClient, + testInfo: TestInfo +): Promise { + if (testInfo.status === testInfo.expectedStatus) { + return; + } + + const save = async (name: string, body: string | Buffer, type: string) => { + const file = testInfo.outputPath(name); + writeFileSync(file, body); + await testInfo.attach(name, { path: file, contentType: type }); + }; + + try { + await client.setContext('content'); + await save('authority-url.txt', await client.getUrl(), 'text/plain'); + + // The containers log their channel traffic with a `[pair2]` prefix, which + // is what says whether pair:supp:request ever arrived. `wrappedJSObject` + // gets past Marionette's Xray vision, which hides page-defined properties. + const trace = await client + .executeScript( + 'return ((window.wrappedJSObject && window.wrappedJSObject.__pair2Log) || []).join("\\n");' + ) + .then((r) => (r ? String(r) : '(buffer empty)')) + .catch((err) => `(could not read buffer: ${err?.message ?? err})`); + await save('authority-pair2-log.txt', trace, 'text/plain'); + + const body = await client.findElement('css selector', 'body'); + const png = Buffer.from(await client.screenshotElement(body), 'base64'); + await save('authority-page.png', png, 'image/png'); + } catch { + // Diagnostics must never mask the real failure. + } +} diff --git a/packages/functional-tests/lib/pairing-supplicant-harness.ts b/packages/functional-tests/lib/pairing-supplicant-harness.ts new file mode 100644 index 00000000000..ec0da4e9f99 --- /dev/null +++ b/packages/functional-tests/lib/pairing-supplicant-harness.ts @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Supplicant harness for the v2 pairing E2E (FXA-13870). + * + * In production the supplicant is Firefox mobile: its chrome runs + * `pair_oauth_start` (real PKCE + ephemeral ECDH) and later decrypts the + * `keys_jwe`. The test supplicant is a Playwright page with no chrome, so this + * harness plays that chrome role with REAL crypto: + * - generates the PKCE pair and an ECDH P-256 keypair, + * - stubs the page's `pair_oauth_start` / `fxa_status` web-channel responses + * with those values and captures the `oauth_login` the container emits, + * - redeems the resulting code at the auth server and decrypts the returned + * `keys_jwe` with the private key, proving real Sync scoped keys arrived. + * + * The authority side stays a real custom Firefox running the real + * `pair_oauth_finish`, so the crypto is genuinely exercised end to end. + */ + +import crypto from 'crypto'; +import { compactDecrypt, importJWK } from 'jose'; +import type { Page } from '@playwright/test'; +import { PAIRING_CLIENT_ID, PAIRING_SCOPE } from './pairing-constants'; + +export type SupplicantCrypto = { + state: string; + scope: string; + clientId: string; + codeVerifier: string; + codeChallenge: string; + keysJwk: string; // base64url(JSON(public JWK)), as chrome expects + privateJwk: crypto.JsonWebKey; +}; + +/** Generate the real PKCE + ECDH material a supplicant's chrome would mint. */ +export function generateSupplicantCrypto(): SupplicantCrypto { + const codeVerifier = crypto.randomBytes(32).toString('base64url'); + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'P-256', + }); + const publicJwk = publicKey.export({ format: 'jwk' }); + const privateJwk = privateKey.export({ format: 'jwk' }); + const keysJwk = Buffer.from(JSON.stringify(publicJwk)).toString('base64url'); + + return { + state: crypto.randomUUID().replace(/-/g, ''), + scope: PAIRING_SCOPE, + clientId: PAIRING_CLIENT_ID, + codeVerifier, + codeChallenge, + keysJwk, + privateJwk, + }; +} + +/** + * Install a page init script that answers the supplicant container's web-channel + * commands and records the final `oauth_login`. Must run before navigation. + */ +export async function installSupplicantWebChannelStub( + page: Page, + c: SupplicantCrypto +): Promise { + await page.addInitScript( + ({ state, scope, codeChallenge, keysJwk, clientId }) => { + const CHANNEL_ID = 'account_updates'; + window.addEventListener('WebChannelMessageToChrome', (event: any) => { + const detail = + typeof event.detail === 'string' + ? JSON.parse(event.detail) + : event.detail; + const message = detail?.message; + if (!message) return; + const { command, messageId } = message; + + const reply = (data: unknown) => + window.dispatchEvent( + new CustomEvent('WebChannelMessageToContent', { + detail: { id: CHANNEL_ID, message: { command, messageId, data } }, + }) + ); + + if (command === 'fxaccounts:fxa_status') { + reply({ + capabilities: { engines: [], pairing: true, pairingVersion: 2 }, + clientId, + signedInUser: null, + }); + } else if (command === 'fxaccounts:pair_oauth_start') { + reply({ + state, + scope, + code_challenge: codeChallenge, + keys_jwk: keysJwk, + }); + } else if (command === 'fxaccounts:oauth_login') { + // Capture for the token exchange; nothing to reply. + (window as any).__pairingOAuthLogin = message.data; + } + }); + }, + { + state: c.state, + scope: c.scope, + codeChallenge: c.codeChallenge, + keysJwk: c.keysJwk, + clientId: c.clientId, + } + ); +} + +/** Read the `{code,state,...}` the supplicant container passed to oauth_login. */ +export async function readCapturedOAuthLogin( + page: Page +): Promise<{ code: string; state: string } | null> { + return page.evaluate(() => (window as any).__pairingOAuthLogin ?? null); +} + +/** + * Redeem the authorization code at the auth server and decrypt the returned + * keys_jwe, returning the scoped keys the supplicant would receive. + */ +export async function redeemAndDecrypt( + authServerUrl: string, + c: SupplicantCrypto, + code: string, + wafToken?: string +): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (wafToken) headers['fxa-ci'] = wafToken; + + // authServerUrl is the origin (e.g. http://localhost:9000); the API is under /v1. + const resp = await fetch(`${authServerUrl}/v1/oauth/token`, { + method: 'POST', + headers, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + code_verifier: c.codeVerifier, + client_id: c.clientId, + }), + }); + if (!resp.ok) { + throw new Error( + `token exchange failed: ${resp.status} ${await resp.text().catch(() => '')}` + ); + } + const body = (await resp.json()) as { keys_jwe?: string }; + if (!body.keys_jwe) { + throw new Error('token response had no keys_jwe'); + } + + const key = await importJWK(c.privateJwk as any, 'ECDH-ES'); + const { plaintext } = await compactDecrypt(body.keys_jwe, key); + return JSON.parse(new TextDecoder().decode(plaintext)); +} diff --git a/packages/functional-tests/tests/pairing/CLAUDE.md b/packages/functional-tests/tests/pairing/CLAUDE.md index 5b9942d8280..c58ff9ffd85 100644 --- a/packages/functional-tests/tests/pairing/CLAUDE.md +++ b/packages/functional-tests/tests/pairing/CLAUDE.md @@ -10,7 +10,7 @@ the same ground framed for developers doing manual, by-hand device testing.) Pairing connects a signed-in desktop Firefox (the **authority**) to a mobile Firefox (the **supplicant**) over a short-lived channel. The specs drive both sides: a Marionette-controlled desktop Firefox as the authority, and a real -mobile app as the supplicant. +mobile app or a Playwright page as the supplicant. - `pairingFlowAndroid.spec.ts` + `lib/android-supplicant.ts` (adb-driven Fenix). - `pairingFlowiOS.spec.ts` (Simulator + XCUITest-driven Firefox iOS). @@ -31,9 +31,12 @@ mobile app's FxA-server override. mza` for a faster core subset). Needs Docker; `yarn start infrastructure` if it isn't up. Provides content-server :3030, auth-server :9000; Fenix/iOS client ids are registered in `packages/fxa-auth-server/config/dev.json`. -- **Playwright's Firefox** (the authority): pulled by `yarn install` in - `packages/functional-tests`; if missing, `npx playwright install firefox`. - Override the binary with `FIREFOX_BINARY`. +- **The authority browser** (Marionette): the v1 specs run on Playwright's + bundled Firefox, pulled by `yarn install`. The **v2** specs need **Firefox + Nightly**, because the v2 chrome commands ship there; they skip without it. + Install it from https://www.mozilla.org/firefox/channel/desktop/#nightly. + `lib/firefox-binary.ts` finds it at the default install path and falls back to + the bundled build. Override with `FIREFOX_BINARY`. - **Android:** a booted emulator/device (`adb devices` lists it) with a Fenix debug build that has the Sync Debug pairing hook (`org.mozilla.fenix.debug`; hook landed in Bug 2053454, so a current debug build already has it). Build + diff --git a/packages/functional-tests/tests/pairing/pairChoice.spec.ts b/packages/functional-tests/tests/pairing/pairChoice.spec.ts index 25fee4d9c9a..58419cae4da 100644 --- a/packages/functional-tests/tests/pairing/pairChoice.spec.ts +++ b/packages/functional-tests/tests/pairing/pairChoice.spec.ts @@ -3,7 +3,26 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { FirefoxCommand } from '../../lib/channels'; -import { test, expect } from '../../lib/fixtures/standard'; +import { Page, expect } from '../../lib/fixtures/standard'; +import { test } from '../../lib/fixtures/pairing'; +import { findV2AuthorityBinary } from '../../lib/firefox-binary'; +import { MarionetteClient } from '../../lib/marionette'; +import { PAIR_V2_ROUTES, SELECTORS } from '../../lib/pairing-constants'; +import { + findElementBySelectors, + getServedPairingVersion, + setPairingVersion, + signInAuthorityViaMarionette, + waitForUrlContaining, +} from '../../lib/pairing-helpers'; +import { BaseTarget, Credentials } from '../../lib/targets/base'; +import { SettingsPage } from '../../pages/settings'; +import { SigninPage } from '../../pages/signin'; + +// What the Firefox app-menu entry sends; /connect_another_device requires an +// eligible entrypoint to consider pairing at all. +const ELIGIBLE_ENTRYPOINT_QS = + 'context=oauth_webchannel_v1&entrypoint=fxa_app_menu'; test.describe('severity-2 #smoke', () => { test.describe('Pair entry flow', () => { @@ -67,5 +86,174 @@ test.describe('severity-2 #smoke', () => { await settings.checkWebChannelMessage(FirefoxCommand.FxAStatus); await settings.checkWebChannelMessage(FirefoxCommand.OAuthFlowBegin); }); + + // v2 removes the supplicant choice screen: a native-camera scan opens + // /pair#...&v=2, which forwards straight into the single-QR v2 supplicant + // flow instead of the has-mobile / needs-mobile choice (FXA-13865). No + // sign-in is needed on the supplicant, so this is deterministic. + test('v2 supplicant URL bypasses the choice screen', async ({ + target, + syncOAuthBrowserPages: { page }, + }) => { + await page.goto( + `${target.contentServerUrl}/pair#channel_id=testchannelid&channel_key=testchannelkey&v=2` + ); + + await page.waitForURL(/\/pair\/supplicant\/approve_signin/); + // The v1 choice screen must not appear in the v2 flow. + await expect(page.getByTestId('has-mobile')).toBeHidden(); + }); + + // Firefox mobile does not open /pair; app-services opens /pair/supp with the + // OAuth params in the query and the channel + v=2 marker in the fragment. + // That entry must also forward into the v2 supplicant flow, preserving both + // the query and the fragment. (FXA-13865 — the on-device entry point.) + test('v2 /pair/supp entry forwards to the v2 supplicant flow', async ({ + target, + syncOAuthBrowserPages: { page }, + }) => { + await page.goto( + `${target.contentServerUrl}/pair/supp?client_id=a2270f727f45f648&scope=profile#channel_id=testchannelid&channel_key=testchannelkey&v=2` + ); + + await page.waitForURL(/\/pair\/supplicant\/approve_signin/); + // The OAuth query and the channel fragment must survive the forward. + expect(page.url()).toContain('client_id=a2270f727f45f648'); + expect(page.url()).toContain('channel_id=testchannelid'); + expect(page.url()).toContain('v=2'); + }); + + /** + * Settings "Connect a device" is the other way into pairing. The CTA is + * gated to desktop Firefox (isFirefox && !isMobile). + * + * Note: the link currently targets /pair (v1). Carrying `?v=2` when the v2 + * pref is on is FXA-14289 / FXA-13869 work not yet wired into this link; + * when it lands, extend this to assert the v2 target. + */ + test('Settings "Connect a device" launches the pair flow', async ({ + target, + page, + pages: { signin, settings }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + await signInAccount(target, page, settings, signin, credentials); + + await settings.goto(); + + // The CTA is gated to desktop Firefox; the functional-test browser qualifies. + const connectDevice = page.locator( + '[data-glean-id="account_pref_connect_device_submit"]' + ); + await expect(connectDevice).toBeVisible(); + await expect(connectDevice).toHaveAttribute('href', /\/pair/); + + await connectDevice.click(); + await page.waitForURL(/\/pair(\?|#|$)/); + }); + }); + + /** + * The authority enters v2 only when FxA and the browser agree, via + * `capabilities.pairingVersion` in the fxa_status reply. Playwright's own + * build omits it and reads as v1, so these need Marionette on Nightly. + * + * No URL here carries `v=2`: the tests above use that override, which skips + * this branch entirely. + */ + test.describe.serial('v2 pairing version negotiation', () => { + let servedVersion = 1; + + // Baked in at server boot, so a test cannot change it. + test.beforeAll(async ({ browser, target }) => { + servedVersion = await getServedPairingVersion(browser, target); + }); + + test.beforeEach(() => { + test.skip( + !findV2AuthorityBinary(), + 'Firefox Nightly not found — install it, or set FIREFOX_BINARY to a v2-capable build' + ); + test.skip( + servedVersion !== 2, + `Needs the stack to serve config.pairing.version=2 (serving ${servedVersion})` + ); + }); + + async function signInAtVersion( + client: MarionetteClient, + contentServerUrl: string, + version: number, + credentials: { email: string; password: string } + ) { + await setPairingVersion(client, version); + await signInAuthorityViaMarionette( + client, + contentServerUrl, + credentials.email, + credentials.password + ); + // Sign-in ends on a chrome-context check, and navigate() needs content. + await client.setContext('content'); + } + + // Each authority entry negotiates separately. /pair is where Settings + // "Connect a device" and the post-signin handoffs land. + for (const entry of ['/connect_another_device', '/pair']) { + test(`a v2 browser skips the choice screen on ${entry}`, async ({ + target, + testAccountTracker, + marionetteAuthority, + }) => { + const client = marionetteAuthority.client; + const credentials = await testAccountTracker.signUp(); + await signInAtVersion(client, target.contentServerUrl, 2, credentials); + + await client.navigate( + `${target.contentServerUrl}${entry}?${ELIGIBLE_ENTRYPOINT_QS}` + ); + + const url = await waitForUrlContaining( + client, + PAIR_V2_ROUTES.AUTHORITY_SCAN_QR + ); + // Reached on capability alone; nothing here asked for v2. + expect(url).not.toContain('v=2'); + }); + } + + // Without this, a branch that always fired would pass the two above. + test('a v1 browser still gets the choice screen', async ({ + target, + testAccountTracker, + marionetteAuthority, + }) => { + const client = marionetteAuthority.client; + const credentials = await testAccountTracker.signUp(); + await signInAtVersion(client, target.contentServerUrl, 1, credentials); + + await client.navigate( + `${target.contentServerUrl}/connect_another_device?${ELIGIBLE_ENTRYPOINT_QS}` + ); + + // Renders only once negotiation finishes, so this waits for the URL check. + await findElementBySelectors(client, SELECTORS.PAIR_RADIO_HAS_MOBILE); + expect(await client.getUrl()).not.toContain('/pair/authority'); + }); }); }); + +async function signInAccount( + target: BaseTarget, + page: Page, + settings: SettingsPage, + signin: SigninPage, + credentials: Credentials +): Promise { + await page.goto(target.contentServerUrl); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + await page.waitForURL(/settings/); + await expect(settings.settingsHeading).toBeVisible(); +} diff --git a/packages/functional-tests/tests/pairing/pairingFlow.spec.ts b/packages/functional-tests/tests/pairing/pairingFlow.spec.ts index a7068b31c32..c0e8608e102 100644 --- a/packages/functional-tests/tests/pairing/pairingFlow.spec.ts +++ b/packages/functional-tests/tests/pairing/pairingFlow.spec.ts @@ -31,6 +31,7 @@ import { MarionetteClient } from '../../lib/marionette'; import { SELECTORS, TIMEOUTS } from '../../lib/pairing-constants'; import { signInAuthorityViaMarionette, + setPairingVersion, getSignedInUser, startPairingFlow, buildSupplicantUrl, @@ -112,6 +113,11 @@ test.describe('severity-2 #smoke', () => { marionetteAuthority, }) => { const client = marionetteAuthority.client; + // /pair negotiates into the v2 flow when FxA and the browser both report + // version 2, which this stack does. Opt the browser down so this spec + // still exercises v1. + await setPairingVersion(client, 1); + const credentials = await test.step('Create test account', async () => { return await testAccountTracker.signUp(); @@ -184,6 +190,11 @@ test.describe('severity-2 #smoke', () => { marionetteAuthority, }) => { const client = marionetteAuthority.client; + // /pair negotiates into the v2 flow when FxA and the browser both report + // version 2, which this stack does. Opt the browser down so this spec + // still exercises v1. + await setPairingVersion(client, 1); + const { credentials, secret } = await test.step('Create test account with TOTP', async () => { diff --git a/packages/functional-tests/tests/pairing/pairingFlowV2.spec.ts b/packages/functional-tests/tests/pairing/pairingFlowV2.spec.ts new file mode 100644 index 00000000000..010b1c30f95 --- /dev/null +++ b/packages/functional-tests/tests/pairing/pairingFlowV2.spec.ts @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * v2 pairing end to end: the happy path and the supplicant-cancel path + * (FXA-12855 / FXA-13870 / FXA-13869). + * + * Authority = real Firefox Nightly via Marionette, signed in, running the + * real chrome fxaccounts:pair_oauth_finish against the auth server. + * Supplicant = Playwright page running the real Pair2 supplicant containers, + * with pair_oauth_start / fxa_status / oauth_login web-channel commands + * backed by real PKCE + ECDH crypto in the harness (a Playwright page has no + * chrome to run them). The harness redeems the resulting code and decrypts + * keys_jwe, proving real Sync scoped keys reach the supplicant. + * + * Prerequisites: FXA stack, Firefox Nightly (or FIREFOX_BINARY at a v2-capable + * build), and network to the channel server. config.pairing.version=2 is not + * required: the authority uses the ?v=2 escape hatch. Skips without Nightly, + * so it does not run in CI. + * + * Both cases share the same topology and setup, so they live together rather + * than in a second spec that would launch its own authority Firefox. + */ + +import path from 'path'; +import { findV2AuthorityBinary } from '../../lib/firefox-binary'; +import { test, expect } from '../../lib/fixtures/pairing'; +import { MarionetteClient } from '../../lib/marionette'; +import { PAIR_V2_ROUTES } from '../../lib/pairing-constants'; +import { + signInAuthorityViaMarionette, + getSignedInUser, + startPairingFlowV2, + waitForUrlContaining, + attachAuthorityDiagnostics, + sleep, +} from '../../lib/pairing-helpers'; +import { + generateSupplicantCrypto, + installSupplicantWebChannelStub, + readCapturedOAuthLogin, + redeemAndDecrypt, +} from '../../lib/pairing-supplicant-harness'; + +const ELIGIBLE_ENTRYPOINT_QS = + 'context=oauth_webchannel_v1&entrypoint=fxa_app_menu'; +const OLDSYNC_SCOPE = 'https://identity.mozilla.com/apps/oldsync'; + +// Set PAIRING_WATCH_MS (e.g. 4000) to pause on each page so the flow is +// watchable in headed mode. Default 0 keeps normal runs fast. +const WATCH_MS = parseInt(process.env.PAIRING_WATCH_MS || '0', 10); + +// Set PAIRING_SHOTS_DIR to capture a screenshot of each supplicant page into +// that directory. +const SHOTS = process.env.PAIRING_SHOTS_DIR; + +function isMobileDevice(d: { type?: string; name?: string }): boolean { + return d.type === 'mobile' || /Android|Fenix|Firefox/i.test(d.name || ''); +} + +// Two real browsers + OAuth sign-in + a full channel handshake. +test.setTimeout(180_000); + +async function clickByTestId(client: MarionetteClient, testId: string) { + const el = await client.findElement( + 'css selector', + `[data-testid="${testId}"]` + ); + await client.clickElement(el); +} + +test.describe('severity-2 #smoke', () => { + test.describe.serial('v2 pairing flow', () => { + test.beforeEach(() => { + test.skip( + !findV2AuthorityBinary(), + 'Firefox Nightly not found — install it, or set FIREFOX_BINARY to a v2-capable build' + ); + }); + + // The authority runs under Marionette, so Playwright's own failure + // artifacts capture nothing from it. Attach its URL, page and trace. + test.afterEach(async ({ marionetteAuthority }, testInfo) => { + await attachAuthorityDiagnostics(marionetteAuthority.client, testInfo); + }); + + test('happy path: authority + supplicant complete pairing with real keys', async ({ + target, + syncOAuthBrowserPages: { page }, + testAccountTracker, + marionetteAuthority, + }) => { + const authority = marionetteAuthority.client; + const crypto = generateSupplicantCrypto(); + + await test.step('Authority signs in', async () => { + const creds = await testAccountTracker.signUp(); + await signInAuthorityViaMarionette( + authority, + target.contentServerUrl, + creds.email, + creds.password + ); + expect((await getSignedInUser(authority)).signedIn).toBe(true); + return creds; + }); + + const pairUrl = await test.step('Authority mints the v2 QR', async () => { + const url = await startPairingFlowV2( + authority, + target.contentServerUrl, + ELIGIBLE_ENTRYPOINT_QS + ); + expect(url).toContain('channel_id='); + expect(url).toContain('v=2'); + await sleep(WATCH_MS); // authority: QR on screen + return url; + }); + + await test.step('Supplicant opens the QR (real crypto stubbed)', async () => { + await installSupplicantWebChannelStub(page, crypto); + await page.goto(pairUrl, { waitUntil: 'load' }); + // /pair#..v=2 forwards into the v2 supplicant flow. + await page.waitForURL( + new RegExp(PAIR_V2_ROUTES.SUPPLICANT_APPROVE_SIGNIN) + ); + if (SHOTS) + await page.screenshot({ + path: path.join(SHOTS, '2-supplicant-approve-signin.png'), + }); + await sleep(WATCH_MS); // supplicant: approve sign-in + }); + + await test.step('Authority receives the request and approves', async () => { + await waitForUrlContaining( + authority, + PAIR_V2_ROUTES.AUTHORITY_APPROVE_SIGNIN + ); + await sleep(WATCH_MS); // authority: approve sign-in + // Attaches the pair:supp:authorize listener before the supplicant sends it. + await clickByTestId(authority, 'pair2-auth-approve-btn'); + }); + + await test.step('Supplicant confirms', async () => { + await page.waitForURL( + new RegExp(PAIR_V2_ROUTES.SUPPLICANT_CONNECT_THIS_DEVICE) + ); + if (SHOTS) + await page.screenshot({ + path: path.join(SHOTS, '4-supplicant-connect-this-device.png'), + }); + await sleep(WATCH_MS); // supplicant: connect this device + await page.getByTestId('pair2-supp-connect-btn').click(); + }); + + await test.step('Both sides reach sync success', async () => { + await page.waitForURL( + new RegExp(PAIR_V2_ROUTES.SUPPLICANT_SYNC_SUCCESS), + { timeout: 60_000 } + ); + await waitForUrlContaining( + authority, + PAIR_V2_ROUTES.AUTHORITY_SYNC_SUCCESS, + 60_000 + ); + if (SHOTS) + await page.screenshot({ + path: path.join(SHOTS, '6-supplicant-sync-success.png'), + }); + await sleep(WATCH_MS); // both: sync success + }); + + await test.step('Supplicant receives real Sync scoped keys', async () => { + const login = await readCapturedOAuthLogin(page); + expect( + login, + 'supplicant should have emitted oauth_login' + ).not.toBeNull(); + const { code, state } = login as { code: string; state: string }; + expect(code, 'oauth_login should carry a code').toBeTruthy(); + expect(state).toBe(crypto.state); + + const scopedKeys = await redeemAndDecrypt( + target.authServerUrl, + crypto, + code, + process.env.CI ? process.env.CI_WAF_TOKEN : undefined + ); + const oldsync = scopedKeys[OLDSYNC_SCOPE]; + expect(oldsync?.k, 'oldsync scoped key material').toBeTruthy(); + expect(oldsync?.kty).toBe('oct'); + }); + }); + + test('supplicant cancels: both sides end on timeout_and_cancel, no device', async ({ + target, + syncOAuthBrowserPages: { page }, + testAccountTracker, + marionetteAuthority, + }) => { + const authority: MarionetteClient = marionetteAuthority.client; + const crypto = generateSupplicantCrypto(); + + const credentials = await test.step('Authority signs in', async () => { + const creds = await testAccountTracker.signUp(); + await signInAuthorityViaMarionette( + authority, + target.contentServerUrl, + creds.email, + creds.password + ); + expect((await getSignedInUser(authority)).signedIn).toBe(true); + return creds; + }); + + const pairUrl = await test.step('Authority mints the v2 QR', async () => { + const url = await startPairingFlowV2( + authority, + target.contentServerUrl, + ELIGIBLE_ENTRYPOINT_QS + ); + expect(url).toContain('v=2'); + return url; + }); + + await test.step('Supplicant reaches connect_this_device', async () => { + await installSupplicantWebChannelStub(page, crypto); + await page.goto(pairUrl, { waitUntil: 'load' }); + await page.waitForURL( + new RegExp(PAIR_V2_ROUTES.SUPPLICANT_CONNECT_THIS_DEVICE) + ); + // The authority received the request and is on its approval screen. + await waitForUrlContaining( + authority, + PAIR_V2_ROUTES.AUTHORITY_APPROVE_SIGNIN + ); + }); + + await test.step('Supplicant cancels', async () => { + await page.getByTestId('pair2-supp-cancel-btn').click(); + await page.waitForURL( + new RegExp(PAIR_V2_ROUTES.SUPPLICANT_TIMEOUT_AND_CANCEL) + ); + }); + + await test.step('Authority is sent to timeout_and_cancel', async () => { + const url = await waitForUrlContaining( + authority, + PAIR_V2_ROUTES.AUTHORITY_TIMEOUT_AND_CANCEL, + 30_000 + ); + expect(url).not.toContain('sync_success'); + }); + + await test.step('No device registered on the account', async () => { + await sleep(3_000); + const devices = await target.authClient.deviceList( + credentials.sessionToken as string + ); + expect(devices.find(isMobileDevice)).toBeFalsy(); + }); + }); + }); +}); diff --git a/packages/functional-tests/tests/pairing/pairingFlowV2Android.spec.ts b/packages/functional-tests/tests/pairing/pairingFlowV2Android.spec.ts new file mode 100644 index 00000000000..80fede65fe7 --- /dev/null +++ b/packages/functional-tests/tests/pairing/pairingFlowV2Android.spec.ts @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * v2 pairing flow, Android supplicant (FXA-12855 / FXA-13870). + * + * The real-device counterpart of `pairingFlowV2.spec.ts`: the authority is a + * real Firefox Nightly (Marionette) running the FxA-owned v2 authority flow, and + * the supplicant is a real Fenix on an Android emulator, driven over adb via + * `AndroidSupplicant` (the same driver the v1 `pairingFlowAndroid.spec.ts` uses). + * + * Authority (Marionette, Nightly) Supplicant (Android / Fenix) + * ------------------------------------ ---------------------------- + * 1. sign in, open /connect_another_device?v=2 -> scan_qr (real channel + QR) + * 2. adb injects the v2 QR URL into + * the Sync Debug hook, begins pairing + * 3. on pair:supp:request -> approve_signin + * 4. tap "Yes, approve sign-in" + * 5. tap "Connect" (connect_this_device) + * 6. pair_oauth_finish -> pair:auth:authorize -> continue_on_mobile -> sync_success + * 7. OAuth completes -> device registered + * + * Gated behind ANDROID_PAIRING_V2_ENABLED and skipped by default: it needs an + * emulator running a Fenix build with the v2 web-channel commands. This is the + * only run against a real browser supplicant; pairingFlowV2.spec.ts covers the + * same authority side and additionally verifies the Sync scoped keys. + * + * Prerequisites: a booted Android emulator with a v2-capable Fenix debug build, + * `yarn adb-reverse`, and the webchannel-localhost manifest patch (see + * docs/pairing/README.md). Set ANDROID_PAIRING_V2_ENABLED=1 to run; the + * authority needs Firefox Nightly, or FIREFOX_BINARY at a v2-capable build. + */ + +import { writeFileSync } from 'fs'; +import { findV2AuthorityBinary } from '../../lib/firefox-binary'; +import { test, expect } from '../../lib/fixtures/pairing'; +import { MarionetteClient } from '../../lib/marionette'; +import { PAIR_V2_ROUTES } from '../../lib/pairing-constants'; +import { + signInAuthorityViaMarionette, + startPairingFlowV2, + extractChannelIdV2, + waitForUrlContaining, +} from '../../lib/pairing-helpers'; +import { AndroidSupplicant } from '../../lib/android-supplicant'; + +const ELIGIBLE_ENTRYPOINT_QS = + 'context=oauth_webchannel_v1&entrypoint=fxa_app_menu'; + +function isMobileDevice(d: { type?: string; name?: string }): boolean { + return d.type === 'mobile' || /Android|Fenix|Firefox/i.test(d.name || ''); +} + +async function pollForMobileDevice( + authClient: { deviceList: (token: string) => Promise }, + sessionToken: string, + timeoutMs: number +): Promise<{ name?: string; type?: string } | undefined> { + const deadline = Date.now() + timeoutMs; + do { + const devices = await authClient.deviceList(sessionToken); + const found = devices.find(isMobileDevice); + if (found) return found; + await new Promise((r) => setTimeout(r, 3_000)); + } while (Date.now() < deadline); + return undefined; +} + +async function clickByTestId(client: MarionetteClient, testId: string) { + const el = await client.findElement( + 'css selector', + `[data-testid="${testId}"]` + ); + await client.clickElement(el); +} + +// Set PAIRING_WATCH_MS (e.g. 4000) to pause between steps so the flow is +// watchable in headed mode. Default 0 keeps normal runs fast. +const WATCH_MS = parseInt(process.env.PAIRING_WATCH_MS || '0', 10); +const watch = () => + WATCH_MS ? new Promise((r) => setTimeout(r, WATCH_MS)) : Promise.resolve(); + +// Emulator cold start + UI nav + channel handshake + OAuth round trip. +test.setTimeout(300_000); + +test.describe.serial('v2 Android pairing flow', () => { + test.describe.configure({ retries: 0 }); + + let supplicant: AndroidSupplicant; + + test.beforeEach(async ({}, testInfo) => { + if (!process.env.ANDROID_PAIRING_V2_ENABLED) { + testInfo.skip( + true, + 'Set ANDROID_PAIRING_V2_ENABLED=1 (needs a booted emulator running a Fenix debug build)' + ); + } + if (!findV2AuthorityBinary()) { + testInfo.skip( + true, + 'Firefox Nightly not found — install it, or set FIREFOX_BINARY to a v2-capable build' + ); + } + supplicant = new AndroidSupplicant(); + }); + + test.afterEach(async ({}, testInfo) => { + if (!process.env.ANDROID_PAIRING_V2_ENABLED) return; + if (testInfo.status !== testInfo.expectedStatus) { + try { + const shot = testInfo.outputPath('android-v2-supplicant.png'); + supplicant.screenshot(shot); + await testInfo.attach('android-v2-supplicant.png', { + path: shot, + contentType: 'image/png', + }); + await testInfo.attach('android-v2-logcat.txt', { + body: supplicant.dumpLogcat(), + contentType: 'text/plain', + }); + } catch { + /* best-effort diagnostics */ + } + } + supplicant.forceStop(); + }); + + test('authority mints v2 QR and Android supplicant completes pairing', async ({ + target, + testAccountTracker, + marionetteAuthority, + }, testInfo) => { + const authority = marionetteAuthority.client; + + const credentials = await test.step('Authority signs in', async () => { + const creds = await testAccountTracker.signUp(); + await signInAuthorityViaMarionette( + authority, + target.contentServerUrl, + creds.email, + creds.password + ); + // signInAuthorityViaMarionette already blocks until Firefox reports the + // signed-in state, so re-asserting it here only repeats that check. + return creds; + }); + + // Prepare the supplicant before minting the (short-lived) channel. The + // reset must come first: a Fenix that still has an account from an earlier + // run takes a re-auth web flow instead of pairing, so the authority waits + // for a `pair:supp:request` that never arrives. + await test.step('Prepare Android supplicant', async () => { + supplicant.resetToColdState(); + await supplicant.ensureReady(target.contentServerUrl); + }); + + const { pairUrl, channelId } = + await test.step('Authority mints the v2 QR', async () => { + const url = await startPairingFlowV2( + authority, + target.contentServerUrl, + ELIGIBLE_ENTRYPOINT_QS + ); + expect(url).toContain('v=2'); + return { pairUrl: url, channelId: extractChannelIdV2(url) }; + }); + + await test.step('Android supplicant opens the QR URL', async () => { + // A v2 device scans the QR with the native camera, so the URL arrives as + // a plain VIEW intent and opens in a normal tab. The page then asks the + // browser for the OAuth params over `fxaccounts:pair_oauth_start`. + const seenUrl = await supplicant.openPairingUrl(pairUrl); + expect(seenUrl).toContain(channelId); + await supplicant.waitForSupplicantOnChannel(45_000); + }); + + await test.step('Authority approves the request', async () => { + await waitForUrlContaining( + authority, + PAIR_V2_ROUTES.AUTHORITY_APPROVE_SIGNIN, + 45_000 + ); + await clickByTestId(authority, 'pair2-auth-approve-btn'); + }); + + await test.step('Android supplicant confirms', async () => { + // The v2 connect_this_device card's CTA reads "Connect". + await supplicant.confirmPairing(45_000, /^Connect$/i); + }); + + await test.step('Authority reaches sync success', async () => { + const finalUrl = await waitForUrlContaining( + authority, + PAIR_V2_ROUTES.AUTHORITY_SYNC_SUCCESS, + 60_000 + ); + expect(finalUrl).not.toContain('timeout_and_cancel'); + }); + + const device = + await test.step('Android device is registered on the account', async () => { + const found = await pollForMobileDevice( + target.authClient, + credentials.sessionToken as string, + 45_000 + ); + expect(found).toBeTruthy(); + return found; + }); + + await test.step('Connected Services lists the Android device', async () => { + // The API check above proves registration; this proves the authority's + // own Settings UI surfaces the newly paired device to the user. + await authority.navigate(`${target.contentServerUrl}/settings/clients`); + await waitForUrlContaining(authority, '/settings/clients', 30_000); + + const deviceName = device?.name ?? ''; + const row = await pollUntilElement( + authority, + `//*[contains(text(), ${xpathLiteral(deviceName)})]`, + 30_000 + ); + expect( + row, + `"${deviceName}" not listed in Connected Services` + ).toBeTruthy(); + + const body = await authority.findElement('css selector', 'body'); + const shot = testInfo.outputPath('authority-connected-services.png'); + writeFileSync( + shot, + Buffer.from(await authority.screenshotElement(body), 'base64') + ); + await testInfo.attach('authority-connected-services.png', { + path: shot, + contentType: 'image/png', + }); + await watch(); + }); + }); +}); + +/** Poll for an element so the Settings list has time to load its services. */ +async function pollUntilElement( + client: MarionetteClient, + xpath: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs; + do { + try { + return await client.findElement('xpath', xpath); + } catch { + await new Promise((r) => setTimeout(r, 2_000)); + } + } while (Date.now() < deadline); + return undefined; +} + +/** Quote a value for use inside an XPath expression. */ +function xpathLiteral(value: string): string { + if (!value.includes("'")) return `'${value}'`; + return `concat('${value.split("'").join(`', "'", '`)}')`; +} diff --git a/packages/functional-tests/tests/pairing/pairingFlowiOS.spec.ts b/packages/functional-tests/tests/pairing/pairingFlowiOS.spec.ts index ebc4caefe40..db85b6aa61e 100644 --- a/packages/functional-tests/tests/pairing/pairingFlowiOS.spec.ts +++ b/packages/functional-tests/tests/pairing/pairingFlowiOS.spec.ts @@ -42,7 +42,6 @@ import { buildAuthorityOAuthUrl, extractChannelId, findElementBySelectors, - screenshotAuthority, isPairRoutesReact, waitForUrlContaining, captureDiagnostics, @@ -283,7 +282,6 @@ test.describe.serial('iOS pairing flow', () => { const user = await getSignedInUser(client); expect(user.signedIn).toBe(true); expect(user.email).toBe(credentials.email); - await screenshotAuthority(client, 'iOS', '1-signed-in'); return user; }); @@ -341,7 +339,6 @@ test.describe.serial('iOS pairing flow', () => { await sleep(3_000); const { url: preApproveUrl } = await captureDiagnostics(client); debug(`Authority URL before approve: ${preApproveUrl}`); - await screenshotAuthority(client, 'iOS', '2-auth-approve'); // Dump the page HTML to help debug missing buttons try { @@ -381,7 +378,6 @@ test.describe.serial('iOS pairing flow', () => { ); await client.clickElement(approveBtn); debug('Authority approved pairing'); - await screenshotAuthority(client, 'iOS', '3-auth-approved'); }); // 5. Wait for authority to reach completion page @@ -396,7 +392,6 @@ test.describe.serial('iOS pairing flow', () => { ); expect(finalUrl).not.toContain('pair/failure'); debug(`Authority completed at: ${finalUrl}`); - await screenshotAuthority(client, 'iOS', '4-auth-complete'); }); // 6. Wait for iOS XCUITest to finish @@ -501,7 +496,6 @@ test.describe.serial('iOS pairing flow', () => { // Wait for the approve button to confirm the page loaded await findElementBySelectors(client, SELECTORS.AUTHORITY_APPROVE, 30_000); debug('Authority approval page loaded'); - await screenshotAuthority(client, 'iOS-cancel', '1-auth-approve'); }); // Wait for iOS XCUITest to finish (supplicant taps Cancel) diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 85178f173bf..6a7306a51a4 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -177,10 +177,10 @@ const PairSuppWaitForAuth = lazy( ); const PairAuthorityApproveSignIn = lazy( - () => import('../../pages/Pair2/Authority/ApproveSignIn') + () => import('../../pages/Pair2/Authority/ApproveSignIn/container') ); const PairAuthorityContinueOnMobile = lazy( - () => import('../../pages/Pair2/Authority/ContinueOnMobile') + () => import('../../pages/Pair2/Authority/ContinueOnMobile/container') ); const PairAuthorityDownloadFirefox = lazy( () => import('../../pages/Pair2/Authority/DownloadFirefox') @@ -192,15 +192,15 @@ const PairAuthoritySyncSuccess = lazy( () => import('../../pages/Pair2/Authority/SyncSuccess') ); const PairAuthorityTimeoutAndCancel = lazy( - () => import('../../pages/Pair2/Authority/TimeoutAndCancel') + () => import('../../pages/Pair2/Authority/TimeoutAndCancel/container') ); const PairSupplicantApproveSignIn = lazy( - () => import('../../pages/Pair2/Supplicant/ApproveSignIn') + () => import('../../pages/Pair2/Supplicant/ApproveSignIn/container') ); const PairSupplicantConnectThisDevice = lazy( - () => import('../../pages/Pair2/Supplicant/ConnectThisDevice') + () => import('../../pages/Pair2/Supplicant/ConnectThisDevice/container') ); const PairSupplicantDownloadFirefox = lazy( () => import('../../pages/Pair2/Supplicant/DownloadFirefox') @@ -1070,26 +1070,11 @@ const AuthAndAccountSetupRoutes = ({ /> { - console.log('TBD!') - }, - onChangePassword: () => { - console.log('TBD!') - } - }} />} + element={} /> console.log('TBD') - }} />} + element={} /> } + element={} /> { - + async pairOauthStart(msg: { + scopes?: string[]; + }): Promise { // Default sync scopes if (msg.scopes == null) { msg.scopes = [ Constants.OAUTH_OLDSYNC_SCOPE, - Constants.OAUTH_TRUSTED_PROFILE_SCOPE + Constants.OAUTH_TRUSTED_PROFILE_SCOPE, ]; } @@ -661,47 +659,63 @@ export class Firefox extends EventTarget { msg, (event) => { if (event?.detail?.state == null) { - throw new Error(`${FirefoxCommand.PairOauthFinish} missing state from event.details`); + throw new Error( + `${FirefoxCommand.PairOauthFinish} missing state from event.details` + ); } if (event?.detail?.scope == null) { - throw new Error(`${FirefoxCommand.PairOauthFinish} missing code from event.details`); + throw new Error( + `${FirefoxCommand.PairOauthFinish} missing code from event.details` + ); } if (event?.detail?.code_challenge == null) { - throw new Error(`${FirefoxCommand.PairOauthFinish} missing code_challenge from event.details`); + throw new Error( + `${FirefoxCommand.PairOauthFinish} missing code_challenge from event.details` + ); } if (event?.detail?.keys_jwk == null) { - throw new Error(`${FirefoxCommand.PairOauthFinish} missing keys_jwk from event.details`); + throw new Error( + `${FirefoxCommand.PairOauthFinish} missing keys_jwk from event.details` + ); } return event.detail as PairOAuthStartState; } - ) + ); } /** Requests that a pairing oauth operation be finished. This is the second half of pairing dance. */ - async pairOauthFinish(msg:{ - client_id:string, - state:string, - scope:string, - code_challenge:string, - }):Promise { + async pairOauthFinish(msg: { + client_id: string; + state: string; + scope: string; + code_challenge: string; + // Forwarded so chrome can wrap the scoped Sync keys into a keys_jwe for the + // supplicant; without it no keys are delivered. code_challenge_method + // defaults to S256 in chrome when omitted. + keys_jwk?: string; + code_challenge_method?: string; + }): Promise { return this._executeCommandWithResponse( FirefoxCommand.PairOauthFinish, msg, (event) => { if (event?.detail?.code == null) { - throw new Error(`${FirefoxCommand.PairOauthFinish} missing code from event.details`); + throw new Error( + `${FirefoxCommand.PairOauthFinish} missing code from event.details` + ); } if (event?.detail?.state == null) { - throw new Error(`${FirefoxCommand.PairOauthFinish} missing state from event.details`); + throw new Error( + `${FirefoxCommand.PairOauthFinish} missing state from event.details` + ); } if (event.detail.state !== msg.state) { throw new Error(`${FirefoxCommand.PairOauthFinish} invalid state!`); - } - return event.detail as PairOAuthFinishState + return event.detail as PairOAuthFinishState; }, 10_000 // The final handshake makes a web call. Give it some leeway. - ) + ); } /** @@ -714,11 +728,11 @@ export class Firefox extends EventTarget { private async _executeCommandWithResponse( cmd: FirefoxCommand, msg: any, - handleResp:(event:any) => TResp, + handleResp: (event: any) => TResp, timeout = DEFAULT_SEND_TIMEOUT_LENGTH_MS ) { let timeoutId: number; - let onResp:EventListenerOrEventListenerObject; + let onResp: EventListenerOrEventListenerObject; return Promise.race([ new Promise((resolve, reject) => { onResp = (event: any) => { @@ -735,13 +749,13 @@ export class Firefox extends EventTarget { // The handler might throw an error and fail fast if the data looks wrong. Handle error // and reject if this happens. try { - const resp = handleResp(event) + const resp = handleResp(event); resolve(resp); } catch (err) { reject(err); } - } + }; this.addEventListener(cmd, onResp); requestAnimationFrame(() => { console.log(`[[Firefox WebChannel] ${cmd} sent msg`, msg); @@ -755,7 +769,7 @@ export class Firefox extends EventTarget { `[Firefox WebChannel] ${cmd} timed out or unavailable in this browser` ); if (onResp) { - this.removeEventListener(cmd, onResp) + this.removeEventListener(cmd, onResp); } resolve(undefined); }, timeout); @@ -763,40 +777,36 @@ export class Firefox extends EventTarget { ]); } - - - - /* - * Sends an fxa_status and returns the signed in user if available. + /** + * Sends an fxa_status and returns the whole response. + * + * Use this instead of `requestSignedInUser` when the caller also needs the + * browser capabilities. Reading them from the awaited response keeps them in + * step with the signed-in user, which a separate capability source cannot + * guarantee. Resolves undefined when the browser does not answer in time. */ - async requestSignedInUser( + async requestFxAStatus( context: string, isPairing: boolean, service: string - ): Promise { + ): Promise { let timeoutId: number; - return Promise.race([ - new Promise((resolve) => { + return Promise.race([ + new Promise((resolve) => { const handleFxAStatusEvent = (event: any) => { clearTimeout(timeoutId); this.removeEventListener( FirefoxCommand.FxAStatus, handleFxAStatusEvent ); - - const status = event.detail as FxAStatusResponse; - resolve(status.signedInUser); + resolve(event.detail as FxAStatusResponse); }; this.addEventListener(FirefoxCommand.FxAStatus, handleFxAStatusEvent); // requestAnimationFrame ensures the event listener is added first // otherwise, there is a race condition requestAnimationFrame(() => { - this.send(FirefoxCommand.FxAStatus, { - context, - isPairing, - service, - }); + this.send(FirefoxCommand.FxAStatus, { context, isPairing, service }); }); }), // Ideally, we would detect WebChannel support instead of relying on a timeout. @@ -816,6 +826,18 @@ export class Firefox extends EventTarget { }), ]); } + + /* + * Sends an fxa_status and returns the signed in user if available. + */ + async requestSignedInUser( + context: string, + isPairing: boolean, + service: string + ): Promise { + const status = await this.requestFxAStatus(context, isPairing, service); + return status?.signedInUser; + } } // Some non-firefox legacy browsers can't extend EventTarget. diff --git a/packages/fxa-settings/src/lib/channels/pairing-channel.ts b/packages/fxa-settings/src/lib/channels/pairing-channel.ts index 71f25657e35..9b2a44ececb 100644 --- a/packages/fxa-settings/src/lib/channels/pairing-channel.ts +++ b/packages/fxa-settings/src/lib/channels/pairing-channel.ts @@ -241,6 +241,7 @@ export class PairingChannelClient extends EventTarget { } } + async send( message: string, data: Record = {} diff --git a/packages/fxa-settings/src/lib/channels/pairing-flow.ts b/packages/fxa-settings/src/lib/channels/pairing-flow.ts new file mode 100644 index 00000000000..b85ce3dbb9d --- /dev/null +++ b/packages/fxa-settings/src/lib/channels/pairing-flow.ts @@ -0,0 +1,193 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Shared v2 pairing flow state (FXA-12855). + * + * The v2 flow spans several routes (scan_qr -> approve_signin -> ...), but the + * pairing channel is a single live WebSocket that must outlive any one page. A + * per-page container would close it on navigation. This module-level controller + * owns the channel and the handshake state so it survives route changes; there + * is only ever one pairing flow per tab. + * + * Reset the controller when the flow ends (success or failure) or when the user + * leaves the pairing routes, so a stale channel does not leak into a new flow. + */ + +import { + PairingChannelClient, + PairingChannelRemoteMetadata, +} from './pairing-channel'; +import { RemoteMetadata } from '../types'; + +// Diagnostic pairing log. Visible in Fenix logcat ("Web Content") on the +// supplicant and in the browser console on the authority. +// Bounded so a long-lived page cannot grow the buffer without limit. +const PLOG_BUFFER_LIMIT = 200; + +export function plog(...args: unknown[]): void { + // eslint-disable-next-line no-console + console.log('[pair2]', ...args); + // Also buffered on `window`: the functional tests cannot see the console of + // the Marionette-driven authority, so they read the trace back from here. + try { + const w = window as unknown as { __pair2Log?: string[] }; + const log = (w.__pair2Log ??= []); + log.push( + args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + ); + if (log.length > PLOG_BUFFER_LIMIT) { + log.shift(); + } + } catch { + // Never let logging break the flow. + } +} + +export type SupplicantOAuthRequest = { + state: string; + scope: string; + code_challenge: string; + // The supplicant sends this; chrome defaults it to S256 when absent. + code_challenge_method?: string; + keys_jwk?: string; + client_id: string; +}; + +export type AuthorityMetadata = { + email: string; + displayName?: string; + avatar?: string; + deviceName?: string; +}; + +/** Map channel sender metadata onto the shape the approval cards render. */ +export function toRemoteMetadata( + m: PairingChannelRemoteMetadata, + deviceName?: string +): RemoteMetadata { + const ua = m.ua || ''; + return { + deviceName, + // Best-effort until a shared UA parser is wired; the channel only gives a + // raw UA string. deviceFamily/deviceOS drive DeviceInfoBlock display only. + deviceFamily: /Android/i.test(ua) + ? 'Android' + : /iPhone|iPad|iOS/i.test(ua) + ? 'iOS' + : 'Mobile', + deviceOS: ua, + ipAddress: m.ipAddress || '', + city: m.city, + region: m.region, + country: m.country, + }; +} + +class PairingFlowController { + private client: PairingChannelClient | null = null; + private abortWired = false; + // Set true once a side reaches success, so the channel close that follows a + // completed flow is not mistaken for an abort. + completing = false; + + // Authority-held state. + channelId?: string; + channelKey?: string; + supplicantRequest?: SupplicantOAuthRequest; + remoteMetadata?: RemoteMetadata; + + // Supplicant-held state. + authorityMetadata?: AuthorityMetadata; + // What the supplicant's pair_oauth_start produced; kept so the incoming + // pair:auth:authorize state can be validated and oauth_login can be issued. + supplicantOAuth?: { + state: string; + scope: string; + code_challenge: string; + keys_jwk?: string; + }; + + getClient(): PairingChannelClient { + if (!this.client) { + this.client = new PairingChannelClient(); + } + return this.client; + } + + get isConnected(): boolean { + return this.client?.isConnected ?? false; + } + + async joinChannel( + channelServerUri: string, + channelId: string, + channelKey: string + ): Promise { + this.channelId = channelId; + this.channelKey = channelKey; + await this.getClient().open(channelServerUri, channelId, channelKey); + plog('channel joined', channelId); + } + + send(message: string, data: Record = {}): Promise { + plog('send ->', message, Object.keys(data)); + return this.getClient() + .send(message, data) + .then( + () => plog('send ok', message), + (e) => { + plog('send FAIL', message, String(e)); + throw e; + } + ); + } + + /** + * Wire abort handling once for the flow: an unexpected channel close or error + * (the peer cancelled, or the connection dropped) invokes `onAbort`, unless we + * are already completing. Idempotent, so it is safe to call from whichever + * page mounts first on each side. + */ + wireAbort(onAbort: () => void): void { + if (this.abortWired) return; + this.abortWired = true; + const client = this.getClient(); + const handler = () => { + plog('channel close/error; completing=', this.completing); + if (!this.completing) onAbort(); + }; + client.addEventListener('close', handler); + client.addEventListener('error', handler); + } + + /** Subscribe to a channel event; returns an unsubscribe fn. */ + on(type: string, listener: EventListener): () => void { + const client = this.getClient(); + const wrapped: EventListener = (ev) => { + plog('recv <-', type); + listener(ev); + }; + client.addEventListener(type, wrapped); + return () => client.removeEventListener(type, wrapped); + } + + async reset(): Promise { + if (this.client) { + await this.client.close(); + this.client = null; + } + this.abortWired = false; + this.completing = false; + this.channelId = undefined; + this.channelKey = undefined; + this.supplicantRequest = undefined; + this.remoteMetadata = undefined; + this.authorityMetadata = undefined; + this.supplicantOAuth = undefined; + } +} + +export const pairingFlow = new PairingFlowController(); +export type { PairingFlowController }; diff --git a/packages/fxa-settings/src/models/integrations/pairing-authority-integration.test.ts b/packages/fxa-settings/src/models/integrations/pairing-authority-integration.test.ts index e2873204d17..b25c2c8ea32 100644 --- a/packages/fxa-settings/src/models/integrations/pairing-authority-integration.test.ts +++ b/packages/fxa-settings/src/models/integrations/pairing-authority-integration.test.ts @@ -6,6 +6,7 @@ import { GenericData } from '../../lib/model-data'; import { AuthorityState, PairingAuthorityIntegration, + resetAuthoritySession, } from './pairing-authority-integration'; const CHANNEL_SERVER_URI = 'wss://channel.example.com'; @@ -90,6 +91,7 @@ function createIntegration( describe('PairingAuthorityIntegration', () => { beforeEach(() => { + resetAuthoritySession(); jest.clearAllMocks(); jest.useFakeTimers(); mockListeners = {}; @@ -586,6 +588,7 @@ describe('PairingAuthorityIntegration', () => { 'close', 'error', 'remote:pair:supp:request', + 'remote:pair:supp:complete', 'remote:pair:supp:authorize', ]); }); diff --git a/packages/fxa-settings/src/models/integrations/pairing-authority-integration.ts b/packages/fxa-settings/src/models/integrations/pairing-authority-integration.ts index ebb13528dd6..ae38bb61341 100644 --- a/packages/fxa-settings/src/models/integrations/pairing-authority-integration.ts +++ b/packages/fxa-settings/src/models/integrations/pairing-authority-integration.ts @@ -15,6 +15,10 @@ import { PairSupplicantMetadataResponse, } from '../../lib/channels/firefox'; import { RemoteMetadata } from '../../lib/types'; +import { + plog, + SupplicantOAuthRequest, +} from '../../lib/channels/pairing-flow'; import UAParser from 'ua-parser-js'; import { toGenericOSName } from '../../lib/utilities'; import config from '../../lib/config'; @@ -63,26 +67,121 @@ export enum AuthorityState { * * Ported from: fxa-content-server/app/scripts/models/auth_brokers/pairing/authority.js */ +/** + * Live authority pairing session, shared across PairingAuthorityIntegration + * instances. See the note on `_channel` for why it cannot be instance state. + * + * Built by a factory so the fields are declared once: `resetAuthoritySession` + * cannot then miss one that is added later. + */ +function newAuthoritySession() { + return { + channel: null as PairingChannelClient | null, + state: null as AuthorityState | null, + remoteMetadata: null as RemoteMetadata | null, + supplicantOAuth: null as SupplicantOAuthRequest | null, + suppAuthorized: false, + authAuthorized: false, + completing: false, + pendingGrant: false, + onSuppComplete: null as (() => void) | null, + onSuppAuthorized: null as (() => void) | null, + onError: null as ((error: unknown) => void) | null, + onStateChange: null as ((state: AuthorityState) => void) | null, + }; +} + +const authoritySession = newAuthoritySession(); + +/** Clear the session, so one test's channel cannot leak into the next. */ +export function resetAuthoritySession(): void { + Object.assign(authoritySession, newAuthoritySession()); +} + export class PairingAuthorityIntegration extends OAuthWebIntegration { private static readonly TERMINAL_STATES = new Set([ AuthorityState.Complete, AuthorityState.Failed, ]); - private _channel: PairingChannelClient | null = null; - private _state: AuthorityState | null = null; + // Session state lives at module scope because `useIntegration` rebuilds the + // integration on navigation. Instance state would be dropped between scan_qr + // and approve_signin, taking the handshake with it. + private get _channel(): PairingChannelClient | null { + return authoritySession.channel; + } + private set _channel(v: PairingChannelClient | null) { + authoritySession.channel = v; + } + private get _state(): AuthorityState | null { + return authoritySession.state; + } + private set _state(v: AuthorityState | null) { + authoritySession.state = v; + } private heartbeatTimer: ReturnType | null = null; - private _remoteMetadata: RemoteMetadata | null = null; + private get _remoteMetadata(): RemoteMetadata | null { + return authoritySession.remoteMetadata; + } + private set _remoteMetadata(v: RemoteMetadata | null) { + authoritySession.remoteMetadata = v; + } private _metadataPromise: Promise | null = null; - private _suppAuthorized = false; - private _authAuthorized = false; + private get _suppAuthorized(): boolean { + return authoritySession.suppAuthorized; + } + private set _suppAuthorized(v: boolean) { + authoritySession.suppAuthorized = v; + } + private get _authAuthorized(): boolean { + return authoritySession.authAuthorized; + } + private set _authAuthorized(v: boolean) { + authoritySession.authAuthorized = v; + } private _cachedChannelId: string | null = null; - public onSuppAuthorized: (() => void) | null = null; + // v2 mints the code here via pair_oauth_finish rather than in Firefox, so the + // authority needs the supplicant's OAuth params and its own channel send. + private get _supplicantOAuth(): SupplicantOAuthRequest | null { + return authoritySession.supplicantOAuth; + } + private set _supplicantOAuth(v: SupplicantOAuthRequest | null) { + authoritySession.supplicantOAuth = v; + } + private get _completing(): boolean { + return authoritySession.completing; + } + private set _completing(v: boolean) { + authoritySession.completing = v; + } + + public get onSuppComplete(): (() => void) | null { + return authoritySession.onSuppComplete; + } + public set onSuppComplete(v: (() => void) | null) { + authoritySession.onSuppComplete = v; + } + public get onSuppAuthorized(): (() => void) | null { + return authoritySession.onSuppAuthorized; + } + public set onSuppAuthorized(v: (() => void) | null) { + authoritySession.onSuppAuthorized = v; + } public onHeartbeatError: ((err: unknown) => void) | null = null; - public onError: ((error: unknown) => void) | null = null; - public onStateChange: ((state: AuthorityState) => void) | null = null; + public get onError(): ((error: unknown) => void) | null { + return authoritySession.onError; + } + public set onError(v: ((error: unknown) => void) | null) { + authoritySession.onError = v; + } + public get onStateChange(): ((state: AuthorityState) => void) | null { + return authoritySession.onStateChange; + } + public set onStateChange(v: ((state: AuthorityState) => void) | null) { + authoritySession.onStateChange = v; + } constructor( data: ModelDataStore, @@ -144,6 +243,10 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { 'remote:pair:supp:authorize', this.handleSuppAuthorize ); + channel.addEventListener( + 'remote:pair:supp:complete', + this.handleSuppComplete + ); try { await channel.create(config.pairing.serverBaseUri); @@ -161,6 +264,10 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { * guard here. */ private handleClose = () => { + // A close during completion is the supplicant finishing, not an abort. + if (this._completing) { + return; + } this.fail({ errno: 1006, message: 'Connection to remote device closed, please try again', @@ -172,11 +279,13 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { }; private setState(state: AuthorityState): void { + plog('auth state ->', state); this._state = state; this.onStateChange?.(state); } private fail(err: unknown): void { + plog('auth fail', (err as { message?: string })?.message ?? String(err)); if ( this._state && PairingAuthorityIntegration.TERMINAL_STATES.has(this._state) @@ -188,6 +297,7 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { } private handleConnected = () => { + plog('auth channel connected'); this.setState(AuthorityState.WaitingForMetadata); }; @@ -208,10 +318,24 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { * asking, and the only field of the message we consume. */ private handleSuppRequest = (event: Event) => { + plog('auth recv <- pair:supp:request'); const data = (event as CustomEvent).detail as PairingChannelIncomingMessage; if (data?.remoteMetaData) { this._remoteMetadata = toRemoteMetadata(data.remoteMetaData); } + // pair_oauth_finish needs these to mint a code bound to the supplicant's + // own PKCE verifier and keys_jwk. + const req = data as unknown as Partial; + if (req?.client_id && req?.state) { + this._supplicantOAuth = { + client_id: req.client_id, + state: req.state, + scope: req.scope ?? '', + code_challenge: req.code_challenge ?? '', + code_challenge_method: req.code_challenge_method, + keys_jwk: req.keys_jwk ?? '', + }; + } this.setState(AuthorityState.WaitingForAuthorizations); }; @@ -226,12 +350,23 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { * rejected anything without a well-formed envelope and sender, so the detail * is deliberately not read here. */ + /** v2: the supplicant finished its OAuth exchange and is signed in. */ + private handleSuppComplete = () => { + this._completing = true; + this.onSuppComplete?.(); + }; + private handleSuppAuthorize = () => { if (!this._suppAuthorized) { this._suppAuthorized = true; this.onSuppAuthorized?.(); } + // The authority already approved and was waiting on this. + if (authoritySession.pendingGrant) { + this.grantOAuthCode().catch((err) => Sentry.captureException(err)); + } + if (this._state === AuthorityState.WaitingForAuthorizations) { // Supplicant approved first — the authority user has yet to act. this.setState(AuthorityState.WaitingForAuthority); @@ -409,6 +544,90 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { } } + /** The OAuth params the supplicant sent, needed by pair_oauth_finish. */ + get supplicantOAuth(): SupplicantOAuthRequest | null { + return this._supplicantOAuth; + } + + /** + * v2 authority approval. Where `authorize()` hands the channel work to + * Firefox via `fxaccounts:pair_authorize`, v2 mints the code here through + * `pair_oauth_finish` and relays it over the channel FxA owns. + * + * Returns false when there is nothing to authorize, so the caller can stay on + * the approval screen rather than navigate into a dead flow. + */ + async authorizeV2(): Promise { + const req = this._supplicantOAuth; + if (!req || !this._channel) { + return false; + } + this._authAuthorized = true; + + // The supplicant only listens for `pair:auth:authorize` once its user taps + // Connect, so sending before then is lost. Approving first is normal: + // defer the grant until `pair:supp:authorize` arrives. + if (!this._suppAuthorized) { + plog('auth approved first; waiting for the supplicant'); + authoritySession.pendingGrant = true; + this.setState(AuthorityState.WaitingForSupplicant); + return true; + } + return this.grantOAuthCode(); + } + + /** + * Reply to `pair:supp:request` with the details the supplicant shows on its + * confirmation card. It waits for this before advancing, so omitting it + * stalls the handshake with both sides connected. + * + * A page passes them in: the supplicant is not signed in, so these are the + * authority's own account fields. + */ + async sendAuthorityMetadata(meta: { + email?: string; + displayName?: string; + avatar?: string; + }): Promise { + if (!this._channel) { + return; + } + plog('auth send -> pair:auth:metadata'); + await this._channel.send('pair:auth:metadata', { + email: meta.email, + displayName: meta.displayName, + avatar: meta.avatar, + deviceName: undefined, + }); + } + + /** Mint the code and relay it over the channel. */ + private async grantOAuthCode(): Promise { + const req = this._supplicantOAuth; + if (!req || !this._channel) { + return false; + } + authoritySession.pendingGrant = false; + const finished = await firefox.pairOauthFinish({ + client_id: req.client_id, + state: req.state, + scope: req.scope, + code_challenge: req.code_challenge, + code_challenge_method: req.code_challenge_method, + keys_jwk: req.keys_jwk, + }); + if (!finished) { + plog('auth pair_oauth_finish returned nothing'); + return false; + } + plog('auth send -> pair:auth:authorize'); + await this._channel.send('pair:auth:authorize', { + code: finished.code, + state: finished.state, + }); + return true; + } + /** Authority user declined the pairing request. */ async decline(): Promise { this.stopHeartbeat(); @@ -425,6 +644,7 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { async destroy() { this.stopHeartbeat(); this.onSuppAuthorized = null; + this.onSuppComplete = null; this.onHeartbeatError = null; this.onStateChange = null; @@ -438,6 +658,10 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration { 'remote:pair:supp:request', this.handleSuppRequest ); + this._channel.removeEventListener( + 'remote:pair:supp:complete', + this.handleSuppComplete + ); this._channel.removeEventListener( 'remote:pair:supp:authorize', this.handleSuppAuthorize diff --git a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx index 000eee5525f..3992ede4ded 100644 --- a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx +++ b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx @@ -240,6 +240,9 @@ const ConnectAnotherDevice = ({ fxaStatus.pairingEnabled === true && fxaStatus.pairingVersion === 2 ) { + // Hard navigation, as the v1 `/pair` entry below already does: the + // integration is built from the URL at page load, so the authority + // pages only get a PairingAuthorityIntegration on a real load. hardNavigate('/pair/authority/scan_qr', {}, true); return; } diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx index 9f1deda6ec7..4640def6156 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { renderWithRouter } from '../../../models/mocks'; +import { mockPairingAppContext } from '../../ConnectAnotherDevice/mocks'; import { usePageViewEvent } from '../../../lib/metrics'; import { REACT_ENTRYPOINT } from '../../../constants'; import GleanMetrics from '../../../lib/glean'; @@ -36,11 +37,14 @@ jest.mock('../../../lib/channels/firefox', () => ({ __esModule: true, default: { send: jest.fn(), - requestSignedInUser: jest.fn().mockResolvedValue({ - uid: 'sync-uid', - email: 'sync@example.com', - sessionToken: 'token', - verified: true, + requestFxAStatus: jest.fn().mockResolvedValue({ + signedInUser: { + uid: 'sync-uid', + email: 'sync@example.com', + sessionToken: 'token', + verified: true, + }, + capabilities: { pairing: true }, }), fxaOAuthFlowBegin: jest.fn().mockResolvedValue(null), }, @@ -345,16 +349,34 @@ describe('Pair', () => { }); describe('sync bootstrap on mount', () => { - const requestSignedInUserMock = jest.mocked(firefox.requestSignedInUser); + const requestFxAStatusMock = jest.mocked(firefox.requestFxAStatus); + // v1 capabilities: pairing is supported but not at version 2, so these + // cases stay on the choice screen. + const capabilities = { engines: [], multiService: false, pairing: true }; + // navigateWithQuery carries the current query string along, so match the + // path rather than the whole argument. + // The v2 entry is a hard navigation so the integration factory builds a + // PairingAuthorityIntegration from the authority URL. + let hardNavigateSpy: jest.SpyInstance; + beforeEach(() => { + hardNavigateSpy = jest + .spyOn(ReactUtils, 'hardNavigate') + .mockImplementation(() => {}); + }); + afterEach(() => { + hardNavigateSpy.mockRestore(); + }); + + const navigatedToScanQr = () => + hardNavigateSpy.mock.calls.some( + ([to]: [string]) => + typeof to === 'string' && to.startsWith('/pair/authority/scan_qr') + ); const fxaOAuthFlowBeginMock = jest.mocked(firefox.fxaOAuthFlowBegin); it('renders the choice screen when Firefox has a verified Sync user', async () => { await renderPair(); - expect(requestSignedInUserMock).toHaveBeenCalledWith( - 'oauth', - true, - 'sync' - ); + expect(requestFxAStatusMock).toHaveBeenCalledWith('oauth', true, 'sync'); expect(fxaOAuthFlowBeginMock).not.toHaveBeenCalled(); }); @@ -381,7 +403,9 @@ describe('Pair', () => { ])( 'starts an OAuth flow when fxa_status returns %s on every attempt', async (_, response) => { - requestSignedInUserMock.mockResolvedValue(response); + requestFxAStatusMock.mockResolvedValue( + response ? { signedInUser: response, capabilities } : undefined + ); renderWithRouter(); await waitFor(() => expect(fxaOAuthFlowBeginMock).toHaveBeenCalledWith([ @@ -392,25 +416,75 @@ describe('Pair', () => { } ); - it('retries fxa_status once when the first reply is empty, then reveals on success', async () => { - requestSignedInUserMock - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ + // /pair is the other authority entry point - Settings "Connect a device" + // and the post-signin handoffs land here - so it must negotiate v2 the same + // way ConnectAnotherDevice does instead of always showing the v1 choice. + it('navigates to the v2 pairing flow when FxA and the browser both support version 2', async () => { + requestFxAStatusMock.mockResolvedValue({ + signedInUser: { + uid: 'sync-uid', + email: 'sync@example.com', + sessionToken: 'token', + verified: true, + }, + capabilities: { ...capabilities, pairingVersion: 2 }, + }); + renderWithRouter(, {}, mockPairingAppContext(2)); + await waitFor(() => expect(navigatedToScanQr()).toBe(true)); + }); + + it('stays on the v1 choice screen when FxA is on version 2 but the browser is not', async () => { + requestFxAStatusMock.mockResolvedValue({ + signedInUser: { uid: 'sync-uid', email: 'sync@example.com', sessionToken: 'token', verified: true, + }, + capabilities: { ...capabilities, pairingVersion: 1 }, + }); + renderWithRouter(, {}, mockPairingAppContext(2)); + await screen.findByLabelText(/I already have Firefox for mobile/); + expect(navigatedToScanQr()).toBe(false); + }); + + it('stays on the v1 choice screen when the browser is on version 2 but FxA is not', async () => { + requestFxAStatusMock.mockResolvedValue({ + signedInUser: { + uid: 'sync-uid', + email: 'sync@example.com', + sessionToken: 'token', + verified: true, + }, + capabilities: { ...capabilities, pairingVersion: 2 }, + }); + renderWithRouter(, {}, mockPairingAppContext(1)); + await screen.findByLabelText(/I already have Firefox for mobile/); + expect(navigatedToScanQr()).toBe(false); + }); + + it('retries fxa_status once when the first reply is empty, then reveals on success', async () => { + requestFxAStatusMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ + signedInUser: { + uid: 'sync-uid', + email: 'sync@example.com', + sessionToken: 'token', + verified: true, + }, + capabilities, }); await renderPair(); - expect(requestSignedInUserMock).toHaveBeenCalledTimes(2); + expect(requestFxAStatusMock).toHaveBeenCalledTimes(2); expect(fxaOAuthFlowBeginMock).not.toHaveBeenCalled(); }); it('caps fxa_status at two asks before falling through to OAuth', async () => { - requestSignedInUserMock.mockResolvedValue(undefined); + requestFxAStatusMock.mockResolvedValue(undefined); renderWithRouter(); await waitFor(() => expect(fxaOAuthFlowBeginMock).toHaveBeenCalled()); - expect(requestSignedInUserMock).toHaveBeenCalledTimes(2); + expect(requestFxAStatusMock).toHaveBeenCalledTimes(2); }); it('hard-navigates to / with the OAuth params Firefox returned', async () => { @@ -418,7 +492,7 @@ describe('Pair', () => { .spyOn(ReactUtils, 'hardNavigate') .mockImplementation(() => {}); try { - requestSignedInUserMock.mockResolvedValue(undefined); + requestFxAStatusMock.mockResolvedValue(undefined); fxaOAuthFlowBeginMock.mockResolvedValueOnce({ action: 'signin', response_type: 'code', @@ -452,7 +526,7 @@ describe('Pair', () => { }); it('reveals the choice screen when WebChannel never replies', async () => { - requestSignedInUserMock.mockResolvedValueOnce(undefined); + requestFxAStatusMock.mockResolvedValueOnce(undefined); fxaOAuthFlowBeginMock.mockResolvedValueOnce(null); await renderPair(); expect( @@ -461,7 +535,7 @@ describe('Pair', () => { }); it('reveals the choice screen when fxa_status throws and OAuth never replies', async () => { - requestSignedInUserMock.mockRejectedValueOnce(new Error('boom')); + requestFxAStatusMock.mockRejectedValueOnce(new Error('boom')); fxaOAuthFlowBeginMock.mockResolvedValueOnce(null); await renderPair(); expect( diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.tsx index 57d652ee0f2..efaf622b152 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.tsx @@ -14,7 +14,7 @@ import { Link, useLocation } from 'react-router'; import { useNavigateWithQuery } from '../../../lib/hooks'; import { FtlMsg } from 'fxa-react/lib/utils'; import { usePageViewEvent } from '../../../lib/metrics'; -import { useFtlMsgResolver } from '../../../models'; +import { useConfig, useFtlMsgResolver } from '../../../models'; import { useCmsInfoState } from '../../../models/hooks'; import { RelierCmsInfo } from '../../../models/integrations'; import AppLayout from '../../../components/AppLayout'; @@ -84,6 +84,7 @@ const Pair = ({ error, cmsInfo: cmsInfoProp, integration }: PairProps) => { ); const navigateWithQuery = useNavigateWithQuery(); const location = useLocation(); + const config = useConfig(); // CMS theming — mirrors the Backbone pair/index.js fetchCmsConfig() flow. // Strict parity with Backbone: only Pair/Index is themed; the rest of @@ -100,6 +101,18 @@ const Pair = ({ error, cmsInfo: cmsInfoProp, integration }: PairProps) => { const choiceHeaderRef = useRef(null); const downloadHeaderRef = useRef(null); + // v2 supplicant entry: a native-camera scan opens /pair#...&v=2. Forward into + // the v2 supplicant flow, preserving the channel fragment. (FXA-13865) + const locationHash = location.hash ?? ''; + const isV2SupplicantEntry = + new URLSearchParams(locationHash.replace(/^#/, '')).get('v') === '2'; + + useEffect(() => { + if (isV2SupplicantEntry) { + navigateWithQuery(`/pair/supplicant/approve_signin${locationHash}`); + } + }, [isV2SupplicantEntry, locationHash, navigateWithQuery]); + // Focus management after view transitions useEffect(() => { if (currentView === 'download') { @@ -108,6 +121,12 @@ const Pair = ({ error, cmsInfo: cmsInfoProp, integration }: PairProps) => { }, [currentView]); useEffect(() => { + // The v2 entry is handled above. Mobile reaches /pair by design there - a + // native-camera scan - so the desktop-only gate below must not claim it. + if (isV2SupplicantEntry) { + return; + } + const ua = navigator.userAgent; const isFirefoxDesktop = /Firefox/i.test(ua) && !/FxiOS/i.test(ua) && !/Android/i.test(ua); @@ -119,9 +138,12 @@ const Pair = ({ error, cmsInfo: cmsInfoProp, integration }: PairProps) => { let cancelled = false; (async () => { + // fxa_status carries the pairing capabilities alongside the signed-in + // user, so one round trip answers both "are we signed in?" and "can this + // browser pair over v2?". const askFirefox = () => firefox - .requestSignedInUser( + .requestFxAStatus( Constants.OAUTH_CONTEXT, true, Constants.SYNC_SERVICE @@ -130,19 +152,36 @@ const Pair = ({ error, cmsInfo: cmsInfoProp, integration }: PairProps) => { // Retry on empty replies so a slow fxaLogin handoff doesn't bail to /signin. const MAX_RETRIES = 1; - let signedInUser = await askFirefox(); + let fxaStatus = await askFirefox(); for ( let attempt = 0; !cancelled && attempt < MAX_RETRIES && - (!signedInUser?.sessionToken || !signedInUser?.verified); + (!fxaStatus?.signedInUser?.sessionToken || + !fxaStatus?.signedInUser?.verified); attempt++ ) { - signedInUser = await askFirefox(); + fxaStatus = await askFirefox(); } if (cancelled) return; + const signedInUser = fxaStatus?.signedInUser; if (signedInUser?.sessionToken && signedInUser.verified) { + // Same negotiation ConnectAnotherDevice runs. /pair is the other + // authority entry - Settings "Connect a device" and the post-signin + // handoffs land here - so it must not strand a v2-capable pair on the + // v1 choice screen. + const capabilities = fxaStatus?.capabilities; + if ( + config.pairing.version === 2 && + capabilities?.pairing && + capabilities.pairingVersion === 2 + ) { + // Hard navigation so the factory builds the authority integration + // from this URL; see ConnectAnotherDevice for the same reasoning. + hardNavigate(`/pair/authority/scan_qr${window.location.search}`); + return; + } setBootstrapping(false); return; } diff --git a/packages/fxa-settings/src/pages/Pair/Supp/index.tsx b/packages/fxa-settings/src/pages/Pair/Supp/index.tsx index 6b793b32f9e..f96f1f6b369 100644 --- a/packages/fxa-settings/src/pages/Pair/Supp/index.tsx +++ b/packages/fxa-settings/src/pages/Pair/Supp/index.tsx @@ -33,6 +33,16 @@ const Supp = ({ integration, error: errorProp }: SuppProps) => { const [error, setError] = useState(errorProp); useEffect(() => { + // v2 supplicant entry: Firefox mobile opens /pair/supp?...#...&v=2. Forward + // into the v2 supplicant flow before the v1 channel opens, keeping the OAuth + // query and the channel fragment. + if ( + new URLSearchParams(window.location.hash.substring(1)).get('v') === '2' + ) { + navigateWithQuery('/pair/supplicant/approve_signin'); + return; + } + if (!(integration instanceof PairingSupplicantIntegration)) { return; } diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/ApproveSignIn/container.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/ApproveSignIn/container.tsx new file mode 100644 index 00000000000..e0e3beacc5e --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Authority/ApproveSignIn/container.tsx @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useEffect } from 'react'; +import { useAccount } from '../../../../models'; +import { + AuthorityState, + Integration, + PairingAuthorityIntegration, +} from '../../../../models'; +import { RemoteMetadata } from '../../../../lib/types'; +import { navigateWithQuery } from '../../../../lib/utilities'; +import ApproveSignIn from '.'; +import { plog } from '../../../../lib/channels/pairing-flow'; + +/** + * Authority approval container (FXA-13865 authority half). + * + * The channel and the supplicant's OAuth params live on + * PairingAuthorityIntegration, which ScanQR created, so this page reads both + * from the integration rather than owning any channel state itself. + */ +const ApproveSignInContainer = ({ + integration, +}: { + integration: Integration; +}) => { + const account = useAccount(); + const authority = + integration instanceof PairingAuthorityIntegration ? integration : null; + const req = authority?.supplicantOAuth ?? null; + const remoteMetadata: RemoteMetadata = authority?.remoteMetadata ?? { + deviceFamily: 'Mobile', + deviceOS: '', + ipAddress: '', + }; + + useEffect(() => { + // No stored request means the flow is out of sync; go back to the QR + // rather than show an empty approval. + if (!req || !authority) { + plog('auth approve_signin has no request; back to scan_qr'); + navigateWithQuery('/pair/authority/scan_qr'); + return; + } + + // ScanQR's state handler went with it on unmount, so take over routing: a + // supplicant that cancels closes the channel, which fails the flow here. + authority.onStateChange = (state: AuthorityState) => { + if (state === AuthorityState.Failed) { + navigateWithQuery('/pair/authority/timeout_and_cancel'); + } + }; + + // The supplicant blocks on this before showing its confirmation card. + authority + .sendAuthorityMetadata({ + email: account.email, + displayName: account.displayName ?? undefined, + avatar: account.avatar?.url ?? undefined, + }) + .catch(() => { + // FXA-13869 owns the error surface. + }); + }, [req, authority, account]); + + const onApprove = () => { + if (!authority || !req) return; + + (async () => { + try { + // Attach before authorizing, so the completion signal cannot arrive + // before we are listening. + authority.onSuppComplete = () => { + navigateWithQuery('/pair/authority/sync_success'); + }; + + const authorized = await authority.authorizeV2(); + if (!authorized) return; + + // continue_on_mobile waits there until onSuppComplete fires. + navigateWithQuery('/pair/authority/continue_on_mobile'); + } catch { + // FXA-13869 owns the error surface. + } + })(); + }; + + const onChangePassword = () => { + navigateWithQuery('/settings/change_password'); + }; + + return ( + + ); +}; + +export default ApproveSignInContainer; diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/ApproveSignIn/index.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/ApproveSignIn/index.tsx index 0c42345c1d3..f65af2064e9 100644 --- a/packages/fxa-settings/src/pages/Pair2/Authority/ApproveSignIn/index.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Authority/ApproveSignIn/index.tsx @@ -59,6 +59,7 @@ const ApproveSignIn = ({ - - - + + + + + + + + ); }; export default ApproveSignIn; diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx new file mode 100644 index 00000000000..006cf4f08ac --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { pairingFlow, plog } from '../../../../lib/channels/pairing-flow'; +import { navigateWithQuery } from '../../../../lib/utilities'; +import firefox from '../../../../lib/channels/firefox'; +import ConnectThisDevice from '.'; + +// Matches the v1 supplicant hand-off (pairing-supplicant-integration.ts). +const PAIR_OAUTH_REDIRECT = + 'urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel'; + +type AuthAuthorizeDetail = { code?: string; state?: string }; + +/** + * Supplicant connect container (FXA-13867 supplicant half). + * + * Shows who is asking, and on confirm signals `pair:supp:authorize`, waits for + * the authority's `pair:auth:authorize`, validates the returned state against + * the one this device generated, completes the OAuth login via + * `fxaccounts:oauth_login`, and lands on sync success. + */ +const ConnectThisDeviceContainer = () => { + const authorityMetadata = pairingFlow.authorityMetadata; + + const onConnect = () => { + const onAuthorize = (event: Event) => { + const detail = (event as CustomEvent).detail; + plog('supp got pair:auth:authorize; code?', !!detail?.code); + if (!detail?.code || !detail?.state) return; + + // The state must echo what this device generated in pair_oauth_start, or + // the grant is for someone else — abort. + if (detail.state !== pairingFlow.supplicantOAuth?.state) { + plog('supp state mismatch; abort'); + off(); + pairingFlow.reset(); + navigateWithQuery('/pair/supplicant/timeout_and_cancel'); + return; + } + + off(); + plog('supp calling oauth_login'); + firefox.fxaOAuthLogin({ + action: 'pairing', + code: detail.code, + state: detail.state, + redirect: PAIR_OAUTH_REDIRECT, + }); + // Mark done so the post-success channel close is not read as an abort. + pairingFlow.completing = true; + // Tell the authority we finished so its continue_on_mobile can advance to + // success. Await delivery before showing success so the signal is not + // dropped by an early teardown. + pairingFlow + .send('pair:supp:complete') + .catch(() => { + // Best effort; the supplicant is done regardless. + }) + .finally(() => { + navigateWithQuery('/pair/supplicant/sync_success'); + }); + }; + + plog('supp onConnect: sending pair:supp:authorize'); + const off = pairingFlow.on('remote:pair:auth:authorize', onAuthorize); + pairingFlow.send('pair:supp:authorize').catch(() => { + // FXA-13869 owns the error surface. + }); + }; + + const onCancel = () => { + // Closing the channel signals the authority to abort too. + pairingFlow.reset(); + navigateWithQuery('/pair/supplicant/timeout_and_cancel'); + }; + + return ( + + ); +}; + +export default ConnectThisDeviceContainer; diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/index.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/index.tsx index 43523daf464..a456ccde2db 100644 --- a/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/index.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/index.tsx @@ -56,46 +56,51 @@ const ConnectThisDevice = ({ region: 'region-foo', city: 'city-foo', }; - return -
- - + return ( + +
+ - -

- Connect this device to your account? -

-
- {/* Raw user data, so it is deliberately not wrapped in FtlMsg. */} -

{email}

+ - + +

+ Connect this device to your account? +

+
+ {/* Raw user data, so it is deliberately not wrapped in FtlMsg. */} +

{email}

- - - - - - -
-
+ + + + + + + + +
+
+ ); }; export default ConnectThisDevice; diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/SyncSuccess/index.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/SyncSuccess/index.tsx index 6934c72091e..f0821fb25aa 100644 --- a/packages/fxa-settings/src/pages/Pair2/Supplicant/SyncSuccess/index.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/SyncSuccess/index.tsx @@ -30,41 +30,44 @@ const SyncSuccess = ({ onViewSyncedTabs, onSyncSettings, }: SyncSuccessProps) => { - return -
- + // TODO: Wire up props + return ( + +
+ - + - -

Your device is connected

-
- -

- Your bookmarks, tabs, and more will stay synced in Firefox. -

-
+ +

Your device is connected

+
+ +

+ Your bookmarks, tabs, and more will stay synced in Firefox. +

+
- - - - - - -
-
+ + + + + + +
+
+ ); }; export default SyncSuccess; diff --git a/packages/fxa-settings/src/react-app-env.d.ts b/packages/fxa-settings/src/react-app-env.d.ts index 45172d299b0..65c44f41575 100644 --- a/packages/fxa-settings/src/react-app-env.d.ts +++ b/packages/fxa-settings/src/react-app-env.d.ts @@ -82,6 +82,8 @@ declare module '*.module.sass' { declare module 'fxa-pairing-channel/dist/FxAccountsPairingChannel.babel.umd.js' { interface FxaPairingChannelSocket { + readonly channelId: string; + readonly channelKey: Uint8Array; send(data: unknown): Promise; close(): Promise; addEventListener(type: string, listener: EventListener): void; @@ -99,4 +101,6 @@ declare module 'fxa-pairing-channel/dist/FxAccountsPairingChannel.babel.umd.js' create(channelServerUri: string): Promise; }; + + export function bytesToBase64url(bytes: Uint8Array): string; }