no bug - Correct some typos in the comments. a=typo-fix
[gecko.git] / services / fxaccounts / FxAccountsPairing.sys.mjs
blobe68554f7abd0bd71ef7e22110a0ef1151739f984
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 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";
15 import {
16   getFxAccountsSingleton,
17   FxAccounts,
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");
24 const lazy = {};
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",
31 });
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);
43   }
45   get currentState() {
46     return this._currentState;
47   }
49   _transition(StateCtor, ...args) {
50     const state = new StateCtor(this, ...args);
51     this._currentState = state;
52   }
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   }
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   }
90   init() {
91     /* Does nothing by default but can be re-implemented. */
92   }
94   get label() {
95     return this.constructor.name;
96   }
98   hasErrored(error) {
99     this._notify("view:Error", error);
100     this._transition(Errored, error);
101   }
103   hasAborted() {
104     this._transition(Aborted);
105   }
107 class SuppConnectionPending extends State {
108   suppConnected(sender, oauthOptions) {
109     this._transition(PendingConfirmations, sender, oauthOptions);
110   }
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   }
120 class PendingConfirmations extends PendingConfirmationsState {
121   init(sender, oauthOptions) {
122     this.sender = sender;
123     this.oauthOptions = oauthOptions;
124   }
126   localConfirmed() {
127     this._transition(PendingRemoteConfirmation);
128   }
130   remoteConfirmed() {
131     this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions);
132   }
134 class PendingLocalConfirmation extends PendingConfirmationsState {
135   init(sender, oauthOptions) {
136     this.sender = sender;
137     this.oauthOptions = oauthOptions;
138   }
140   localConfirmed() {
141     this._transition(Completed);
142   }
144   remoteConfirmed() {
145     throw new Error(
146       "Insane state! Remote has already been confirmed at this point."
147     );
148   }
150 class PendingRemoteConfirmation extends PendingConfirmationsState {
151   localConfirmed() {
152     throw new Error(
153       "Insane state! Local has already been confirmed at this point."
154     );
155   }
157   remoteConfirmed() {
158     this._transition(Completed);
159   }
161 class Completed extends State {}
162 class Aborted extends State {}
163 class Errored extends State {
164   init(error) {
165     this.error = error;
166   }
169 const flows = new Map();
171 export class FxAccountsPairingFlow {
172   static get(channelId) {
173     return flows.get(channelId);
174   }
176   static finalizeAll() {
177     for (const flow of flows) {
178       flow.finalize();
179     }
180   }
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, {
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);
209     return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`;
210   }
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   }
228   _onFlowTimeout() {
229     log.warn(`The pairing flow ${this._channelId} timed out.`);
230     this._onError(new Error("Timeout"));
231     this.finalize();
232   }
234   _closeChannel() {
235     if (!this._closed && !this._pairingChannel.closed) {
236       this._pairingChannel.close();
237       this._closed = true;
238     }
239   }
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   }
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   }
260   _onAbort() {
261     this._stateMachine.currentState.hasAborted();
262     this.finalize();
263   }
265   _onError(error) {
266     this._stateMachine.currentState.hasErrored(error);
267     this._closeChannel();
268   }
270   _onPairingChannelError(error) {
271     log.error("Pairing channel error", error);
272     this._onError(error);
273   }
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         case COMMAND_PAIR_AUTHORIZE:
295           stateMachine.assertState(
296             [PendingConfirmations, PendingLocalConfirmation],
297             `Wrong state for ${command}`
298           );
299           const {
300             client_id,
301             state,
302             scope,
303             code_challenge,
304             code_challenge_method,
305             keys_jwk,
306           } = curState.oauthOptions;
307           const authorizeParams = {
308             client_id,
309             access_type: "offline",
310             state,
311             scope,
312             code_challenge,
313             code_challenge_method,
314             keys_jwk,
315           };
316           const codeAndState = await this._authorizeOAuthCode(authorizeParams);
317           if (codeAndState.state != state) {
318             throw new Error(`OAuth state mismatch`);
319           }
320           await this._pairingChannel.send({
321             message: "pair:auth:authorize",
322             data: {
323               ...codeAndState,
324             },
325           });
326           curState.localConfirmed();
327           break;
328         case COMMAND_PAIR_DECLINE:
329           this._onAbort();
330           break;
331         case COMMAND_PAIR_HEARTBEAT:
332           if (curState instanceof Errored || this._pairingChannel.closed) {
333             return { err: curState.error.message || "Pairing channel closed" };
334           }
335           const suppAuthorized = !(
336             curState instanceof PendingConfirmations ||
337             curState instanceof PendingRemoteConfirmation
338           );
339           return { suppAuthorized };
340         case COMMAND_PAIR_COMPLETE:
341           this.finalize();
342           break;
343         default:
344           throw new Error(`Received unknown WebChannel command: ${command}`);
345       }
346     } catch (e) {
347       log.error(e);
348       curState.hasErrored(e);
349     }
350     return {};
351   }
353   async onPairingChannelMessage(sender, payload) {
354     const { message } = payload;
355     const stateMachine = this._stateMachine;
356     const curState = stateMachine.currentState;
357     try {
358       switch (message) {
359         case "pair:supp:request":
360           stateMachine.assertState(
361             SuppConnectionPending,
362             `Wrong state for ${message}`
363           );
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",
370             data: {
371               email,
372               avatar,
373               displayName,
374               deviceName,
375             },
376           });
377           const {
378             client_id,
379             state,
380             scope,
381             code_challenge,
382             code_challenge_method,
383             keys_jwk,
384           } = payload.data;
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, {
394             client_id,
395             state,
396             scope,
397             code_challenge,
398             code_challenge_method,
399             keys_jwk,
400           });
401           break;
402         case "pair:supp:authorize":
403           stateMachine.assertState(
404             [PendingConfirmations, PendingRemoteConfirmation],
405             `Wrong state for ${message}`
406           );
407           curState.remoteConfirmed();
408           break;
409         default:
410           throw new Error(
411             `Received unknown Pairing Channel message: ${message}`
412           );
413       }
414     } catch (e) {
415       log.error(e);
416       curState.hasErrored(e);
417     }
418   }
420   onPrefViewClosed() {
421     const curState = this._stateMachine.currentState;
422     // We don't want to stop the pairing process in the later stages.
423     if (
424       curState instanceof SuppConnectionPending ||
425       curState instanceof Aborted ||
426       curState instanceof Errored
427     ) {
428       this.finalize();
429     }
430   }
432   /**
433    * Grant an OAuth authorization code for the connecting client.
434    *
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.
444    */
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" })
453           )
454         );
455         params.keys_jwe = await this._createKeysJWE(
456           sessionToken,
457           params.client_id,
458           params.scope,
459           jwk
460         );
461         delete params.keys_jwk;
462       }
463       try {
464         return await this._fxai.fxAccountsClient.oauthAuthorize(
465           sessionToken,
466           params
467         );
468       } catch (err) {
469         throw this._fxai._errorToErrorClass(err);
470       }
471     });
472   }
474   /**
475    * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow.
476    *
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
480    * true:
481    *
482    *   * This client must actually have the key.
483    *   * The other client must be allowed to request that key.
484    *
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
489    */
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(
494       sessionToken,
495       clientId,
496       scopes
497     );
498     const scopedKeys = {};
499     for (const scope of Object.keys(clientKeyData)) {
500       const key = await this._fxai.keys.getKeyForScope(scope);
501       if (!key) {
502         throw new Error(`Key not available for scope "${scope}"`);
503       }
504       scopedKeys[scope] = key;
505     }
506     return lazy.jwcrypto.generateJWE(
507       jwk,
508       new TextEncoder().encode(JSON.stringify(scopedKeys))
509     );
510   }