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/.
7 PREF_REMOTE_PAIRING_URI,
8 COMMAND_PAIR_SUPP_METADATA,
9 COMMAND_PAIR_AUTHORIZE,
11 COMMAND_PAIR_HEARTBEAT,
12 COMMAND_PAIR_COMPLETE,
13 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
16 getFxAccountsSingleton,
18 } from "resource://gre/modules/FxAccounts.sys.mjs";
20 const fxAccounts = getFxAccountsSingleton();
21 import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
23 ChromeUtils.importESModule("resource://services-common/utils.sys.mjs");
25 ChromeUtils.defineESModuleGetters(lazy, {
26 FxAccountsPairingChannel:
27 "resource://gre/modules/FxAccountsPairingChannel.sys.mjs",
29 Weave: "resource://services-sync/main.sys.mjs",
30 jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
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.
39 class PairingStateMachine {
40 constructor(emitter) {
41 this._emitter = emitter;
42 this._transition(SuppConnectionPending);
46 return this._currentState;
49 _transition(StateCtor, ...args) {
50 const state = new StateCtor(this, ...args);
51 this._currentState = state;
54 assertState(RequiredStates, messagePrefix = null) {
55 if (!(RequiredStates instanceof Array)) {
56 RequiredStates = [RequiredStates];
60 RequiredState => this._currentState instanceof RequiredState
64 messagePrefix ? `${messagePrefix}. ` : ""
65 }Valid expected states: ${RequiredStates.map(({ name }) => name).join(
67 )}. Current state: ${this._currentState.label}.`;
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).
84 constructor(stateMachine, ...args) {
85 this._transition = (...args) => stateMachine._transition(...args);
86 this._notify = (...args) => stateMachine._emitter.emit(...args);
91 /* Does nothing by default but can be re-implemented. */
95 return this.constructor.name;
99 this._notify("view:Error", error);
100 this._transition(Errored, error);
104 this._transition(Aborted);
107 class SuppConnectionPending extends State {
108 suppConnected(sender, oauthOptions) {
109 this._transition(PendingConfirmations, sender, oauthOptions);
112 class PendingConfirmationsState extends State {
114 throw new Error("Subclasses must implement this method.");
117 throw new Error("Subclasses must implement this method.");
120 class PendingConfirmations extends PendingConfirmationsState {
121 init(sender, oauthOptions) {
122 this.sender = sender;
123 this.oauthOptions = oauthOptions;
127 this._transition(PendingRemoteConfirmation);
131 this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions);
134 class PendingLocalConfirmation extends PendingConfirmationsState {
135 init(sender, oauthOptions) {
136 this.sender = sender;
137 this.oauthOptions = oauthOptions;
141 this._transition(Completed);
146 "Insane state! Remote has already been confirmed at this point."
150 class PendingRemoteConfirmation extends PendingConfirmationsState {
153 "Insane state! Local has already been confirmed at this point."
158 this._transition(Completed);
161 class Completed extends State {}
162 class Aborted extends State {}
163 class Errored extends State {
169 const flows = new Map();
171 export class FxAccountsPairingFlow {
172 static get(channelId) {
173 return flows.get(channelId);
176 static finalizeAll() {
177 for (const flow of flows) {
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;
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, {
198 const pairingFlow = new FxAccountsPairingFlow({
207 flows.set(channelId, pairingFlow);
209 return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`;
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(),
229 log.warn(`The pairing flow ${this._channelId} timed out.`);
230 this._onError(new Error("Timeout"));
235 if (!this._closed && !this._pairingChannel.closed) {
236 this._pairingChannel.close();
242 this._closeChannel();
243 clearTimeout(this._flowTimeoutId);
244 // Free up resources and let the GC do its thing.
245 flows.delete(this._channelId);
249 this._pairingChannel.addEventListener(
251 ({ detail: { sender, data } }) =>
252 this.onPairingChannelMessage(sender, data)
254 this._pairingChannel.addEventListener("error", event =>
255 this._onPairingChannelError(event.detail.error)
257 this._emitter.on("view:Closed", () => this.onPrefViewClosed());
261 this._stateMachine.currentState.hasAborted();
266 this._stateMachine.currentState.hasErrored(error);
267 this._closeChannel();
270 _onPairingChannelError(error) {
271 log.error("Pairing channel error", error);
272 this._onError(error);
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;
281 case COMMAND_PAIR_SUPP_METADATA:
282 stateMachine.assertState(
283 [PendingConfirmations, PendingLocalConfirmation],
284 `Wrong state for ${command}`
293 return { ua, city, region, country, ipAddress };
294 case COMMAND_PAIR_AUTHORIZE:
295 stateMachine.assertState(
296 [PendingConfirmations, PendingLocalConfirmation],
297 `Wrong state for ${command}`
304 code_challenge_method,
306 } = curState.oauthOptions;
307 const authorizeParams = {
309 access_type: "offline",
313 code_challenge_method,
316 const codeAndState = await this._authorizeOAuthCode(authorizeParams);
317 if (codeAndState.state != state) {
318 throw new Error(`OAuth state mismatch`);
320 await this._pairingChannel.send({
321 message: "pair:auth:authorize",
326 curState.localConfirmed();
328 case COMMAND_PAIR_DECLINE:
331 case COMMAND_PAIR_HEARTBEAT:
332 if (curState instanceof Errored || this._pairingChannel.closed) {
333 return { err: curState.error.message || "Pairing channel closed" };
335 const suppAuthorized = !(
336 curState instanceof PendingConfirmations ||
337 curState instanceof PendingRemoteConfirmation
339 return { suppAuthorized };
340 case COMMAND_PAIR_COMPLETE:
344 throw new Error(`Received unknown WebChannel command: ${command}`);
348 curState.hasErrored(e);
353 async onPairingChannelMessage(sender, payload) {
354 const { message } = payload;
355 const stateMachine = this._stateMachine;
356 const curState = stateMachine.currentState;
359 case "pair:supp:request":
360 stateMachine.assertState(
361 SuppConnectionPending,
362 `Wrong state for ${message}`
364 const oauthUri = await this._fxaConfig.promiseOAuthURI();
365 const { uid, email, avatar, displayName } =
366 await this._fxa.getSignedInUser();
367 const deviceName = this._weave.Service.clientsEngine.localName;
368 await this._pairingChannel.send({
369 message: "pair:auth:metadata",
382 code_challenge_method,
385 const url = new URL(oauthUri);
386 url.searchParams.append("client_id", client_id);
387 url.searchParams.append("scope", scope);
388 url.searchParams.append("email", email);
389 url.searchParams.append("uid", uid);
390 url.searchParams.append("channel_id", this._channelId);
391 url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI);
392 this._emitter.emit("view:SwitchToWebContent", url.href);
393 curState.suppConnected(sender, {
398 code_challenge_method,
402 case "pair:supp:authorize":
403 stateMachine.assertState(
404 [PendingConfirmations, PendingRemoteConfirmation],
405 `Wrong state for ${message}`
407 curState.remoteConfirmed();
411 `Received unknown Pairing Channel message: ${message}`
416 curState.hasErrored(e);
421 const curState = this._stateMachine.currentState;
422 // We don't want to stop the pairing process in the later stages.
424 curState instanceof SuppConnectionPending ||
425 curState instanceof Aborted ||
426 curState instanceof Errored
433 * Grant an OAuth authorization code for the connecting client.
435 * @param {Object} options
436 * @param options.client_id
437 * @param options.state
438 * @param options.scope
439 * @param options.access_type
440 * @param options.code_challenge_method
441 * @param options.code_challenge
442 * @param [options.keys_jwe]
443 * @returns {Promise<Object>} Object containing "code" and "state" properties.
445 _authorizeOAuthCode(options) {
446 return this._fxa._withVerifiedAccountState(async state => {
447 const { sessionToken } = await state.getUserAccountData(["sessionToken"]);
448 const params = { ...options };
449 if (params.keys_jwk) {
450 const jwk = JSON.parse(
451 new TextDecoder().decode(
452 ChromeUtils.base64URLDecode(params.keys_jwk, { padding: "reject" })
455 params.keys_jwe = await this._createKeysJWE(
461 delete params.keys_jwk;
464 return await this._fxai.fxAccountsClient.oauthAuthorize(
469 throw this._fxai._errorToErrorClass(err);
475 * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow.
477 * This method is used to transfer key material to another client, by providing
478 * an appropriately-encrypted value for the `keys_jwe` OAuth response parameter.
479 * Since we're transferring keys from one client to another, two things must be
482 * * This client must actually have the key.
483 * * The other client must be allowed to request that key.
485 * @param {String} sessionToken the sessionToken to use when fetching key metadata
486 * @param {String} clientId the client requesting access to our keys
487 * @param {String} scopes Space separated requested scopes being requested
488 * @param {Object} jwk Ephemeral JWK provided by the client for secure key transfer
490 async _createKeysJWE(sessionToken, clientId, scopes, jwk) {
491 // This checks with the FxA server about what scopes the client is allowed.
492 // Note that we pass the requesting client_id here, not our own client_id.
493 const clientKeyData = await this._fxai.fxAccountsClient.getScopedKeyData(
498 const scopedKeys = {};
499 for (const scope of Object.keys(clientKeyData)) {
500 const key = await this._fxai.keys.getKeyForScope(scope);
502 throw new Error(`Key not available for scope "${scope}"`);
504 scopedKeys[scope] = key;
506 return lazy.jwcrypto.generateJWE(
508 new TextEncoder().encode(JSON.stringify(scopedKeys))