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
85 changes: 80 additions & 5 deletions packages/fxa-settings/src/lib/channels/firefox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import {
firefox,
Firefox,
FirefoxCommand,
buildSyncOAuthSearch,
buildOAuthSearch,
FxAOAuthFlowBeginResponse,
WebChannelService,
PairOAuthFinishState,
PairOAuthStartState,
} from './firefox';

// Keep in sync with DEFAULT_SEND_TIMEOUT_LENGTH_MS in firefox.ts (not exported).
const SEND_TIMEOUT_MS = 500;

describe('Firefox pairing WebChannel methods', () => {
let sendSpy: jest.SpyInstance;
const originalRAF = window.requestAnimationFrame;
Expand Down Expand Up @@ -76,7 +80,7 @@ describe('Firefox pairing WebChannel methods', () => {
});
});

describe('buildSyncOAuthSearch', () => {
describe('buildOAuthSearch', () => {
const MOCK_CODE_VERIFIER = 'au3dqDz2dOB0_vSikXCUf4S8Gc-37dL-F7sGxtxpR3R';

// Mirrors a real fxa_oauth_flow_begin response. Firefox derives the challenge
Expand All @@ -99,7 +103,7 @@ describe('buildSyncOAuthSearch', () => {
// would still pass if the allowlist were replaced by a spread. The response
// type has no verifier field, but the payload crosses a trust boundary and
// TypeScript is erased, so a compromised Firefox could put one on the wire.
const search = buildSyncOAuthSearch({
const search = buildOAuthSearch({
...OAUTH_PARAMS,
code_verifier: MOCK_CODE_VERIFIER,
sessionToken: 'deadbeef',
Expand All @@ -122,11 +126,33 @@ describe('buildSyncOAuthSearch', () => {
expect(search.get('code_challenge')).toBe(OAUTH_PARAMS.code_challenge);
expect(search.toString()).not.toContain(MOCK_CODE_VERIFIER);
});

// Precedence: the caller argument, then the browser echo, then sync.
it.each([
{ echoed: undefined, passed: undefined, expected: 'sync' },
{ echoed: undefined, passed: 'relay', expected: 'relay' },
{ echoed: 'vpn', passed: undefined, expected: 'vpn' },
{ echoed: 'vpn', passed: 'sync', expected: 'sync' },
// The echo crosses a trust boundary and the type is erased, so an
// unrecognized name must not reach the sign-in URL.
{
echoed: 'evil' as WebChannelService,
passed: undefined,
expected: 'sync',
},
] as const)(
'sets service=$expected when the browser echoes $echoed and the caller passes $passed',
({ echoed, passed, expected }) => {
const search = buildOAuthSearch(
{ ...OAUTH_PARAMS, service: echoed },
passed
);
expect(search.get('service')).toBe(expected);
}
);
});

describe('Firefox pairing OAuth WebChannel methods', () => {
// Keep in sync with DEFAULT_SEND_TIMEOUT_LENGTH_MS in firefox.ts (not exported).
const SEND_TIMEOUT_MS = 500;
// pairOauthFinish overrides the default; it makes a web call.
const FINISH_TIMEOUT_MS = 10_000;

Expand Down Expand Up @@ -368,3 +394,52 @@ describe('Firefox pairing OAuth WebChannel methods', () => {
});
});
});

describe('fxaOAuthFlowBegin', () => {
const SCOPES = ['profile', Constants.OAUTH_OLDSYNC_SCOPE];

let ff: Firefox;
let sendSpy: jest.SpyInstance;
const originalRAF = window.requestAnimationFrame;

beforeEach(() => {
jest.useFakeTimers();
ff = new Firefox();
sendSpy = jest.spyOn(ff, 'send').mockImplementation(() => {});
window.requestAnimationFrame = (cb: FrameRequestCallback) => {
cb(0);
return 0;
};
});

afterEach(() => {
jest.restoreAllMocks();
window.requestAnimationFrame = originalRAF;
jest.useRealTimers();
});

// Let the request time out so a pending promise cannot leak into the next case.
const settle = async (promise: Promise<unknown>) => {
jest.advanceTimersByTime(SEND_TIMEOUT_MS);
await promise;
};

it.each(['sync', 'relay'] as const)(
'sends service=%s with the scopes',
async (service) => {
const promise = ff.fxaOAuthFlowBegin(SCOPES, service);
expect(sendSpy).toHaveBeenCalledWith(FirefoxCommand.OAuthFlowBegin, {
scopes: SCOPES,
service,
});
await settle(promise);
}
);

it('omits the service when the caller passes none', async () => {
const promise = ff.fxaOAuthFlowBegin(SCOPES);
// toStrictEqual, because toEqual would pass on a `service: undefined` key.
expect(sendSpy.mock.calls[0][1]).toStrictEqual({ scopes: SCOPES });
await settle(promise);
});
});
131 changes: 84 additions & 47 deletions packages/fxa-settings/src/lib/channels/firefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* 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 { Constants } from "../constants";
import { Constants } from '../constants';

export enum FirefoxCommand {
AccountDeleted = 'fxaccounts:delete',
Expand Down Expand Up @@ -96,7 +96,6 @@ export type FxAStatusResponse = {
};
clientId?: string;
signedInUser?: SignedInUser;

};

export type SignedInUser = {
Expand All @@ -109,15 +108,15 @@ export type SignedInUser = {
};

export type PairOAuthStartState = {
state:string,
scope:string,
code_challenge: string,
keys_jwk:string
}
state: string;
scope: string;
code_challenge: string;
keys_jwk: string;
};
export type PairOAuthFinishState = {
state:string,
code:string
}
state: string;
code: string;
};

export type FxALoginRequest = {
email: string;
Expand Down Expand Up @@ -150,6 +149,25 @@ export type WebChannelServices =
vpn: {};
};

// keyof a union yields only the shared keys, so distribute over it first.
type KeysOfUnion<T> = T extends unknown ? keyof T : never;

// Service names the WebChannel messages accept, derived from
// WebChannelServices so the two cannot drift apart.
export type WebChannelService = KeysOfUnion<WebChannelServices>;

// The same names at runtime. The browser echoes a service back over the
// WebChannel, where the type above is erased, so the echo needs a real check.
// The Record makes a missing name a compile error, not a silent rejection.
const WEB_CHANNEL_SERVICES = new Set<string>(
Object.keys({
sync: true,
relay: true,
smartwindow: true,
vpn: true,
} satisfies Record<WebChannelService, true>)
);

// ref: [FxAccounts.sys.mjs](https://searchfox.org/mozilla-central/rev/82828dba9e290914eddd294a0871533875b3a0b5/services/fxaccounts/FxAccounts.sys.mjs#910)
export type FxALoginSignedInUserRequest = FxALoginRequest & {
authAt: number;
Expand Down Expand Up @@ -213,17 +231,23 @@ export type FxAOAuthFlowBeginResponse = {
code_challenge_method?: string;
// Forward to /authorization, otherwise the OAuth code is keyless and Sync never enables.
keys_jwk?: string;
// Optional because the browser does not echo the service back yet.
service?: WebChannelService;
};

// Builds the oauth_webchannel_v1 Sync sign-in URL search params from the
// fxa_oauth_flow_begin response. Callers may set additional params on the
// returned URLSearchParams (e.g. entrypoint, email, utm_*).
export function buildSyncOAuthSearch(
oauthParams: FxAOAuthFlowBeginResponse
// Builds the oauth_webchannel_v1 sign-in URL search params from the
// fxa_oauth_flow_begin response. The service comes from the caller, then the
// browser echo, then sync. Callers may set additional params on the returned
// URLSearchParams (e.g. entrypoint, email, utm_*).
export function buildOAuthSearch(
oauthParams: FxAOAuthFlowBeginResponse,
service?: WebChannelService
): URLSearchParams {
const echoed = oauthParams.service;
const search = new URLSearchParams({
context: 'oauth_webchannel_v1',
service: 'sync',
service:
service ?? (echoed && WEB_CHANNEL_SERVICES.has(echoed) ? echoed : 'sync'),
client_id: oauthParams.client_id,
state: oauthParams.state,
scope: oauthParams.scope,
Expand Down Expand Up @@ -505,7 +529,8 @@ export class Firefox extends EventTarget {

/** Start new OAuth flow in Firefox and get fresh params for recovery. */
async fxaOAuthFlowBegin(
scopes: string[]
scopes: string[],
service?: WebChannelService
): Promise<FxAOAuthFlowBeginResponse | null> {
let timeoutId: number;
return Promise.race<FxAOAuthFlowBeginResponse | null>([
Expand All @@ -519,7 +544,12 @@ export class Firefox extends EventTarget {

this.addEventListener(FirefoxCommand.OAuthFlowBegin, eventHandler);
requestAnimationFrame(() => {
this.send(FirefoxCommand.OAuthFlowBegin, { scopes });
// Omit the key when the caller has no service, rather than send
// undefined.
this.send(FirefoxCommand.OAuthFlowBegin, {
scopes,
...(service ? { service } : {}),
});
});
}),
new Promise<FxAOAuthFlowBeginResponse | null>((resolve) => {
Expand Down Expand Up @@ -644,15 +674,14 @@ export class Firefox extends EventTarget {
}

/** Requests that a pairing oauth operation begin. This is the first half of pairing dance. */
async pairOauthStart(msg:{
scopes?: string[]
}):Promise<PairOAuthStartState|undefined> {

async pairOauthStart(msg: {
scopes?: string[];
}): Promise<PairOAuthStartState | undefined> {
// Default sync scopes
if (msg.scopes == null) {
msg.scopes = [
Constants.OAUTH_OLDSYNC_SCOPE,
Constants.OAUTH_TRUSTED_PROFILE_SCOPE
Constants.OAUTH_TRUSTED_PROFILE_SCOPE,
];
}

Expand All @@ -661,47 +690,58 @@ 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<PairOAuthFinishState|undefined> {
async pairOauthFinish(msg: {
client_id: string;
state: string;
scope: string;
code_challenge: string;
}): Promise<PairOAuthFinishState | undefined> {
return this._executeCommandWithResponse<PairOAuthFinishState>(
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.
)
);
}

/**
Expand All @@ -714,11 +754,11 @@ export class Firefox extends EventTarget {
private async _executeCommandWithResponse<TResp>(
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<undefined | TResp>([
new Promise<undefined | TResp>((resolve, reject) => {
onResp = (event: any) => {
Expand All @@ -735,13 +775,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);
Expand All @@ -755,17 +795,14 @@ 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);
}),
]);
}




/*
* Sends an fxa_status and returns the signed in user if available.
*/
Expand Down
Loading