summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/RustFxAccount.js
diff options
context:
space:
mode:
Diffstat (limited to 'services/fxaccounts/RustFxAccount.js')
-rw-r--r--services/fxaccounts/RustFxAccount.js436
1 files changed, 436 insertions, 0 deletions
diff --git a/services/fxaccounts/RustFxAccount.js b/services/fxaccounts/RustFxAccount.js
new file mode 100644
index 0000000000..e6128e9283
--- /dev/null
+++ b/services/fxaccounts/RustFxAccount.js
@@ -0,0 +1,436 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["RustFxAccount"];
+
+/**
+ * This class is a low-level JS wrapper around the `mozIFirefoxAccountsBridge`
+ * interface.
+ * A `RustFxAccount` instance can be associated to 0 or 1 Firefox Account depending
+ * on its login state.
+ * This class responsibilities are to:
+ * - Expose an async JS interface to the methods in `mozIFirefoxAccountsBridge` by
+ * converting the callbacks-driven routines into proper JS promises.
+ * - Serialize and deserialize the input and outputs of `mozIFirefoxAccountsBridge`.
+ * Complex objects are generally returned through JSON strings.
+ */
+class RustFxAccount {
+ /**
+ * Create a new `RustFxAccount` instance, depending on the argument passed it could be:
+ * - From scratch (object passed).
+ * - Restore a previously serialized account (string passed).
+ * @param {(Object)|string} options Object type creates a new instance, string type restores an instance from a serialized state obtained with `stateJSON`.
+ * @param {string} options.fxaServer Content URL of the remote Firefox Accounts server.
+ * @param {string} options.clientId OAuth client_id of the application.
+ * @param {string} options.redirectUri Redirection URL to be navigated to at the end of the OAuth login flow.
+ * @param {string} [options.tokenServerUrlOverride] Override the token server URL: used by self-hosters of Sync.
+ */
+ constructor(options) {
+ // This initializes the network stack for all the Rust components.
+ let viaduct = Cc["@mozilla.org/toolkit/viaduct;1"].createInstance(
+ Ci.mozIViaduct
+ );
+ viaduct.EnsureInitialized();
+
+ this.bridge = Cc[
+ "@mozilla.org/services/firefox-accounts-bridge;1"
+ ].createInstance(Ci.mozIFirefoxAccountsBridge);
+
+ if (typeof options == "string") {
+ // Restore from JSON case.
+ this.bridge.initFromJSON(options);
+ } else {
+ // New instance case.
+ let props = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag
+ );
+ props.setProperty("content_url", options.fxaServer);
+ props.setProperty("client_id", options.clientId);
+ props.setProperty("redirect_uri", options.redirectUri);
+ props.setProperty(
+ "token_server_url_override",
+ options.tokenServerUrlOverride || ""
+ );
+ this.bridge.init(props);
+ }
+ }
+ /**
+ * Serialize the state of a `RustFxAccount` instance. It can be restored
+ * later by passing the resulting String back to the `RustFxAccount` constructor.
+ * It is the responsability of the caller to
+ * persist that serialized state regularly (after operations that mutate
+ * `RustFxAccount`) in a **secure** location.
+ * @returns {Promise<string>} The JSON representation of the state.
+ */
+ async stateJSON() {
+ return promisify(this.bridge.stateJSON);
+ }
+ /**
+ * Request a OAuth token by starting a new OAuth flow.
+ *
+ * Once the user has confirmed the authorization grant, they will get redirected to `redirect_url`:
+ * the caller must intercept that redirection; extract the `code` and `state` query parameters and call
+ * `completeOAuthFlow(...)` to complete the flow.
+ *
+ * @param {[string]} scopes
+ * @param {string} entryPoint - a string for metrics.
+ * @returns {Promise<string>} a URL string that the caller should navigate to.
+ */
+ async beginOAuthFlow(scopes, entryPoint = "desktop") {
+ return promisify(this.bridge.beginOAuthFlow, scopes, entryPoint);
+ }
+ /**
+ * Complete an OAuth flow initiated by `beginOAuthFlow(...)`.
+ *
+ * @param {string} code
+ * @param {string} state
+ * @throws if there was an error during the login flow.
+ */
+ async completeOAuthFlow(code, state) {
+ return promisify(this.bridge.completeOAuthFlow, code, state);
+ }
+ /**
+ * Try to get an OAuth access token.
+ *
+ * @typedef {Object} AccessTokenInfo
+ * @property {string} scope
+ * @property {string} token
+ * @property {ScopedKey} [key]
+ * @property {Date} expires_at
+ *
+ * @typedef {Object} ScopedKey
+ * @property {string} kty
+ * @property {string} scope
+ * @property {string} k
+ * @property {string} kid
+ *
+ * @param {string} scope Single OAuth scope
+ * @param {Number} [ttl] Time in seconds for which the token will be used.
+ * @returns {Promise<AccessTokenInfo>}
+ * @throws if we couldn't provide an access token
+ * for this scope. The caller should then start the OAuth Flow again with
+ * the desired scope.
+ */
+ async getAccessToken(scope, ttl) {
+ return JSON.parse(await promisify(this.bridge.getAccessToken, scope, ttl));
+ }
+ /**
+ * Get the session token if held.
+ *
+ * @returns {Promise<string>}
+ * @throws if a session token is not being held.
+ */
+ async getSessionToken() {
+ return promisify(this.bridge.getSessionToken);
+ }
+ /**
+ * Returns the list of OAuth attached clients.
+ *
+ * @typedef {Object} AttachedClient
+ * @property {string} [clientId]
+ * @property {string} [sessionTokenId]
+ * @property {string} [refreshTokenId]
+ * @property {string} [deviceId]
+ * @property {DeviceType} [deviceType]
+ * @property {boolean} isCurrentSession
+ * @property {string} [name]
+ * @property {Number} [createdTime]
+ * @property {Number} [lastAccessTime]
+ * @property {string[]} [scope]
+ * @property {string} userAgent
+ * @property {string} [os]
+ *
+ * @returns {Promise<[AttachedClient]>}
+ * @throws if a session token is not being held.
+ */
+ async getAttachedClients() {
+ return JSON.parse(await promisify(this.bridge.getAttachedClients));
+ }
+ /**
+ * Check whether the currently held refresh token is active.
+ *
+ * @typedef {Object} IntrospectInfo
+ * @property {boolean} active
+ *
+ * @returns {Promise<IntrospectInfo>}
+ */
+ async checkAuthorizationStatus() {
+ return JSON.parse(await promisify(this.bridge.checkAuthorizationStatus));
+ }
+ /*
+ * This method should be called when a request made with
+ * an OAuth token failed with an authentication error.
+ * It clears the internal cache of OAuth access tokens,
+ * so the caller can try to call `getAccessToken` or `getProfile`
+ * again.
+ */
+ async clearAccessTokenCache() {
+ return promisify(this.bridge.clearAccessTokenCache);
+ }
+ /*
+ * Disconnect from the account and optionaly destroy our device record.
+ * `beginOAuthFlow(...)` will need to be called to reconnect.
+ */
+ async disconnect() {
+ return promisify(this.bridge.disconnect);
+ }
+ /**
+ * Gets the logged-in user profile.
+ *
+ * @typedef {Object} Profile
+ * @property {string} uid
+ * @property {string} email
+ * @property {string} avatar
+ * @property {boolean} avatarDefault
+ * @property {string} [displayName]
+ *
+ * @param {boolean} [ignoreCache=false] Ignore the profile freshness threshold.
+ * @returns {Promise<Profile>}
+ * @throws if no suitable access token was found to make this call.
+ * The caller should then start the OAuth login flow again with
+ * at least the `profile` scope.
+ */
+ async getProfile(ignoreCache) {
+ return JSON.parse(await promisify(this.bridge.getProfile, ignoreCache));
+ }
+ /**
+ * Start a migration process from a session-token-based authenticated account.
+ *
+ * @typedef {Object} MigrationResult
+ * @property {Number} total_duration
+ *
+ * @param {string} sessionToken
+ * @param {string} kSync
+ * @param {string} kXCS
+ * @param {Boolean} copySessionToken
+ * @returns {Promise<MigrationResult>}
+ */
+ async migrateFromSessionToken(
+ sessionToken,
+ kSync,
+ kXCS,
+ copySessionToken = false
+ ) {
+ return JSON.parse(
+ await promisify(
+ this.bridge.migrateFromSessionToken,
+ sessionToken,
+ kSync,
+ kXCS,
+ copySessionToken
+ )
+ );
+ }
+ /**
+ * Retry a migration that failed earlier because of transient reasons.
+ *
+ * @returns {Promise<MigrationResult>}
+ */
+ async retryMigrateFromSessionToken() {
+ return JSON.parse(
+ await promisify(this.bridge.retryMigrateFromSessionToken)
+ );
+ }
+ /**
+ * Call this function after migrateFromSessionToken is un-successful
+ * (or after app startup) to figure out if we can call `retryMigrateFromSessionToken`.
+ *
+ * @returns {Promise<boolean>} true if a migration flow can be resumed.
+ */
+ async isInMigrationState() {
+ return promisify(this.bridge.isInMigrationState);
+ }
+ /**
+ * Called after a password change was done through webchannel.
+ *
+ * @param {string} sessionToken
+ */
+ async handleSessionTokenChange(sessionToken) {
+ return promisify(this.bridge.handleSessionTokenChange, sessionToken);
+ }
+ /**
+ * Get the token server URL with `1.0/sync/1.5` appended at the end.
+ *
+ * @returns {Promise<string>}
+ */
+ async getTokenServerEndpointURL() {
+ let url = await promisify(this.bridge.getTokenServerEndpointURL);
+ return `${url}${url.endsWith("/") ? "" : "/"}1.0/sync/1.5`;
+ }
+ /**
+ * @returns {Promise<string>}
+ */
+ async getConnectionSuccessURL() {
+ return promisify(this.bridge.getConnectionSuccessURL);
+ }
+ /**
+ * @param {string} entrypoint
+ * @returns {Promise<string>}
+ */
+ async getManageAccountURL(entrypoint) {
+ return promisify(this.bridge.getManageAccountURL, entrypoint);
+ }
+ /**
+ * @param {string} entrypoint
+ * @returns {Promise<string>}
+ */
+ async getManageDevicesURL(entrypoint) {
+ return promisify(this.bridge.getManageDevicesURL, entrypoint);
+ }
+ /**
+ * Fetch the devices in the account.
+ * @typedef {Object} Device
+ * @property {string} id
+ * @property {string} name
+ * @property {DeviceType} type
+ * @property {boolean} isCurrentDevice
+ * @property {Number} [lastAccessTime]
+ * @property {String} [pushAuthKey]
+ * @property {String} [pushCallback]
+ * @property {String} [pushPublicKey]
+ * @property {boolean} pushEndpointExpired
+ * @property {Object} availableCommands
+ * @property {Object} location
+ *
+ * @typedef {Object} DevicePushSubscription
+ * @property {string} endpoint
+ * @property {string} publicKey
+ * @property {string} authKey
+ *
+ * @param {boolean} [ignoreCache=false] Ignore the devices freshness threshold.
+ *
+ * @returns {Promise<[Device]>}
+ */
+ async fetchDevices(ignoreCache) {
+ return JSON.parse(await promisify(this.bridge.fetchDevices, ignoreCache));
+ }
+ /**
+ * Rename the local device
+ *
+ * @param {string} name
+ */
+ async setDeviceDisplayName(name) {
+ return promisify(this.bridge.setDeviceDisplayName, name);
+ }
+ /**
+ * Handle an incoming Push message payload.
+ *
+ * @typedef {Object} DeviceConnectedEvent
+ * @property {string} deviceName
+ *
+ * @typedef {Object} DeviceDisconnectedEvent
+ * @property {string} deviceId
+ * @property {boolean} isLocalDevice
+ *
+ * @param {string} payload
+ * @return {Promise<[TabReceivedCommand|DeviceConnectedEvent|DeviceDisconnectedEvent]>}
+ */
+ async handlePushMessage(payload) {
+ return JSON.parse(await promisify(this.bridge.handlePushMessage, payload));
+ }
+ /**
+ * Fetch for device commands we didn't receive through Push.
+ *
+ * @typedef {Object} TabReceivedCommand
+ * @property {Device} [from]
+ * @property {TabData} tabData
+ *
+ * @typedef {Object} TabData
+ * @property {string} title
+ * @property {string} url
+ *
+ * @returns {Promise<[TabReceivedCommand]>}
+ */
+ async pollDeviceCommands() {
+ return JSON.parse(await promisify(this.bridge.pollDeviceCommands));
+ }
+ /**
+ * Send a tab to a device identified by its ID.
+ *
+ * @param {string} targetId
+ * @param {string} title
+ * @param {string} url
+ */
+ async sendSingleTab(targetId, title, url) {
+ return promisify(this.bridge.sendSingleTab, targetId, title, url);
+ }
+ /**
+ * Update our FxA push subscription.
+ *
+ * @param {string} endpoint
+ * @param {string} publicKey
+ * @param {string} authKey
+ */
+ async setDevicePushSubscription(endpoint, publicKey, authKey) {
+ return promisify(
+ this.bridge.setDevicePushSubscription,
+ endpoint,
+ publicKey,
+ authKey
+ );
+ }
+ /**
+ * Initialize the local device (should be done only once after log-in).
+ *
+ * @param {string} name
+ * @param {DeviceType} deviceType
+ * @param {[DeviceCapability]} supportedCapabilities
+ */
+ async initializeDevice(name, deviceType, supportedCapabilities) {
+ return promisify(
+ this.bridge.initializeDevice,
+ name,
+ deviceType,
+ supportedCapabilities
+ );
+ }
+ /**
+ * Update the device capabilities if needed.
+ *
+ * @param {[DeviceCapability]} supportedCapabilities
+ */
+ async ensureCapabilities(supportedCapabilities) {
+ return promisify(this.bridge.ensureCapabilities, supportedCapabilities);
+ }
+}
+
+function promisify(func, ...params) {
+ return new Promise((resolve, reject) => {
+ func(...params, {
+ // This object implicitly implements
+ // `mozIFirefoxAccountsBridgeCallback`.
+ handleSuccess: resolve,
+ handleError(code, message) {
+ let error = new Error(message);
+ error.result = code;
+ reject(error);
+ },
+ });
+ });
+}
+
+/**
+ * @enum
+ */
+const DeviceType = Object.freeze({
+ desktop: "desktop",
+ mobile: "mobile",
+ tablet: "tablet",
+ tv: "tv",
+ vr: "vr",
+});
+
+/**
+ * @enum
+ */
+const DeviceCapability = Object.freeze({
+ sendTab: "sendTab",
+ fromCommandName(str) {
+ switch (str) {
+ case "https://identity.mozilla.com/cmd/open-uri":
+ return DeviceCapability.sendTab;
+ }
+ throw new Error("Unknown device capability.");
+ },
+});