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 } = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
15 getFxAccountsSingleton,
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");
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",
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);
45 return this._currentState;
48 _transition(StateCtor, ...args) {
49 const state = new StateCtor(this, ...args);
50 this._currentState = state;
53 assertState(RequiredStates, messagePrefix = null) {
54 if (!(RequiredStates instanceof Array)) {
55 RequiredStates = [RequiredStates];
59 RequiredState => this._currentState instanceof RequiredState
63 messagePrefix ? `${messagePrefix}. ` : ""
64 }Valid expected states: ${RequiredStates.map(({ name }) => name).join(
66 )}. Current state: ${this._currentState.label}.`;
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).
83 constructor(stateMachine, ...args) {
84 this._transition = (...args) => stateMachine._transition(...args);
85 this._notify = (...args) => stateMachine._emitter.emit(...args);
90 /* Does nothing by default but can be re-implemented. */
94 return this.constructor.name;
98 this._notify("view:Error", error);
99 this._transition(Errored, error);
103 this._transition(Aborted);
106 class SuppConnectionPending extends State {
107 suppConnected(sender, oauthOptions) {
108 this._transition(PendingConfirmations, sender, oauthOptions);
111 class PendingConfirmationsState extends State {
113 throw new Error("Subclasses must implement this method.");
116 throw new Error("Subclasses must implement this method.");
119 class PendingConfirmations extends PendingConfirmationsState {
120 init(sender, oauthOptions) {
121 this.sender = sender;
122 this.oauthOptions = oauthOptions;
126 this._transition(PendingRemoteConfirmation);
130 this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions);
133 class PendingLocalConfirmation extends PendingConfirmationsState {
134 init(sender, oauthOptions) {
135 this.sender = sender;
136 this.oauthOptions = oauthOptions;
140 this._transition(Completed);
145 "Insane state! Remote has already been confirmed at this point."
149 class PendingRemoteConfirmation extends PendingConfirmationsState {
152 "Insane state! Local has already been confirmed at this point."
157 this._transition(Completed);
160 class Completed extends State {}
161 class Aborted extends State {}
162 class Errored extends State {
168 const flows = new Map();
170 export class FxAccountsPairingFlow {
171 static get(channelId) {
172 return flows.get(channelId);
175 static finalizeAll() {
176 for (const flow of flows) {
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, {
197 const pairingFlow = new FxAccountsPairingFlow({
206 flows.set(channelId, pairingFlow);
208 return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`;
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(),
228 log.warn(`The pairing flow ${this._channelId} timed out.`);
229 this._onError(new Error("Timeout"));
234 if (!this._closed && !this._pairingChannel.closed) {
235 this._pairingChannel.close();
241 this._closeChannel();
242 clearTimeout(this._flowTimeoutId);
243 // Free up resources and let the GC do its thing.
244 flows.delete(this._channelId);
248 this._pairingChannel.addEventListener(
250 ({ detail: { sender, data } }) =>
251 this.onPairingChannelMessage(sender, data)
253 this._pairingChannel.addEventListener("error", event =>
254 this._onPairingChannelError(event.detail.error)
256 this._emitter.on("view:Closed", () => this.onPrefViewClosed());
260 this._stateMachine.currentState.hasAborted();
265 this._stateMachine.currentState.hasErrored(error);
266 this._closeChannel();
269 _onPairingChannelError(error) {
270 log.error("Pairing channel error", error);
271 this._onError(error);
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;
280 case COMMAND_PAIR_SUPP_METADATA:
281 stateMachine.assertState(
282 [PendingConfirmations, PendingLocalConfirmation],
283 `Wrong state for ${command}`
292 return { ua, city, region, country, ipAddress };
293 case COMMAND_PAIR_AUTHORIZE:
294 stateMachine.assertState(
295 [PendingConfirmations, PendingLocalConfirmation],
296 `Wrong state for ${command}`
303 code_challenge_method,
305 } = curState.oauthOptions;
306 const authorizeParams = {
308 access_type: "offline",
312 code_challenge_method,
315 const codeAndState = await this._authorizeOAuthCode(authorizeParams);
316 if (codeAndState.state != state) {
317 throw new Error(`OAuth state mismatch`);
319 await this._pairingChannel.send({
320 message: "pair:auth:authorize",
325 curState.localConfirmed();
327 case COMMAND_PAIR_DECLINE:
330 case COMMAND_PAIR_HEARTBEAT:
331 if (curState instanceof Errored || this._pairingChannel.closed) {
332 return { err: curState.error.message || "Pairing channel closed" };
334 const suppAuthorized = !(
335 curState instanceof PendingConfirmations ||
336 curState instanceof PendingRemoteConfirmation
338 return { suppAuthorized };
339 case COMMAND_PAIR_COMPLETE:
343 throw new Error(`Received unknown WebChannel command: ${command}`);
347 curState.hasErrored(e);
352 async onPairingChannelMessage(sender, payload) {
353 const { message } = payload;
354 const stateMachine = this._stateMachine;
355 const curState = stateMachine.currentState;
358 case "pair:supp:request":
359 stateMachine.assertState(
360 SuppConnectionPending,
361 `Wrong state for ${message}`
363 const oauthUri = await this._fxaConfig.promiseOAuthURI();
369 } = await this._fxa.getSignedInUser();
370 const deviceName = this._weave.Service.clientsEngine.localName;
371 await this._pairingChannel.send({
372 message: "pair:auth:metadata",
385 code_challenge_method,
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, {
401 code_challenge_method,
405 case "pair:supp:authorize":
406 stateMachine.assertState(
407 [PendingConfirmations, PendingRemoteConfirmation],
408 `Wrong state for ${message}`
410 curState.remoteConfirmed();
414 `Received unknown Pairing Channel message: ${message}`
419 curState.hasErrored(e);
424 const curState = this._stateMachine.currentState;
425 // We don't want to stop the pairing process in the later stages.
427 curState instanceof SuppConnectionPending ||
428 curState instanceof Aborted ||
429 curState instanceof Errored
436 * Grant an OAuth authorization code for the connecting client.
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.
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" })
458 params.keys_jwe = await this._createKeysJWE(
464 delete params.keys_jwk;
467 return await this._fxai.fxAccountsClient.oauthAuthorize(
472 throw this._fxai._errorToErrorClass(err);
478 * Create a JWE to deliver keys to another client via the OAuth scoped-keys flow.
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
485 * * This client must actually have the key.
486 * * The other client must be allowed to request that key.
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
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(
501 const scopedKeys = {};
502 for (const scope of Object.keys(clientKeyData)) {
503 const key = await this._fxai.keys.getKeyForScope(scope);
505 throw new Error(`Key not available for scope "${scope}"`);
507 scopedKeys[scope] = key;
509 return lazy.jwcrypto.generateJWE(
511 new TextEncoder().encode(JSON.stringify(scopedKeys))