/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const { FxAccountsPairingFlow } = ChromeUtils.importESModule( "resource://gre/modules/FxAccountsPairing.sys.mjs" ); const { EventEmitter } = ChromeUtils.importESModule( "resource://gre/modules/EventEmitter.sys.mjs" ); ChromeUtils.defineESModuleGetters(this, { jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs", }); const CHANNEL_ID = "sW-UA97Q6Dljqen7XRlYPw"; const CHANNEL_KEY = crypto.getRandomValues(new Uint8Array(32)); const SENDER_SUPP = { ua: "Firefox Supp", city: "Nice", region: "PACA", country: "France", remote: "127.0.0.1", }; const UID = "abcd"; const EMAIL = "foo@bar.com"; const AVATAR = "https://foo.bar/avatar"; const DISPLAY_NAME = "Foo bar"; const DEVICE_NAME = "Foo's computer"; const PAIR_URI = "https://foo.bar/pair"; const OAUTH_URI = "https://foo.bar/oauth"; const KSYNC = "myksync"; const SESSION = "mysession"; const fxaConfig = { promisePairingURI() { return PAIR_URI; }, promiseOAuthURI() { return OAUTH_URI; }, }; const fxAccounts = { getSignedInUser() { return { uid: UID, email: EMAIL, avatar: AVATAR, displayName: DISPLAY_NAME, }; }, async _withVerifiedAccountState(cb) { return cb({ async getUserAccountData() { return { sessionToken: SESSION, }; }, }); }, _internal: { keys: { getKeyForScope() { return { kid: "123456", k: KSYNC, kty: "oct", }; }, }, fxAccountsClient: { async getScopedKeyData() { return { "https://identity.mozilla.com/apps/oldsync": { identifier: "https://identity.mozilla.com/apps/oldsync", keyRotationTimestamp: 12345678, }, }; }, async oauthAuthorize() { return { code: "mycode", state: "mystate" }; }, }, }, }; const weave = { Service: { clientsEngine: { localName: DEVICE_NAME } }, }; class MockPairingChannel extends EventTarget { get channelId() { return CHANNEL_ID; } get channelKey() { return CHANNEL_KEY; } send(data) { this.dispatchEvent( new CustomEvent("send", { detail: { data }, }) ); } simulateIncoming(data) { this.dispatchEvent( new CustomEvent("message", { detail: { data, sender: SENDER_SUPP }, }) ); } close() { this.closed = true; } } add_task(async function testFullFlow() { const emitter = new EventEmitter(); const pairingChannel = new MockPairingChannel(); const pairingUri = await FxAccountsPairingFlow.start({ emitter, pairingChannel, fxAccounts, fxaConfig, weave, }); Assert.equal( pairingUri, `${PAIR_URI}#channel_id=${CHANNEL_ID}&channel_key=${ChromeUtils.base64URLEncode( CHANNEL_KEY, { pad: false } )}` ); const flow = FxAccountsPairingFlow.get(CHANNEL_ID); const promiseSwitchToWebContent = emitter.once("view:SwitchToWebContent"); const promiseMetadataSent = promiseOutgoingMessage(pairingChannel); const epk = await generateEphemeralKeypair(); pairingChannel.simulateIncoming({ message: "pair:supp:request", data: { client_id: "client_id_1", state: "mystate", keys_jwk: ChromeUtils.base64URLEncode( new TextEncoder().encode(JSON.stringify(epk.publicJWK)), { pad: false } ), scope: "profile https://identity.mozilla.com/apps/oldsync", code_challenge: "chal", code_challenge_method: "S256", }, }); const sentAuthMetadata = await promiseMetadataSent; Assert.deepEqual(sentAuthMetadata, { message: "pair:auth:metadata", data: { email: EMAIL, avatar: AVATAR, displayName: DISPLAY_NAME, deviceName: DEVICE_NAME, }, }); const oauthUrl = await promiseSwitchToWebContent; Assert.equal( oauthUrl, `${OAUTH_URI}?client_id=client_id_1&scope=profile+https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Foldsync&email=foo%40bar.com&uid=abcd&channel_id=${CHANNEL_ID}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob%3Apair-auth-webchannel` ); let pairSuppMetadata = await simulateIncomingWebChannel( flow, "fxaccounts:pair_supplicant_metadata" ); Assert.deepEqual( { ua: "Firefox Supp", city: "Nice", region: "PACA", country: "France", ipAddress: "127.0.0.1", }, pairSuppMetadata ); const generateJWE = sinon.spy(jwcrypto, "generateJWE"); const oauthAuthorize = sinon.spy( fxAccounts._internal.fxAccountsClient, "oauthAuthorize" ); const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel); await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize"); // We should have generated the expected JWE. Assert.ok(generateJWE.calledOnce); const generateArgs = generateJWE.firstCall.args; Assert.deepEqual(generateArgs[0], epk.publicJWK); Assert.deepEqual(JSON.parse(new TextDecoder().decode(generateArgs[1])), { "https://identity.mozilla.com/apps/oldsync": { kid: "123456", k: KSYNC, kty: "oct", }, }); // We should have authorized an oauth code with expected parameters. Assert.ok(oauthAuthorize.calledOnce); const oauthCodeArgs = oauthAuthorize.firstCall.args[1]; console.log(oauthCodeArgs); Assert.ok(!oauthCodeArgs.keys_jwk); Assert.deepEqual( oauthCodeArgs.keys_jwe, await generateJWE.firstCall.returnValue ); Assert.equal(oauthCodeArgs.client_id, "client_id_1"); Assert.equal(oauthCodeArgs.access_type, "offline"); Assert.equal(oauthCodeArgs.state, "mystate"); Assert.equal( oauthCodeArgs.scope, "profile https://identity.mozilla.com/apps/oldsync" ); Assert.equal(oauthCodeArgs.code_challenge, "chal"); Assert.equal(oauthCodeArgs.code_challenge_method, "S256"); const oAuthParams = await promiseOAuthParamsMsg; Assert.deepEqual(oAuthParams, { message: "pair:auth:authorize", data: { code: "mycode", state: "mystate" }, }); let heartbeat = await simulateIncomingWebChannel( flow, "fxaccounts:pair_heartbeat" ); Assert.ok(!heartbeat.suppAuthorized); await pairingChannel.simulateIncoming({ message: "pair:supp:authorize", }); heartbeat = await simulateIncomingWebChannel( flow, "fxaccounts:pair_heartbeat" ); Assert.ok(heartbeat.suppAuthorized); await simulateIncomingWebChannel(flow, "fxaccounts:pair_complete"); // The flow should have been destroyed! Assert.ok(!FxAccountsPairingFlow.get(CHANNEL_ID)); Assert.ok(pairingChannel.closed); generateJWE.restore(); oauthAuthorize.restore(); }); add_task(async function testUnknownPairingMessage() { const emitter = new EventEmitter(); const pairingChannel = new MockPairingChannel(); await FxAccountsPairingFlow.start({ emitter, pairingChannel, fxAccounts, fxaConfig, weave, }); const flow = FxAccountsPairingFlow.get(CHANNEL_ID); const viewErrorObserved = emitter.once("view:Error"); pairingChannel.simulateIncoming({ message: "pair:boom", }); await viewErrorObserved; let heartbeat = await simulateIncomingWebChannel( flow, "fxaccounts:pair_heartbeat" ); Assert.ok(heartbeat.err); }); add_task(async function testUnknownWebChannelCommand() { const emitter = new EventEmitter(); const pairingChannel = new MockPairingChannel(); await FxAccountsPairingFlow.start({ emitter, pairingChannel, fxAccounts, fxaConfig, weave, }); const flow = FxAccountsPairingFlow.get(CHANNEL_ID); const viewErrorObserved = emitter.once("view:Error"); await simulateIncomingWebChannel(flow, "fxaccounts:boom"); await viewErrorObserved; let heartbeat = await simulateIncomingWebChannel( flow, "fxaccounts:pair_heartbeat" ); Assert.ok(heartbeat.err); }); add_task(async function testPairingChannelFailure() { const emitter = new EventEmitter(); const pairingChannel = new MockPairingChannel(); await FxAccountsPairingFlow.start({ emitter, pairingChannel, fxAccounts, fxaConfig, weave, }); const flow = FxAccountsPairingFlow.get(CHANNEL_ID); const viewErrorObserved = emitter.once("view:Error"); sinon.stub(pairingChannel, "send").callsFake(() => { throw new Error("Boom!"); }); pairingChannel.simulateIncoming({ message: "pair:supp:request", data: { client_id: "client_id_1", state: "mystate", scope: "profile https://identity.mozilla.com/apps/oldsync", code_challenge: "chal", code_challenge_method: "S256", }, }); await viewErrorObserved; let heartbeat = await simulateIncomingWebChannel( flow, "fxaccounts:pair_heartbeat" ); Assert.ok(heartbeat.err); }); add_task(async function testFlowTimeout() { const emitter = new EventEmitter(); const pairingChannel = new MockPairingChannel(); const viewErrorObserved = emitter.once("view:Error"); await FxAccountsPairingFlow.start({ emitter, pairingChannel, fxAccounts, fxaConfig, weave, flowTimeout: 1, }); const flow = FxAccountsPairingFlow.get(CHANNEL_ID); await viewErrorObserved; let heartbeat = await simulateIncomingWebChannel( flow, "fxaccounts:pair_heartbeat" ); Assert.ok(heartbeat.err.match(/Timeout/)); }); async function simulateIncomingWebChannel(flow, command) { return flow.onWebChannelMessage(command); } async function promiseOutgoingMessage(pairingChannel) { return new Promise(res => { const onMessage = event => { pairingChannel.removeEventListener("send", onMessage); res(event.detail.data); }; pairingChannel.addEventListener("send", onMessage); }); } async function generateEphemeralKeypair() { const keypair = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"] ); const publicJWK = await crypto.subtle.exportKey("jwk", keypair.publicKey); const privateJWK = await crypto.subtle.exportKey("jwk", keypair.privateKey); delete publicJWK.key_ops; return { publicJWK, privateJWK, }; }