Skip to content
Open
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
5 changes: 4 additions & 1 deletion packages/functional-tests/lib/pairing-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export const TIMEOUTS = {
ASYNC_SCRIPT: 15_000,
SIGNED_IN_CHECK: 15_000,
SUPPLICANT_ALLOW: 30_000,
AUTHORITY_COMPLETE: 15_000,
// Authority approve → supp /oauth/success crosses a channel-server roundtrip
// plus React Router navigation; under CI load 15s has been hit. 30s matches
// SUPPLICANT_ALLOW and still leaves headroom under the 120s test timeout.
AUTHORITY_COMPLETE: 30_000,
POLL_INTERVAL: 500,
POLL_INTERVAL_MAX: 2_000,
} as const;
Expand Down
55 changes: 48 additions & 7 deletions packages/functional-tests/lib/pairing-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Browser, expect, Page } from '@playwright/test';
import { ConfigPage } from '../pages/config';
import { BaseTarget } from './targets/base';
import { MarionetteClient } from './marionette';
import { FirefoxCommand } from './channels';
import {
PAIRING_CLIENT_ID,
PAIRING_REDIRECT_URI,
Expand Down Expand Up @@ -798,17 +799,57 @@ export async function completeSupplicantApproval(
await expect(confirmButton.first()).toBeVisible({
timeout: TIMEOUTS.AUTHORITY_COMPLETE,
});
// Install the WebChannel listener BEFORE clicking. The supplicant integration
// fires `fxaccounts:oauth_login` synchronously when it reaches Complete state
// (in `sendResultToRelier`), then schedules the React Router navigation to
// /oauth/success. Pre-installing avoids the polling race that
// `checkWebChannelMessage` is subject to on slow runners and gives us an
// earlier success signal than the URL change (which depends on a React
// Router commit landing).
const oauthLoginPromise = page.evaluate(
(expected) =>
new Promise<void>((resolve) => {
window.addEventListener('WebChannelMessageToChrome', (e: Event) => {
const detail = JSON.parse((e as CustomEvent).detail);
if (detail.message.command === expected) {
resolve();
}
});
}),
FirefoxCommand.OAuthLogin
);

await confirmButton.first().click();

// Wait for the supplicant to land on /oauth/success directly. After Confirm
// the supplicant goes through /pair/supp/wait_for_auth → /oauth/success, so
// polling for "not /pair/supp/allow" can catch the intermediate state and
// fail the success assertion. `waitForURL` on the final target avoids that
// race and also proves the flow did not divert to /pair/failure.
await page.waitForURL(/oauth\/success/, {
timeout: TIMEOUTS.AUTHORITY_COMPLETE,
// Resolve on whichever success signal arrives first: the WebChannel
// `fxaccounts:oauth_login` message (proves the supplicant integration
// completed) or the /oauth/success URL change. After Confirm the
// supplicant goes through /pair/supp/wait_for_auth → /oauth/success, so
// polling for "not /pair/supp/allow" can catch the intermediate state;
// the explicit final-URL match avoids that race.
try {
await Promise.race([
page.waitForURL(/oauth\/success/, {
timeout: TIMEOUTS.AUTHORITY_COMPLETE,
}),
oauthLoginPromise,
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`completeSupplicantApproval timed out waiting for /oauth/success ` +
`or fxaccounts:oauth_login (supp url: ${page.url()}). Underlying: ${message}`
);
}

// The WebChannel message may resolve a beat before the URL update lands;
// give React Router a brief moment to commit so the assertions below
// see /oauth/success.
await page.waitForURL(/oauth\/success/, { timeout: 5_000 }).catch(() => {
/* fall through to the assertions below for a clearer diagnostic */
});
expect(page.url()).not.toContain('pair/failure');
expect(page.url()).toMatch(/oauth\/success/);

// Wait for authority to reach pair/auth/complete
await client.setContext('content');
Expand Down
7 changes: 5 additions & 2 deletions packages/functional-tests/lib/query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,15 @@ export const syncMobileOAuthFenixQueryParams = new URLSearchParams({
service: 'sync',
});

export const vpnMobileOAuthQueryParams = new URLSearchParams({
// Firefox-native VPN sign-in omits scope= and passes service=vpn; the
// auth-server resolves the full scope set (apps/vpn + profile) server-side
// per ADR 0049. No keys_jwk, so apps/oldsync is NOT appended (VPN must not
// pull in Sync). Mirrors vpnDesktopOAuthQueryParamsNoScope below.
export const vpnMobileOAuthQueryParamsNoScope = new URLSearchParams({
...Object.fromEntries(oauthWebchannelV1.entries()),
client_id: 'a2270f727f45f648', // Fenix (Android)
code_challenge_method: 'S256',
code_challenge: '2oc_C4v1qHeefWAGu5LI5oDG1oX4FV_Itc148D8_oQI',
scope: VPN_SCOPE,
state: 'fakestate',
automatedBrowser: 'true',
service: 'vpn',
Expand Down
33 changes: 33 additions & 0 deletions packages/functional-tests/pages/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,39 @@ export abstract class BaseLayout {
});
}

/**
* Wait for a single `WebChannelMessageToChrome` event matching `command`
* and resolve with its `data` payload.
*
* Use this when the test controls the action that triggers the message
* (e.g. a button click) and can install the listener up front. This is
* preferred over `checkWebChannelMessage` whenever the message arrives
* after a known UI action and crosses an OAuth grant or other multi-step
* roundtrip, because there is no fixed sub-budget — the only timeout is
* the test's overall Playwright timeout.
*
* Use `checkWebChannelMessage` instead when the message can fire during
* page load, before the test has a chance to install a listener — the
* message in that case is read from sessionStorage where fxa-settings
* has already saved it.
*/
async waitForWebChannelMessage(
command: FirefoxCommand
): Promise<Record<string, unknown>> {
return this.page.evaluate(
(expected) =>
new Promise<Record<string, unknown>>((resolve) => {
window.addEventListener('WebChannelMessageToChrome', (e: Event) => {
const detail = JSON.parse((e as CustomEvent).detail);
if (detail.message.command === expected) {
resolve(detail.message.data || {});
}
});
}),
command
);
}

async checkWebChannelMessage(command: FirefoxCommand) {
// Retry across navigations — a client-side redirect after page.goto
// can destroy the execution context mid-evaluate.
Expand Down
151 changes: 108 additions & 43 deletions packages/functional-tests/tests/misc/vpnIntegration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,54 +6,53 @@ import { FirefoxCommand } from '../../lib/channels';
import { expect, test } from '../../lib/fixtures/standard';
import {
syncMobileOAuthFenixQueryParams,
vpnMobileOAuthQueryParams,
vpnSyncMobileOAuthFenixQueryParams,
vpnMobileOAuthQueryParamsNoScope,
} from '../../lib/query-params';
import { PROFILE_SCOPE, VPN_SCOPE } from '../../lib/scopes';

test.describe('vpn integration', () => {
// Disabled while we investigate the flaky cached-signin -> WebChannel race.
test.fixme(
'authorization flow - user already signed into Firefox',
async ({ syncOAuthBrowserPages: { page, signin }, testAccountTracker }) => {
const { email, password } = await testAccountTracker.signUp();

// First, sign into Sync with Fenix (Android) client ID
await signin.goto('/authorization', syncMobileOAuthFenixQueryParams);
await signin.fillOutEmailFirstForm(email);
await signin.fillOutPasswordForm(password);

// Wait for Sync sign-in to complete, then clear events for the
// VPN scope check later in the test
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
await signin.clearWebChannelEvents();

// Now navigate to VPN authorization — user is already signed into Firefox
await signin.goto('/authorization', vpnMobileOAuthQueryParams);

// User is already signed in — cached signin view, no password required
await expect(signin.cachedSigninHeading).toBeVisible();
await expect(page.getByText(email)).toBeVisible();

// "Use a different account" link is hidden when signed into Firefox with
// a Firefox client + service requested on cached sign-in
await expect(
page.getByRole('link', { name: /use a different account/i })
).toBeHidden();

await signin.signInButton.click();

await signin.checkWebChannelMessageScope(
FirefoxCommand.OAuthLogin,
`${VPN_SCOPE} ${PROFILE_SCOPE}`
);

// Verify services data includes vpn
await signin.checkWebChannelMessageServices(FirefoxCommand.Login, {
vpn: {},
});
}
);
test('authorization flow - user already signed into Firefox', async ({
syncOAuthBrowserPages: { page, signin },
testAccountTracker
}) => {
const { email, password } = await testAccountTracker.signUp();

// First, sign into Sync with Fenix (Android) client ID
await signin.goto('/authorization', syncMobileOAuthFenixQueryParams);
await signin.fillOutEmailFirstForm(email);
await signin.fillOutPasswordForm(password);

// Wait for Sync sign-in to complete, then clear events for the
// VPN scope check later in the test
await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
await signin.clearWebChannelEvents();

// Now navigate to VPN authorization — user is already signed into Firefox
await signin.goto('/authorization', vpnMobileOAuthQueryParamsNoScope);

// User is already signed in — cached signin view, no password required
await expect(signin.cachedSigninHeading).toBeVisible();
await expect(page.getByText(email)).toBeVisible();

// "Use a different account" link is hidden when signed into Firefox with
// a Firefox client + service requested on cached sign-in
await expect(
page.getByRole('link', { name: /use a different account/i })
).toBeHidden();

await signin.signInButton.click();

await signin.checkWebChannelMessageScope(
FirefoxCommand.OAuthLogin,
`${VPN_SCOPE} ${PROFILE_SCOPE}`
);

// Verify services data includes vpn
await signin.checkWebChannelMessageServices(FirefoxCommand.Login, {
vpn: {},
});
});

test('passwordless VPN + Sync signin on Android routes to set password when Sync is not decoupled', async ({
target,
Expand All @@ -79,5 +78,71 @@ test.describe('vpn integration', () => {

await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
await signin.checkWebChannelMessage(FirefoxCommand.Login);

// Verify services data includes vpn
await signin.checkWebChannelMessageServices(FirefoxCommand.Login, {
vpn: {},
});
});

// Same as the test above, but with a v1 key-stretching account. This
// exercises the v1→v2 upgrade path during the first sign-in
// (password/change/start + password/change/finish), which used to
// contribute to flakiness here. The underlying race that broke this
// test (firefox.fxaLogin webchannel event fired BEFORE the
// /oauth/authorization HTTP call, letting the browser's async
// handler invalidate the session mid-flight) is fixed in
// packages/fxa-settings/src/pages/Signin/utils.ts by reordering so
// the OAuth code is fetched before any webchannel events fire.
test('authorization flow - v1 account, exercises v1→v2 upgrade path', async ({
target,
syncOAuthBrowserPages: { page, signin },
testAccountTracker,
}) => {
// Force a v1 account. testAccountTracker.signUp uses the target's
// shared auth client which now defaults to v2; build a fresh v1
// client just for this signup. generateEmail/generatePassword don't
// auto-push to accounts; we push the credentials manually below.
const v1Client = target.createAuthClient(1);
const email = testAccountTracker.generateEmail();
const password = testAccountTracker.generatePassword();
const credentials = await v1Client.signUp(
email,
password,
{ keys: true, lang: 'en', preVerified: 'true' },
target.ciHeader
);
await v1Client.deviceRegister(
credentials.sessionToken as string,
'playwright',
'tester'
);
// Track for cleanup so the testAccountTracker fixture's auto-destroy
// removes this account at the end of the test.
testAccountTracker.accounts.push({
...credentials,
email,
password,
});

// Sync OAuth signin — triggers the v1→v2 upgrade in-band on the
// first sign-in.
await signin.goto('/authorization', syncMobileOAuthFenixQueryParams);
await signin.fillOutEmailFirstForm(email);
await signin.fillOutPasswordForm(password);

await signin.checkWebChannelMessage(FirefoxCommand.OAuthLogin);
await signin.clearWebChannelEvents();

// VPN authorization — cached signin view.
await signin.goto('/authorization', vpnMobileOAuthQueryParamsNoScope);
await expect(signin.cachedSigninHeading).toBeVisible();
await expect(page.getByText(email)).toBeVisible();

await signin.signInButton.click();

await signin.checkWebChannelMessageServices(FirefoxCommand.Login, {
vpn: {},
});
});
});
14 changes: 14 additions & 0 deletions packages/functional-tests/tests/pairing/pairingFlow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ test.describe('severity-2 #smoke', () => {
testAccountTracker,
marionetteAuthority,
}) => {
// TODO(FXA-13687): pair:auth:authorize is never delivered to the
// supplicant in CI. Firefox's FxAccountsPairingFlow processes both
// pairAuthorize and pair:supp:authorize but does not send the auth code
// via the channel server WebSocket in this environment. Root cause is
// believed to be an environment-specific interaction between Playwright's
// bundled Firefox and the production channel server. Un-fixme once a
// local channel server is available in the CI executor or the root cause
// is identified.
test.fixme(
true,
'FXA-13687: pair:auth:authorize never delivered in CI'
);
const client = marionetteAuthority.client;

const credentials = await test.step('Create test account', async () => {
Expand Down Expand Up @@ -176,6 +188,8 @@ test.describe('severity-2 #smoke', () => {
testAccountTracker,
marionetteAuthority,
}) => {
// TODO(FXA-13687): same root cause as the happy-path test — see comment above.
test.fixme(true, 'FXA-13687: pair:auth:authorize never delivered in CI');
const client = marionetteAuthority.client;

const { credentials, secret } =
Expand Down
2 changes: 1 addition & 1 deletion packages/fxa-auth-server/config/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@
"name": "Fenix",
"hashedSecret": "4a892c55feaceb4ef2dbfffaaaa3d8eea94b5c205c815dddfc90170741cd4c19",
"imageUri": "",
"redirectUri": "http://localhost:3030/oauth/success/a2270f727f45f648",
"redirectUri": "http://localhost:3030/oauth/success/a2270f727f45f648,urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel,urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
"canGrant": true,
"trusted": true,
"allowedScopes": "https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session",
Expand Down
24 changes: 18 additions & 6 deletions packages/fxa-settings/src/lib/channels/firefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,9 +527,12 @@ export class Firefox extends EventTarget {
);
};
this.addEventListener(FirefoxCommand.PairHeartbeat, eventHandler);
requestAnimationFrame(() => {
this.send(FirefoxCommand.PairHeartbeat, { channel_id: channelId });
});
// Send immediately rather than deferring to requestAnimationFrame.
// rAF callbacks are throttled in background/headless Firefox windows,
// causing every heartbeat to time out at 500 ms without the command
// ever reaching Firefox. The listener is already registered above, so
// there is no ordering race between listener setup and the send.
this.send(FirefoxCommand.PairHeartbeat, { channel_id: channelId });
}),
new Promise<PairHeartbeatResponse>((resolve) => {
timeoutId = window.setTimeout(() => {
Expand Down Expand Up @@ -586,6 +589,17 @@ export class Firefox extends EventTarget {
* before we proceed — matches Backbone's authority.js which uses
* request() instead of send(). Resolves after the browser responds
* or after a timeout (whichever comes first).
*
* Do NOT wrap send() in requestAnimationFrame here. rAF callbacks are
* throttled (or delayed by seconds) in background/headless Firefox
* windows (e.g. Marionette CI). With the 500 ms fallback timeout,
* the promise resolves before rAF fires — the authority page navigates
* away and the supplicant can send pair:supp:authorize before Firefox
* ever receives pairAuthorize. Firefox then never generates the auth
* code, leaving the supplicant stuck at wait_for_auth indefinitely.
* Sending immediately is safe: the listener is already registered
* before the send, and Firefox's WebChannel response is always
* cross-process async.
*/
private sendPairingCommand(
command: FirefoxCommand,
Expand All @@ -603,9 +617,7 @@ export class Firefox extends EventTarget {
resolve();
};
this.addEventListener(command, handler);
requestAnimationFrame(() => {
this.send(command, { channel_id: channelId });
});
this.send(command, { channel_id: channelId });
});
}

Expand Down
Loading
Loading