test_pairing.js (10007B)
1 /* Any copyright is dedicated to the Public Domain. 2 * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 const { FxAccountsPairingFlow } = ChromeUtils.importESModule( 7 "resource://gre/modules/FxAccountsPairing.sys.mjs" 8 ); 9 const { EventEmitter } = ChromeUtils.importESModule( 10 "resource://gre/modules/EventEmitter.sys.mjs" 11 ); 12 ChromeUtils.defineESModuleGetters(this, { 13 jwcrypto: "moz-src:///services/crypto/modules/jwcrypto.sys.mjs", 14 }); 15 16 const CHANNEL_ID = "sW-UA97Q6Dljqen7XRlYPw"; 17 const CHANNEL_KEY = crypto.getRandomValues(new Uint8Array(32)); 18 19 const SENDER_SUPP = { 20 ua: "Firefox Supp", 21 city: "Nice", 22 region: "PACA", 23 country: "France", 24 remote: "127.0.0.1", 25 }; 26 const UID = "abcd"; 27 const EMAIL = "foo@bar.com"; 28 const AVATAR = "https://foo.bar/avatar"; 29 const DISPLAY_NAME = "Foo bar"; 30 const DEVICE_NAME = "Foo's computer"; 31 32 const PAIR_URI = "https://foo.bar/pair"; 33 const OAUTH_URI = "https://foo.bar/oauth"; 34 const KSYNC = "myksync"; 35 const SESSION = "mysession"; 36 const fxaConfig = { 37 promisePairingURI() { 38 return PAIR_URI; 39 }, 40 promiseOAuthURI() { 41 return OAUTH_URI; 42 }, 43 }; 44 const fxAccounts = { 45 getSignedInUser() { 46 return { 47 uid: UID, 48 email: EMAIL, 49 avatar: AVATAR, 50 displayName: DISPLAY_NAME, 51 }; 52 }, 53 async _withVerifiedAccountState(cb) { 54 return cb({ 55 async getUserAccountData() { 56 return { 57 sessionToken: SESSION, 58 }; 59 }, 60 }); 61 }, 62 _internal: { 63 keys: { 64 getKeyForScope() { 65 return { 66 kid: "123456", 67 k: KSYNC, 68 kty: "oct", 69 }; 70 }, 71 }, 72 fxAccountsClient: { 73 async getScopedKeyData() { 74 return { 75 [SCOPE_APP_SYNC]: { 76 identifier: SCOPE_APP_SYNC, 77 keyRotationTimestamp: 12345678, 78 }, 79 }; 80 }, 81 async oauthAuthorize() { 82 return { code: "mycode", state: "mystate" }; 83 }, 84 }, 85 }, 86 }; 87 const weave = { 88 Service: { clientsEngine: { localName: DEVICE_NAME } }, 89 }; 90 91 class MockPairingChannel extends EventTarget { 92 get channelId() { 93 return CHANNEL_ID; 94 } 95 96 get channelKey() { 97 return CHANNEL_KEY; 98 } 99 100 send(data) { 101 this.dispatchEvent( 102 new CustomEvent("send", { 103 detail: { data }, 104 }) 105 ); 106 } 107 108 simulateIncoming(data) { 109 this.dispatchEvent( 110 new CustomEvent("message", { 111 detail: { data, sender: SENDER_SUPP }, 112 }) 113 ); 114 } 115 116 close() { 117 this.closed = true; 118 } 119 } 120 121 add_task(async function testFullFlow() { 122 const emitter = new EventEmitter(); 123 const pairingChannel = new MockPairingChannel(); 124 const pairingUri = await FxAccountsPairingFlow.start({ 125 emitter, 126 pairingChannel, 127 fxAccounts, 128 fxaConfig, 129 weave, 130 }); 131 Assert.equal( 132 pairingUri, 133 `${PAIR_URI}#channel_id=${CHANNEL_ID}&channel_key=${ChromeUtils.base64URLEncode( 134 CHANNEL_KEY, 135 { pad: false } 136 )}` 137 ); 138 139 const flow = FxAccountsPairingFlow.get(CHANNEL_ID); 140 141 const promiseSwitchToWebContent = emitter.once("view:SwitchToWebContent"); 142 const promiseMetadataSent = promiseOutgoingMessage(pairingChannel); 143 const epk = await generateEphemeralKeypair(); 144 145 pairingChannel.simulateIncoming({ 146 message: "pair:supp:request", 147 data: { 148 client_id: "client_id_1", 149 state: "mystate", 150 keys_jwk: ChromeUtils.base64URLEncode( 151 new TextEncoder().encode(JSON.stringify(epk.publicJWK)), 152 { pad: false } 153 ), 154 scope: `profile ${SCOPE_APP_SYNC}`, 155 code_challenge: "chal", 156 code_challenge_method: "S256", 157 }, 158 }); 159 const sentAuthMetadata = await promiseMetadataSent; 160 Assert.deepEqual(sentAuthMetadata, { 161 message: "pair:auth:metadata", 162 data: { 163 email: EMAIL, 164 avatar: AVATAR, 165 displayName: DISPLAY_NAME, 166 deviceName: DEVICE_NAME, 167 }, 168 }); 169 const oauthUrl = await promiseSwitchToWebContent; 170 Assert.equal( 171 oauthUrl, 172 `${OAUTH_URI}?client_id=client_id_1&scope=profile+${encodeURIComponent( 173 SCOPE_APP_SYNC 174 )}&email=foo%40bar.com&uid=abcd&channel_id=${CHANNEL_ID}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob%3Apair-auth-webchannel` 175 ); 176 177 let pairSuppMetadata = await simulateIncomingWebChannel( 178 flow, 179 "fxaccounts:pair_supplicant_metadata" 180 ); 181 Assert.deepEqual( 182 { 183 ua: "Firefox Supp", 184 city: "Nice", 185 region: "PACA", 186 country: "France", 187 ipAddress: "127.0.0.1", 188 }, 189 pairSuppMetadata 190 ); 191 192 const generateJWE = sinon.spy(jwcrypto, "generateJWE"); 193 const oauthAuthorize = sinon.spy( 194 fxAccounts._internal.fxAccountsClient, 195 "oauthAuthorize" 196 ); 197 const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel); 198 await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize"); 199 // We should have generated the expected JWE. 200 Assert.ok(generateJWE.calledOnce); 201 const generateArgs = generateJWE.firstCall.args; 202 Assert.deepEqual(generateArgs[0], epk.publicJWK); 203 Assert.deepEqual(JSON.parse(new TextDecoder().decode(generateArgs[1])), { 204 [SCOPE_APP_SYNC]: { 205 kid: "123456", 206 k: KSYNC, 207 kty: "oct", 208 }, 209 }); 210 // We should have authorized an oauth code with expected parameters. 211 Assert.ok(oauthAuthorize.calledOnce); 212 const oauthCodeArgs = oauthAuthorize.firstCall.args[1]; 213 console.log(oauthCodeArgs); 214 Assert.ok(!oauthCodeArgs.keys_jwk); 215 Assert.deepEqual( 216 oauthCodeArgs.keys_jwe, 217 await generateJWE.firstCall.returnValue 218 ); 219 Assert.equal(oauthCodeArgs.client_id, "client_id_1"); 220 Assert.equal(oauthCodeArgs.access_type, "offline"); 221 Assert.equal(oauthCodeArgs.state, "mystate"); 222 Assert.equal(oauthCodeArgs.scope, `profile ${SCOPE_APP_SYNC}`); 223 Assert.equal(oauthCodeArgs.code_challenge, "chal"); 224 Assert.equal(oauthCodeArgs.code_challenge_method, "S256"); 225 226 const oAuthParams = await promiseOAuthParamsMsg; 227 Assert.deepEqual(oAuthParams, { 228 message: "pair:auth:authorize", 229 data: { code: "mycode", state: "mystate" }, 230 }); 231 232 let heartbeat = await simulateIncomingWebChannel( 233 flow, 234 "fxaccounts:pair_heartbeat" 235 ); 236 Assert.ok(!heartbeat.suppAuthorized); 237 238 await pairingChannel.simulateIncoming({ 239 message: "pair:supp:authorize", 240 }); 241 242 heartbeat = await simulateIncomingWebChannel( 243 flow, 244 "fxaccounts:pair_heartbeat" 245 ); 246 Assert.ok(heartbeat.suppAuthorized); 247 248 await simulateIncomingWebChannel(flow, "fxaccounts:pair_complete"); 249 // The flow should have been destroyed! 250 Assert.ok(!FxAccountsPairingFlow.get(CHANNEL_ID)); 251 Assert.ok(pairingChannel.closed); 252 generateJWE.restore(); 253 oauthAuthorize.restore(); 254 }); 255 256 add_task(async function testUnknownPairingMessage() { 257 const emitter = new EventEmitter(); 258 const pairingChannel = new MockPairingChannel(); 259 await FxAccountsPairingFlow.start({ 260 emitter, 261 pairingChannel, 262 fxAccounts, 263 fxaConfig, 264 weave, 265 }); 266 const flow = FxAccountsPairingFlow.get(CHANNEL_ID); 267 const viewErrorObserved = emitter.once("view:Error"); 268 pairingChannel.simulateIncoming({ 269 message: "pair:boom", 270 }); 271 await viewErrorObserved; 272 let heartbeat = await simulateIncomingWebChannel( 273 flow, 274 "fxaccounts:pair_heartbeat" 275 ); 276 Assert.ok(heartbeat.err); 277 }); 278 279 add_task(async function testUnknownWebChannelCommand() { 280 const emitter = new EventEmitter(); 281 const pairingChannel = new MockPairingChannel(); 282 await FxAccountsPairingFlow.start({ 283 emitter, 284 pairingChannel, 285 fxAccounts, 286 fxaConfig, 287 weave, 288 }); 289 const flow = FxAccountsPairingFlow.get(CHANNEL_ID); 290 const viewErrorObserved = emitter.once("view:Error"); 291 await simulateIncomingWebChannel(flow, "fxaccounts:boom"); 292 await viewErrorObserved; 293 let heartbeat = await simulateIncomingWebChannel( 294 flow, 295 "fxaccounts:pair_heartbeat" 296 ); 297 Assert.ok(heartbeat.err); 298 }); 299 300 add_task(async function testPairingChannelFailure() { 301 const emitter = new EventEmitter(); 302 const pairingChannel = new MockPairingChannel(); 303 await FxAccountsPairingFlow.start({ 304 emitter, 305 pairingChannel, 306 fxAccounts, 307 fxaConfig, 308 weave, 309 }); 310 const flow = FxAccountsPairingFlow.get(CHANNEL_ID); 311 const viewErrorObserved = emitter.once("view:Error"); 312 sinon.stub(pairingChannel, "send").callsFake(() => { 313 throw new Error("Boom!"); 314 }); 315 pairingChannel.simulateIncoming({ 316 message: "pair:supp:request", 317 data: { 318 client_id: "client_id_1", 319 state: "mystate", 320 scope: `profile ${SCOPE_APP_SYNC}`, 321 code_challenge: "chal", 322 code_challenge_method: "S256", 323 }, 324 }); 325 await viewErrorObserved; 326 327 let heartbeat = await simulateIncomingWebChannel( 328 flow, 329 "fxaccounts:pair_heartbeat" 330 ); 331 Assert.ok(heartbeat.err); 332 }); 333 334 add_task(async function testFlowTimeout() { 335 const emitter = new EventEmitter(); 336 const pairingChannel = new MockPairingChannel(); 337 const viewErrorObserved = emitter.once("view:Error"); 338 await FxAccountsPairingFlow.start({ 339 emitter, 340 pairingChannel, 341 fxAccounts, 342 fxaConfig, 343 weave, 344 flowTimeout: 1, 345 }); 346 const flow = FxAccountsPairingFlow.get(CHANNEL_ID); 347 await viewErrorObserved; 348 349 let heartbeat = await simulateIncomingWebChannel( 350 flow, 351 "fxaccounts:pair_heartbeat" 352 ); 353 Assert.ok(heartbeat.err.match(/Timeout/)); 354 }); 355 356 async function simulateIncomingWebChannel(flow, command) { 357 return flow.onWebChannelMessage(command); 358 } 359 360 async function promiseOutgoingMessage(pairingChannel) { 361 return new Promise(res => { 362 const onMessage = event => { 363 pairingChannel.removeEventListener("send", onMessage); 364 res(event.detail.data); 365 }; 366 pairingChannel.addEventListener("send", onMessage); 367 }); 368 } 369 370 async function generateEphemeralKeypair() { 371 const keypair = await crypto.subtle.generateKey( 372 { name: "ECDH", namedCurve: "P-256" }, 373 true, 374 ["deriveKey"] 375 ); 376 const publicJWK = await crypto.subtle.exportKey("jwk", keypair.publicKey); 377 const privateJWK = await crypto.subtle.exportKey("jwk", keypair.privateKey); 378 delete publicJWK.key_ops; 379 return { 380 publicJWK, 381 privateJWK, 382 }; 383 }