diff options
Diffstat (limited to 'services/fxaccounts/tests/xpcshell/test_pairing.js')
-rw-r--r-- | services/fxaccounts/tests/xpcshell/test_pairing.js | 385 |
1 files changed, 385 insertions, 0 deletions
diff --git a/services/fxaccounts/tests/xpcshell/test_pairing.js b/services/fxaccounts/tests/xpcshell/test_pairing.js new file mode 100644 index 0000000000..abc094da54 --- /dev/null +++ b/services/fxaccounts/tests/xpcshell/test_pairing.js @@ -0,0 +1,385 @@ +/* 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", +}); +XPCOMUtils.defineLazyGlobalGetters(this, ["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 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(scope) { + 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, + }; +} |