Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/functional-tests/lib/android-supplicant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,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<void> {
async confirmPairing(
timeoutMs = 45_000,
// v1 uses "Confirm"/"Confirm pairing"; the v2 supplicant card uses "Connect".
re = /^Confirm pairing$|^Confirm$/i
): Promise<void> {
// 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) {
Expand Down
21 changes: 19 additions & 2 deletions packages/functional-tests/lib/marionette-firefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions packages/functional-tests/lib/pairing-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,32 @@ 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://<host>/pair#channel_id=<id>&channel_key=<key>&v=2
// i.e. the v1 fragment plus the v2 marker.
export const PAIR_V2_URL_MARKER = 'v=2';

export const SELECTORS = {
EMAIL_INPUT: [
'input[type="email"]',
Expand Down
107 changes: 107 additions & 0 deletions packages/functional-tests/lib/pairing-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,113 @@ 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=<id>&channel_key=<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 reads the encoded URL back from the page.
*
* CONTRACT (FXA-13868): scan_qr must expose the encoded pairing URL to tests via
* `data-testid="pairing-qr"` with a `data-pairing-url` attribute. The QR value
* itself is not otherwise present in the DOM as text. Update the selector here if
* 13868 exposes it differently.
*
* 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<string> {
await client.setContext('content');
await client.navigate(
`${contentServerUrl}/connect_another_device?${eligibleEntrypointQs}&v=2`
);
await waitForUrlContaining(client, '/pair/authority/scan_qr');

const pairUrl = await pollUntil(
async () => {
const url = await client.executeScript(
`const el = document.querySelector('[data-testid="pairing-qr"]');
return el ? el.getAttribute('data-pairing-url') : null;`,
{ sandbox: 'system' }
);
return typeof url === 'string' && url.includes('channel_id=')
? url
: undefined;
},
TIMEOUTS.ASYNC_SCRIPT,
'scan_qr did not expose a v2 pairing URL (needs FXA-13868 data-testid="pairing-qr")'
);

return pairUrl as string;
}

/**
* 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.
Expand Down
165 changes: 165 additions & 0 deletions packages/functional-tests/lib/pairing-supplicant-harness.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Record<string, { kid: string; k: string; kty: string }>> {
const headers: Record<string, string> = {
'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));
}
Loading