FxAccountsPairing.sys.mjs (15423B)
1 // This Source Code Form is subject to the terms of the Mozilla Public 2 // License, v. 2.0. If a copy of the MPL was not distributed with this 3 // file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 5 import { 6 log, 7 PREF_REMOTE_PAIRING_URI, 8 COMMAND_PAIR_SUPP_METADATA, 9 COMMAND_PAIR_AUTHORIZE, 10 COMMAND_PAIR_DECLINE, 11 COMMAND_PAIR_HEARTBEAT, 12 COMMAND_PAIR_COMPLETE, 13 } from "resource://gre/modules/FxAccountsCommon.sys.mjs"; 14 15 import { 16 getFxAccountsSingleton, 17 FxAccounts, 18 } from "resource://gre/modules/FxAccounts.sys.mjs"; 19 20 const fxAccounts = getFxAccountsSingleton(); 21 import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; 22 23 ChromeUtils.importESModule("resource://services-common/utils.sys.mjs"); 24 const lazy = {}; 25 ChromeUtils.defineESModuleGetters(lazy, { 26 FxAccountsPairingChannel: 27 "resource://gre/modules/FxAccountsPairingChannel.sys.mjs", 28 29 Weave: "resource://services-sync/main.sys.mjs", 30 jwcrypto: "moz-src:///services/crypto/modules/jwcrypto.sys.mjs", 31 }); 32 33 const PAIRING_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel"; 34 // A pairing flow is not tied to a specific browser window, can also finish in 35 // various ways and subsequently might leak a Web Socket, so just in case we 36 // time out and free-up the resources after a specified amount of time. 37 const FLOW_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes. 38 39 class PairingStateMachine { 40 constructor(emitter) { 41 this._emitter = emitter; 42 this._transition(SuppConnectionPending); 43 } 44 45 get currentState() { 46 return this._currentState; 47 } 48 49 _transition(StateCtor, ...args) { 50 const state = new StateCtor(this, ...args); 51 this._currentState = state; 52 } 53 54 assertState(RequiredStates, messagePrefix = null) { 55 if (!(RequiredStates instanceof Array)) { 56 RequiredStates = [RequiredStates]; 57 } 58 if ( 59 !RequiredStates.some( 60 RequiredState => this._currentState instanceof RequiredState 61 ) 62 ) { 63 const msg = `${ 64 messagePrefix ? `${messagePrefix}. ` : "" 65 }Valid expected states: ${RequiredStates.map(({ name }) => name).join( 66 ", " 67 )}. Current state: ${this._currentState.label}.`; 68 throw new Error(msg); 69 } 70 } 71 } 72 73 /** 74 * The pairing flow can be modeled by a finite state machine: 75 * We start by connecting to a WebSocket channel (SuppConnectionPending). 76 * Then the other party connects and requests some metadata from us (PendingConfirmations). 77 * A confirmation happens locally first (PendingRemoteConfirmation) 78 * or the oppposite (PendingLocalConfirmation). 79 * Any side can decline this confirmation (Aborted). 80 * Once both sides have confirmed, the pairing flow is finished (Completed). 81 * During this flow errors can happen and should be handled (Errored). 82 */ 83 class State { 84 constructor(stateMachine, ...args) { 85 this._transition = (...args) => stateMachine._transition(...args); 86 this._notify = (...args) => stateMachine._emitter.emit(...args); 87 this.init(...args); 88 } 89 90 init() { 91 /* Does nothing by default but can be re-implemented. */ 92 } 93 94 get label() { 95 return this.constructor.name; 96 } 97 98 hasErrored(error) { 99 this._notify("view:Error", error); 100 this._transition(Errored, error); 101 } 102 103 hasAborted() { 104 this._transition(Aborted); 105 } 106 } 107 class SuppConnectionPending extends State { 108 suppConnected(sender, oauthOptions) { 109 this._transition(PendingConfirmations, sender, oauthOptions); 110 } 111 } 112 class PendingConfirmationsState extends State { 113 localConfirmed() { 114 throw new Error("Subclasses must implement this method."); 115 } 116 remoteConfirmed() { 117 throw new Error("Subclasses must implement this method."); 118 } 119 } 120 class PendingConfirmations extends PendingConfirmationsState { 121 init(sender, oauthOptions) { 122 this.sender = sender; 123 this.oauthOptions = oauthOptions; 124 } 125 126 localConfirmed() { 127 this._transition(PendingRemoteConfirmation); 128 } 129 130 remoteConfirmed() { 131 this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions); 132 } 133 } 134 class PendingLocalConfirmation extends PendingConfirmationsState { 135 init(sender, oauthOptions) { 136 this.sender = sender; 137 this.oauthOptions = oauthOptions; 138 } 139 140 localConfirmed() { 141 this._transition(Completed); 142 } 143 144 remoteConfirmed() { 145 throw new Error( 146 "Insane state! Remote has already been confirmed at this point." 147 ); 148 } 149 } 150 class PendingRemoteConfirmation extends PendingConfirmationsState { 151 localConfirmed() { 152 throw new Error( 153 "Insane state! Local has already been confirmed at this point." 154 ); 155 } 156 157 remoteConfirmed() { 158 this._transition(Completed); 159 } 160 } 161 class Completed extends State {} 162 class Aborted extends State {} 163 class Errored extends State { 164 init(error) { 165 this.error = error; 166 } 167 } 168 169 const flows = new Map(); 170 171 export class FxAccountsPairingFlow { 172 static get(channelId) { 173 return flows.get(channelId); 174 } 175 176 static finalizeAll() { 177 for (const flow of flows) { 178 flow.finalize(); 179 } 180 } 181 182 static async start(options) { 183 const { emitter } = options; 184 const fxaConfig = options.fxaConfig || FxAccounts.config; 185 const fxa = options.fxAccounts || fxAccounts; 186 const weave = options.weave || lazy.Weave; 187 const flowTimeout = options.flowTimeout || FLOW_TIMEOUT_MS; 188 189 const contentPairingURI = await fxaConfig.promisePairingURI(); 190 const wsUri = Services.urlFormatter.formatURLPref(PREF_REMOTE_PAIRING_URI); 191 const pairingChannel = 192 options.pairingChannel || 193 (await lazy.FxAccountsPairingChannel.create(wsUri)); 194 const { channelId, channelKey } = pairingChannel; 195 const channelKeyB64 = ChromeUtils.base64URLEncode(channelKey, { 196 pad: false, 197 }); 198 const pairingFlow = new FxAccountsPairingFlow({ 199 channelId, 200 pairingChannel, 201 emitter, 202 fxa, 203 fxaConfig, 204 flowTimeout, 205 weave, 206 }); 207 flows.set(channelId, pairingFlow); 208 209 return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`; 210 } 211 212 constructor(options) { 213 this._channelId = options.channelId; 214 this._pairingChannel = options.pairingChannel; 215 this._emitter = options.emitter; 216 this._fxa = options.fxa; 217 this._fxai = options.fxai || this._fxa._internal; 218 this._fxaConfig = options.fxaConfig; 219 this._weave = options.weave; 220 this._stateMachine = new PairingStateMachine(this._emitter); 221 this._setupListeners(); 222 this._flowTimeoutId = setTimeout( 223 () => this._onFlowTimeout(), 224 options.flowTimeout 225 ); 226 } 227 228 _onFlowTimeout() { 229 log.warn(`The pairing flow ${this._channelId} timed out.`); 230 this._onError(new Error("Timeout")); 231 this.finalize(); 232 } 233 234 _closeChannel() { 235 if (!this._closed && !this._pairingChannel.closed) { 236 this._pairingChannel.close(); 237 this._closed = true; 238 } 239 } 240 241 finalize() { 242 this._closeChannel(); 243 clearTimeout(this._flowTimeoutId); 244 // Free up resources and let the GC do its thing. 245 flows.delete(this._channelId); 246 } 247 248 _setupListeners() { 249 this._pairingChannel.addEventListener( 250 "message", 251 ({ detail: { sender, data } }) => 252 this.onPairingChannelMessage(sender, data) 253 ); 254 this._pairingChannel.addEventListener("error", event => 255 this._onPairingChannelError(event.detail.error) 256 ); 257 this._emitter.on("view:Closed", () => this.onPrefViewClosed()); 258 } 259 260 _onAbort() { 261 this._stateMachine.currentState.hasAborted(); 262 this.finalize(); 263 } 264 265 _onError(error) { 266 this._stateMachine.currentState.hasErrored(error); 267 this._closeChannel(); 268 } 269 270 _onPairingChannelError(error) { 271 log.error("Pairing channel error", error); 272 this._onError(error); 273 } 274 275 // Any non-falsy returned value is sent back through WebChannel. 276 async onWebChannelMessage(command) { 277 const stateMachine = this._stateMachine; 278 const curState = stateMachine.currentState; 279 try { 280 switch (command) { 281 case COMMAND_PAIR_SUPP_METADATA: { 282 stateMachine.assertState( 283 [PendingConfirmations, PendingLocalConfirmation], 284 `Wrong state for ${command}` 285 ); 286 const { 287 ua, 288 city, 289 region, 290 country, 291 remote: ipAddress, 292 } = curState.sender; 293 return { ua, city, region, country, ipAddress }; 294 } 295 case COMMAND_PAIR_AUTHORIZE: { 296 stateMachine.assertState( 297 [PendingConfirmations, PendingLocalConfirmation], 298 `Wrong state for ${command}` 299 ); 300 const { 301 client_id, 302 state, 303 scope, 304 code_challenge, 305 code_challenge_method, 306 keys_jwk, 307 } = curState.oauthOptions; 308 const authorizeParams = { 309 client_id, 310 access_type: "offline", 311 state, 312 scope, 313 code_challenge, 314 code_challenge_method, 315 keys_jwk, 316 }; 317 const codeAndState = await this._authorizeOAuthCode(authorizeParams); 318 if (codeAndState.state != state) { 319 throw new Error(`OAuth state mismatch`); 320 } 321 await this._pairingChannel.send({ 322 message: "pair:auth:authorize", 323 data: { 324 ...codeAndState, 325 }, 326 }); 327 curState.localConfirmed(); 328 break; 329 } 330 case COMMAND_PAIR_DECLINE: 331 this._onAbort(); 332 break; 333 case COMMAND_PAIR_HEARTBEAT: { 334 if (curState instanceof Errored || this._pairingChannel.closed) { 335 return { err: curState.error.message || "Pairing channel closed" }; 336 } 337 const suppAuthorized = !( 338 curState instanceof PendingConfirmations || 339 curState instanceof PendingRemoteConfirmation 340 ); 341 return { suppAuthorized }; 342 } 343 case COMMAND_PAIR_COMPLETE: 344 this.finalize(); 345 break; 346 default: 347 throw new Error(`Received unknown WebChannel command: ${command}`); 348 } 349 } catch (e) { 350 log.error(e); 351 curState.hasErrored(e); 352 } 353 return {}; 354 } 355 356 async onPairingChannelMessage(sender, payload) { 357 const { message } = payload; 358 const stateMachine = this._stateMachine; 359 const curState = stateMachine.currentState; 360 try { 361 switch (message) { 362 case "pair:supp:request": { 363 stateMachine.assertState( 364 SuppConnectionPending, 365 `Wrong state for ${message}` 366 ); 367 const oauthUri = await this._fxaConfig.promiseOAuthURI(); 368 const { uid, email, avatar, displayName } = 369 await this._fxa.getSignedInUser(); 370 const deviceName = this._weave.Service.clientsEngine.localName; 371 await this._pairingChannel.send({ 372 message: "pair:auth:metadata", 373 data: { 374 email, 375 avatar, 376 displayName, 377 deviceName, 378 }, 379 }); 380 const { 381 client_id, 382 state, 383 scope, 384 code_challenge, 385 code_challenge_method, 386 keys_jwk, 387 } = payload.data; 388 const url = new URL(oauthUri); 389 url.searchParams.append("client_id", client_id); 390 url.searchParams.append("scope", scope); 391 url.searchParams.append("email", email); 392 url.searchParams.append("uid", uid); 393 url.searchParams.append("channel_id", this._channelId); 394 url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI); 395 this._emitter.emit("view:SwitchToWebContent", url.href); 396 curState.suppConnected(sender, { 397 client_id, 398 state, 399 scope, 400 code_challenge, 401 code_challenge_method, 402 keys_jwk, 403 }); 404 break; 405 } 406 case "pair:supp:authorize": 407 stateMachine.assertState( 408 [PendingConfirmations, PendingRemoteConfirmation], 409 `Wrong state for ${message}` 410 ); 411 curState.remoteConfirmed(); 412 break; 413 default: 414 throw new Error( 415 `Received unknown Pairing Channel message: ${message}` 416 ); 417 } 418 } catch (e) { 419 log.error(e); 420 curState.hasErrored(e); 421 } 422 } 423 424 onPrefViewClosed() { 425 const curState = this._stateMachine.currentState; 426 // We don't want to stop the pairing process in the later stages. 427 if ( 428 curState instanceof SuppConnectionPending || 429 curState instanceof Aborted || 430 curState instanceof Errored 431 ) { 432 this.finalize(); 433 } 434 } 435 436 /** 437 * Grant an OAuth authorization code for the connecting client. 438 * 439 * @param {object} options 440 * @param options.client_id 441 * @param options.state 442 * @param options.scope 443 * @param options.access_type 444 * @param options.code_challenge_method 445 * @param options.code_challenge 446 * @param [options.keys_jwe] 447 * @returns {Promise<object>} Object containing "code" and "state" properties. 448 */ 449 _authorizeOAuthCode(options) { 450 return this._fxa._withVerifiedAccountState(async state => { 451 const { sessionToken } = await state.getUserAccountData(["sessionToken"]); 452 const params = { ...options }; 453 if (params.keys_jwk) { 454 const jwk = JSON.parse( 455 new TextDecoder().decode( 456 ChromeUtils.base64URLDecode(params.keys_jwk, { padding: "reject" }) 457 ) 458 ); 459 params.keys_jwe = await this._createKeysJWE( 460 sessionToken, 461 params.client_id, 462 params.scope, 463 jwk 464 ); 465 delete params.keys_jwk; 466 } 467 try { 468 return await this._fxai.fxAccountsClient.oauthAuthorize( 469 sessionToken, 470 params 471 ); 472 } catch (err) { 473 throw this._fxai._errorToErrorClass(err); 474 } 475 }); 476 } 477 478 /** 479 * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow. 480 * 481 * This method is used to transfer key material to another client, by providing 482 * an appropriately-encrypted value for the `keys_jwe` OAuth response parameter. 483 * Since we're transferring keys from one client to another, two things must be 484 * true: 485 * 486 * * This client must actually have the key. 487 * * The other client must be allowed to request that key. 488 * 489 * @param {string} sessionToken the sessionToken to use when fetching key metadata 490 * @param {string} clientId the client requesting access to our keys 491 * @param {string} scopes Space separated requested scopes being requested 492 * @param {object} jwk Ephemeral JWK provided by the client for secure key transfer 493 */ 494 async _createKeysJWE(sessionToken, clientId, scopes, jwk) { 495 // This checks with the FxA server about what scopes the client is allowed. 496 // Note that we pass the requesting client_id here, not our own client_id. 497 const clientKeyData = await this._fxai.fxAccountsClient.getScopedKeyData( 498 sessionToken, 499 clientId, 500 scopes 501 ); 502 const scopedKeys = {}; 503 for (const scope of Object.keys(clientKeyData)) { 504 const key = await this._fxai.keys.getKeyForScope(scope); 505 if (!key) { 506 throw new Error(`Key not available for scope "${scope}"`); 507 } 508 scopedKeys[scope] = key; 509 } 510 return lazy.jwcrypto.generateJWE( 511 jwk, 512 new TextEncoder().encode(JSON.stringify(scopedKeys)) 513 ); 514 } 515 }