gecko-dev/services/fxaccounts/FxAccountsPairing.jsm

439 строки
12 KiB
JavaScript

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
"use strict";
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const {
log,
PREF_REMOTE_PAIRING_URI,
COMMAND_PAIR_SUPP_METADATA,
COMMAND_PAIR_AUTHORIZE,
COMMAND_PAIR_DECLINE,
COMMAND_PAIR_HEARTBEAT,
COMMAND_PAIR_COMPLETE,
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
const { fxAccounts, FxAccounts } = ChromeUtils.import(
"resource://gre/modules/FxAccounts.jsm"
);
const { setTimeout, clearTimeout } = ChromeUtils.import(
"resource://gre/modules/Timer.jsm"
);
ChromeUtils.import("resource://services-common/utils.js");
ChromeUtils.defineModuleGetter(
this,
"Weave",
"resource://services-sync/main.js"
);
ChromeUtils.defineModuleGetter(
this,
"FxAccountsPairingChannel",
"resource://gre/modules/FxAccountsPairingChannel.js"
);
const PAIRING_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel";
// A pairing flow is not tied to a specific browser window, can also finish in
// various ways and subsequently might leak a Web Socket, so just in case we
// time out and free-up the resources after a specified amount of time.
const FLOW_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes.
class PairingStateMachine {
constructor(emitter) {
this._emitter = emitter;
this._transition(SuppConnectionPending);
}
get currentState() {
return this._currentState;
}
_transition(StateCtor, ...args) {
const state = new StateCtor(this, ...args);
this._currentState = state;
}
assertState(RequiredStates, messagePrefix = null) {
if (!(RequiredStates instanceof Array)) {
RequiredStates = [RequiredStates];
}
if (
!RequiredStates.some(
RequiredState => this._currentState instanceof RequiredState
)
) {
const msg = `${
messagePrefix ? `${messagePrefix}. ` : ""
}Valid expected states: ${RequiredStates.map(({ name }) => name).join(
", "
)}. Current state: ${this._currentState.label}.`;
throw new Error(msg);
}
}
}
/**
* The pairing flow can be modeled by a finite state machine:
* We start by connecting to a WebSocket channel (SuppConnectionPending).
* Then the other party connects and requests some metadata from us (PendingConfirmations).
* A confirmation happens locally first (PendingRemoteConfirmation)
* or the oppposite (PendingLocalConfirmation).
* Any side can decline this confirmation (Aborted).
* Once both sides have confirmed, the pairing flow is finished (Completed).
* During this flow errors can happen and should be handled (Errored).
*/
class State {
constructor(stateMachine, ...args) {
this._transition = (...args) => stateMachine._transition(...args);
this._notify = (...args) => stateMachine._emitter.emit(...args);
this.init(...args);
}
init() {
/* Does nothing by default but can be re-implemented. */
}
get label() {
return this.constructor.name;
}
hasErrored(error) {
this._notify("view:Error", error);
this._transition(Errored, error);
}
hasAborted() {
this._transition(Aborted);
}
}
class SuppConnectionPending extends State {
suppConnected(sender, oauthOptions) {
this._transition(PendingConfirmations, sender, oauthOptions);
}
}
class PendingConfirmationsState extends State {
localConfirmed() {
throw new Error("Subclasses must implement this method.");
}
remoteConfirmed() {
throw new Error("Subclasses must implement this method.");
}
}
class PendingConfirmations extends PendingConfirmationsState {
init(sender, oauthOptions) {
this.sender = sender;
this.oauthOptions = oauthOptions;
}
localConfirmed() {
this._transition(PendingRemoteConfirmation);
}
remoteConfirmed() {
this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions);
}
}
class PendingLocalConfirmation extends PendingConfirmationsState {
init(sender, oauthOptions) {
this.sender = sender;
this.oauthOptions = oauthOptions;
}
localConfirmed() {
this._transition(Completed);
}
remoteConfirmed() {
throw new Error(
"Insane state! Remote has already been confirmed at this point."
);
}
}
class PendingRemoteConfirmation extends PendingConfirmationsState {
localConfirmed() {
throw new Error(
"Insane state! Local has already been confirmed at this point."
);
}
remoteConfirmed() {
this._transition(Completed);
}
}
class Completed extends State {}
class Aborted extends State {}
class Errored extends State {
init(error) {
this.error = error;
}
}
const flows = new Map();
this.FxAccountsPairingFlow = class FxAccountsPairingFlow {
static get(channelId) {
return flows.get(channelId);
}
static finalizeAll() {
for (const flow of flows) {
flow.finalize();
}
}
static async start(options) {
const { emitter } = options;
const fxaConfig = options.fxaConfig || FxAccounts.config;
const fxa = options.fxAccounts || fxAccounts;
const weave = options.weave || Weave;
const flowTimeout = options.flowTimeout || FLOW_TIMEOUT_MS;
const contentPairingURI = await fxaConfig.promisePairingURI();
const wsUri = Services.urlFormatter.formatURLPref(PREF_REMOTE_PAIRING_URI);
const pairingChannel =
options.pairingChannel || (await FxAccountsPairingChannel.create(wsUri));
const { channelId, channelKey } = pairingChannel;
const channelKeyB64 = ChromeUtils.base64URLEncode(channelKey, {
pad: false,
});
const pairingFlow = new FxAccountsPairingFlow({
channelId,
pairingChannel,
emitter,
fxa,
fxaConfig,
flowTimeout,
weave,
});
flows.set(channelId, pairingFlow);
return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`;
}
constructor(options) {
this._channelId = options.channelId;
this._pairingChannel = options.pairingChannel;
this._emitter = options.emitter;
this._fxa = options.fxa;
this._fxaConfig = options.fxaConfig;
this._weave = options.weave;
this._stateMachine = new PairingStateMachine(this._emitter);
this._setupListeners();
this._flowTimeoutId = setTimeout(
() => this._onFlowTimeout(),
options.flowTimeout
);
}
_onFlowTimeout() {
log.warn(`The pairing flow ${this._channelId} timed out.`);
this._onError(new Error("Timeout"));
this.finalize();
}
_closeChannel() {
if (!this._closed && !this._pairingChannel.closed) {
this._pairingChannel.close();
this._closed = true;
}
}
finalize() {
this._closeChannel();
clearTimeout(this._flowTimeoutId);
// Free up resources and let the GC do its thing.
flows.delete(this._channelId);
}
_setupListeners() {
this._pairingChannel.addEventListener(
"message",
({ detail: { sender, data } }) =>
this.onPairingChannelMessage(sender, data)
);
this._pairingChannel.addEventListener("error", event =>
this._onPairingChannelError(event.detail.error)
);
this._emitter.on("view:Closed", () => this.onPrefViewClosed());
}
_onAbort() {
this._stateMachine.currentState.hasAborted();
this.finalize();
}
_onError(error) {
this._stateMachine.currentState.hasErrored(error);
this._closeChannel();
}
_onPairingChannelError(error) {
log.error("Pairing channel error", error);
this._onError(error);
}
// Any non-falsy returned value is sent back through WebChannel.
async onWebChannelMessage(command) {
const stateMachine = this._stateMachine;
const curState = stateMachine.currentState;
try {
switch (command) {
case COMMAND_PAIR_SUPP_METADATA:
stateMachine.assertState(
[PendingConfirmations, PendingLocalConfirmation],
`Wrong state for ${command}`
);
const {
ua,
city,
region,
country,
remote: ipAddress,
} = curState.sender;
return { ua, city, region, country, ipAddress };
case COMMAND_PAIR_AUTHORIZE:
stateMachine.assertState(
[PendingConfirmations, PendingLocalConfirmation],
`Wrong state for ${command}`
);
const {
client_id,
state,
scope,
code_challenge,
code_challenge_method,
keys_jwk,
} = curState.oauthOptions;
const authorizeParams = {
client_id,
access_type: "offline",
state,
scope,
code_challenge,
code_challenge_method,
keys_jwk,
};
const codeAndState = await this._fxa.authorizeOAuthCode(
authorizeParams
);
if (codeAndState.state != state) {
throw new Error(`OAuth state mismatch`);
}
await this._pairingChannel.send({
message: "pair:auth:authorize",
data: {
...codeAndState,
},
});
curState.localConfirmed();
break;
case COMMAND_PAIR_DECLINE:
this._onAbort();
break;
case COMMAND_PAIR_HEARTBEAT:
if (curState instanceof Errored || this._pairingChannel.closed) {
return { err: curState.error.message || "Pairing channel closed" };
}
const suppAuthorized = !(
curState instanceof PendingConfirmations ||
curState instanceof PendingRemoteConfirmation
);
return { suppAuthorized };
case COMMAND_PAIR_COMPLETE:
this.finalize();
break;
default:
throw new Error(`Received unknown WebChannel command: ${command}`);
}
} catch (e) {
log.error(e);
curState.hasErrored(e);
}
return {};
}
async onPairingChannelMessage(sender, payload) {
const { message } = payload;
const stateMachine = this._stateMachine;
const curState = stateMachine.currentState;
try {
switch (message) {
case "pair:supp:request":
stateMachine.assertState(
SuppConnectionPending,
`Wrong state for ${message}`
);
const oauthUri = await this._fxaConfig.promiseOAuthURI();
const {
uid,
email,
avatar,
displayName,
} = await this._fxa.getSignedInUserProfile();
const deviceName = this._weave.Service.clientsEngine.localName;
await this._pairingChannel.send({
message: "pair:auth:metadata",
data: {
email,
avatar,
displayName,
deviceName,
},
});
const {
client_id,
state,
scope,
code_challenge,
code_challenge_method,
keys_jwk,
} = payload.data;
const url = new URL(oauthUri);
url.searchParams.append("client_id", client_id);
url.searchParams.append("scope", scope);
url.searchParams.append("email", email);
url.searchParams.append("uid", uid);
url.searchParams.append("channel_id", this._channelId);
url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI);
this._emitter.emit("view:SwitchToWebContent", url.href);
curState.suppConnected(sender, {
client_id,
state,
scope,
code_challenge,
code_challenge_method,
keys_jwk,
});
break;
case "pair:supp:authorize":
stateMachine.assertState(
[PendingConfirmations, PendingRemoteConfirmation],
`Wrong state for ${message}`
);
curState.remoteConfirmed();
break;
default:
throw new Error(
`Received unknown Pairing Channel message: ${message}`
);
}
} catch (e) {
log.error(e);
curState.hasErrored(e);
}
}
onPrefViewClosed() {
const curState = this._stateMachine.currentState;
// We don't want to stop the pairing process in the later stages.
if (
curState instanceof SuppConnectionPending ||
curState instanceof Aborted ||
curState instanceof Errored
) {
this.finalize();
}
}
};
const EXPORTED_SYMBOLS = ["FxAccountsPairingFlow"];