Bug 1835710 - Cancel off-thread JIT compilation before changing nursery allocation...
[gecko.git] / services / fxaccounts / FxAccountsPairing.sys.mjs
blob7a0aec22175194fd921e1b3c7149d05b509c027b
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 { uid, email, avatar, displayName } =
365             await this._fxa.getSignedInUser();
366           const deviceName = this._weave.Service.clientsEngine.localName;
367           await this._pairingChannel.send({
368             message: "pair:auth:metadata",
369             data: {
370               email,
371               avatar,
372               displayName,
373               deviceName,
374             },
375           });
376           const {
377             client_id,
378             state,
379             scope,
380             code_challenge,
381             code_challenge_method,
382             keys_jwk,
383           } = payload.data;
384           const url = new URL(oauthUri);
385           url.searchParams.append("client_id", client_id);
386           url.searchParams.append("scope", scope);
387           url.searchParams.append("email", email);
388           url.searchParams.append("uid", uid);
389           url.searchParams.append("channel_id", this._channelId);
390           url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI);
391           this._emitter.emit("view:SwitchToWebContent", url.href);
392           curState.suppConnected(sender, {
393             client_id,
394             state,
395             scope,
396             code_challenge,
397             code_challenge_method,
398             keys_jwk,
399           });
400           break;
401         case "pair:supp:authorize":
402           stateMachine.assertState(
403             [PendingConfirmations, PendingRemoteConfirmation],
404             `Wrong state for ${message}`
405           );
406           curState.remoteConfirmed();
407           break;
408         default:
409           throw new Error(
410             `Received unknown Pairing Channel message: ${message}`
411           );
412       }
413     } catch (e) {
414       log.error(e);
415       curState.hasErrored(e);
416     }
417   }
419   onPrefViewClosed() {
420     const curState = this._stateMachine.currentState;
421     // We don't want to stop the pairing process in the later stages.
422     if (
423       curState instanceof SuppConnectionPending ||
424       curState instanceof Aborted ||
425       curState instanceof Errored
426     ) {
427       this.finalize();
428     }
429   }
431   /**
432    * Grant an OAuth authorization code for the connecting client.
433    *
434    * @param {Object} options
435    * @param options.client_id
436    * @param options.state
437    * @param options.scope
438    * @param options.access_type
439    * @param options.code_challenge_method
440    * @param options.code_challenge
441    * @param [options.keys_jwe]
442    * @returns {Promise<Object>} Object containing "code" and "state" properties.
443    */
444   _authorizeOAuthCode(options) {
445     return this._fxa._withVerifiedAccountState(async state => {
446       const { sessionToken } = await state.getUserAccountData(["sessionToken"]);
447       const params = { ...options };
448       if (params.keys_jwk) {
449         const jwk = JSON.parse(
450           new TextDecoder().decode(
451             ChromeUtils.base64URLDecode(params.keys_jwk, { padding: "reject" })
452           )
453         );
454         params.keys_jwe = await this._createKeysJWE(
455           sessionToken,
456           params.client_id,
457           params.scope,
458           jwk
459         );
460         delete params.keys_jwk;
461       }
462       try {
463         return await this._fxai.fxAccountsClient.oauthAuthorize(
464           sessionToken,
465           params
466         );
467       } catch (err) {
468         throw this._fxai._errorToErrorClass(err);
469       }
470     });
471   }
473   /**
474    * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow.
475    *
476    * This method is used to transfer key material to another client, by providing
477    * an appropriately-encrypted value for the `keys_jwe` OAuth response parameter.
478    * Since we're transferring keys from one client to another, two things must be
479    * true:
480    *
481    *   * This client must actually have the key.
482    *   * The other client must be allowed to request that key.
483    *
484    * @param {String} sessionToken the sessionToken to use when fetching key metadata
485    * @param {String} clientId the client requesting access to our keys
486    * @param {String} scopes Space separated requested scopes being requested
487    * @param {Object} jwk Ephemeral JWK provided by the client for secure key transfer
488    */
489   async _createKeysJWE(sessionToken, clientId, scopes, jwk) {
490     // This checks with the FxA server about what scopes the client is allowed.
491     // Note that we pass the requesting client_id here, not our own client_id.
492     const clientKeyData = await this._fxai.fxAccountsClient.getScopedKeyData(
493       sessionToken,
494       clientId,
495       scopes
496     );
497     const scopedKeys = {};
498     for (const scope of Object.keys(clientKeyData)) {
499       const key = await this._fxai.keys.getKeyForScope(scope);
500       if (!key) {
501         throw new Error(`Key not available for scope "${scope}"`);
502       }
503       scopedKeys[scope] = key;
504     }
505     return lazy.jwcrypto.generateJWE(
506       jwk,
507       new TextEncoder().encode(JSON.stringify(scopedKeys))
508     );
509   }