fune/services/fxaccounts/tests/xpcshell/test_pairing.js
Edouard Oger 569d0cc5a7 Bug 1490671 - Add FxA device pairing. r=markh,rfkelly,vladikoff,flod
Differential Revision: https://phabricator.services.mozilla.com/D6966

--HG--
extra : moz-landing-system : lando
2019-02-19 19:20:12 +00:00

244 lines
8.7 KiB
JavaScript

/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const {FxAccountsPairingFlow} = ChromeUtils.import("resource://gre/modules/FxAccountsPairing.jsm", {});
const {EventEmitter} = ChromeUtils.import("resource://gre/modules/EventEmitter.jsm", {});
const {PromiseUtils} = ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm", {});
const {CryptoUtils} = ChromeUtils.import("resource://services-crypto/utils.js", {});
XPCOMUtils.defineLazyModuleGetters(this, {
jwcrypto: "resource://services-crypto/jwcrypto.jsm",
});
XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "crypto"]);
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 fxaConfig = {
promisePairingURI() { return PAIR_URI; },
promiseOAuthURI() { return OAUTH_URI; },
};
const fxAccounts = {
getScopedKeys(scope) {
return {
[scope]: {
kid: "123456",
k: KSYNC,
kty: "oct",
},
};
},
authorizeOAuthCode() {
return {code: "mycode", state: "mystate"};
},
getSignedInUserProfile() {
return {
uid: UID,
email: EMAIL,
avatar: AVATAR,
displayName: DISPLAY_NAME,
};
},
};
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 authorizeOAuthCode = sinon.spy(fxAccounts, "authorizeOAuthCode");
const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel);
await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize");
Assert.ok(authorizeOAuthCode.calledOnce);
const oauthCodeArgs = authorizeOAuthCode.firstCall.args[0];
Assert.equal(oauthCodeArgs.keys_jwk, ChromeUtils.base64URLEncode(new TextEncoder().encode(JSON.stringify(epk.publicJWK)), {pad: false}));
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);
fxAccounts.authorizeOAuthCode.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,
};
}