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