/* 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/. */ var EXPORTED_SYMBOLS = ["FxAccountsClient"]; ChromeUtils.import("resource://gre/modules/Services.jsm"); ChromeUtils.import("resource://services-common/utils.js"); ChromeUtils.import("resource://services-common/hawkclient.js"); ChromeUtils.import("resource://services-common/hawkrequest.js"); ChromeUtils.import("resource://services-crypto/utils.js"); ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js"); ChromeUtils.import("resource://gre/modules/Credentials.jsm"); const HOST_PREF = "identity.fxaccounts.auth.uri"; const SIGNIN = "/account/login"; const SIGNUP = "/account/create"; var FxAccountsClient = function(host = Services.prefs.getCharPref(HOST_PREF)) { this.host = host; // The FxA auth server expects requests to certain endpoints to be authorized // using Hawk. this.hawk = new HawkClient(host); this.hawk.observerPrefix = "FxA:hawk"; // Manage server backoff state. C.f. // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol this.backoffError = null; }; this.FxAccountsClient.prototype = { /** * Return client clock offset, in milliseconds, as determined by hawk client. * Provided because callers should not have to know about hawk * implementation. * * The offset is the number of milliseconds that must be added to the client * clock to make it equal to the server clock. For example, if the client is * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. */ get localtimeOffsetMsec() { return this.hawk.localtimeOffsetMsec; }, /* * Return current time in milliseconds * * Not used by this module, but made available to the FxAccounts.jsm * that uses this client. */ now() { return this.hawk.now(); }, /** * Common code from signIn and signUp. * * @param path * Request URL path. Can be /account/create or /account/login * @param email * The email address for the account (utf8) * @param password * The user's password * @param [getKeys=false] * If set to true the keyFetchToken will be retrieved * @param [retryOK=true] * If capitalization of the email is wrong and retryOK is set to true, * we will retry with the suggested capitalization from the server * @return Promise * Returns a promise that resolves to an object: * { * authAt: authentication time for the session (seconds since epoch) * email: the primary email for this account * keyFetchToken: a key fetch token (hex) * sessionToken: a session token (hex) * uid: the user's unique ID (hex) * unwrapBKey: used to unwrap kB, derived locally from the * password (not revealed to the FxA server) * verified (optional): flag indicating verification status of the * email * } */ _createSession(path, email, password, getKeys = false, retryOK = true) { return Credentials.setup(email, password).then((creds) => { let data = { authPW: CommonUtils.bytesAsHex(creds.authPW), email, }; let keys = getKeys ? "?keys=true" : ""; return this._request(path + keys, "POST", null, data).then( // Include the canonical capitalization of the email in the response so // the caller can set its signed-in user state accordingly. result => { result.email = data.email; result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey); return result; }, error => { log.debug("Session creation failed", error); // If the user entered an email with different capitalization from // what's stored in the database (e.g., Greta.Garbo@gmail.COM as // opposed to greta.garbo@gmail.com), the server will respond with a // errno 120 (code 400) and the expected capitalization of the email. // We retry with this email exactly once. If successful, we use the // server's version of the email as the signed-in-user's email. This // is necessary because the email also serves as salt; so we must be // in agreement with the server on capitalization. // // API reference: // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) { if (!error.email) { log.error("Server returned errno 120 but did not provide email"); throw error; } return this._createSession(path, error.email, password, getKeys, false); } throw error; } ); }); }, /** * Create a new Firefox Account and authenticate * * @param email * The email address for the account (utf8) * @param password * The user's password * @param [getKeys=false] * If set to true the keyFetchToken will be retrieved * @return Promise * Returns a promise that resolves to an object: * { * uid: the user's unique ID (hex) * sessionToken: a session token (hex) * keyFetchToken: a key fetch token (hex), * unwrapBKey: used to unwrap kB, derived locally from the * password (not revealed to the FxA server) * } */ signUp(email, password, getKeys = false) { return this._createSession(SIGNUP, email, password, getKeys, false /* no retry */); }, /** * Authenticate and create a new session with the Firefox Account API server * * @param email * The email address for the account (utf8) * @param password * The user's password * @param [getKeys=false] * If set to true the keyFetchToken will be retrieved * @return Promise * Returns a promise that resolves to an object: * { * authAt: authentication time for the session (seconds since epoch) * email: the primary email for this account * keyFetchToken: a key fetch token (hex) * sessionToken: a session token (hex) * uid: the user's unique ID (hex) * unwrapBKey: used to unwrap kB, derived locally from the * password (not revealed to the FxA server) * verified: flag indicating verification status of the email * } */ signIn: function signIn(email, password, getKeys = false) { return this._createSession(SIGNIN, email, password, getKeys, true /* retry */); }, /** * Check the status of a session given a session token * * @param sessionTokenHex * The session token encoded in hex * @return Promise * Resolves with a boolean indicating if the session is still valid */ sessionStatus(sessionTokenHex) { return this._request("/session/status", "GET", deriveHawkCredentials(sessionTokenHex, "sessionToken")).then( () => Promise.resolve(true), error => { if (isInvalidTokenError(error)) { return Promise.resolve(false); } throw error; } ); }, /** * Destroy the current session with the Firefox Account API server and its * associated device. * * @param sessionTokenHex * The session token encoded in hex * @return Promise */ signOut(sessionTokenHex, options = {}) { let path = "/session/destroy"; if (options.service) { path += "?service=" + encodeURIComponent(options.service); } return this._request(path, "POST", deriveHawkCredentials(sessionTokenHex, "sessionToken")); }, /** * Check the verification status of the user's FxA email address * * @param sessionTokenHex * The current session token encoded in hex * @return Promise */ recoveryEmailStatus(sessionTokenHex, options = {}) { let path = "/recovery_email/status"; if (options.reason) { path += "?reason=" + encodeURIComponent(options.reason); } return this._request(path, "GET", deriveHawkCredentials(sessionTokenHex, "sessionToken")); }, /** * Resend the verification email for the user * * @param sessionTokenHex * The current token encoded in hex * @return Promise */ resendVerificationEmail(sessionTokenHex) { return this._request("/recovery_email/resend_code", "POST", deriveHawkCredentials(sessionTokenHex, "sessionToken")); }, /** * Retrieve encryption keys * * @param keyFetchTokenHex * A one-time use key fetch token encoded in hex * @return Promise * Returns a promise that resolves to an object: * { * kA: an encryption key for recevorable data (bytes) * wrapKB: an encryption key that requires knowledge of the * user's password (bytes) * } */ accountKeys(keyFetchTokenHex) { let creds = deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken"); let keyRequestKey = creds.extra.slice(0, 32); let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined, Credentials.keyWord("account/keys"), 3 * 32); let respHMACKey = morecreds.slice(0, 32); let respXORKey = morecreds.slice(32, 96); return this._request("/account/keys", "GET", creds).then(resp => { if (!resp.bundle) { throw new Error("failed to retrieve keys"); } let bundle = CommonUtils.hexToBytes(resp.bundle); let mac = bundle.slice(-32); let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, CryptoUtils.makeHMACKey(respHMACKey)); let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher); if (mac !== bundleMAC) { throw new Error("error unbundling encryption keys"); } let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64)); return { kA: keyAWrapB.slice(0, 32), wrapKB: keyAWrapB.slice(32), }; }); }, /** * Sends a public key to the FxA API server and returns a signed certificate * * @param sessionTokenHex * The current session token encoded in hex * @param serializedPublicKey * A public key (usually generated by jwcrypto) * @param lifetime * The lifetime of the certificate * @return Promise * Returns a promise that resolves to the signed certificate. * The certificate can be used to generate a Persona assertion. * @throws a new Error * wrapping any of these HTTP code/errno pairs: * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-12 */ signCertificate(sessionTokenHex, serializedPublicKey, lifetime) { let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); let body = { publicKey: serializedPublicKey, duration: lifetime }; return Promise.resolve() .then(_ => this._request("/certificate/sign", "POST", creds, body)) .then(resp => resp.cert, err => { log.error("HAWK.signCertificate error: " + JSON.stringify(err)); throw err; }); }, /** * Determine if an account exists * * @param email * The email address to check * @return Promise * The promise resolves to true if the account exists, or false * if it doesn't. The promise is rejected on other errors. */ accountExists(email) { return this.signIn(email, "").then( (cantHappen) => { throw new Error("How did I sign in with an empty password?"); }, (expectedError) => { switch (expectedError.errno) { case ERRNO_ACCOUNT_DOES_NOT_EXIST: return false; case ERRNO_INCORRECT_PASSWORD: return true; default: // not so expected, any more ... throw expectedError; } } ); }, /** * Given the uid of an existing account (not an arbitrary email), ask * the server if it still exists via /account/status. * * Used for differentiating between password change and account deletion. */ accountStatus(uid) { return this._request("/account/status?uid=" + uid, "GET").then( (result) => { return result.exists; }, (error) => { log.error("accountStatus failed with: " + error); return Promise.reject(error); } ); }, /** * Register a new device * * @method registerDevice * @param sessionTokenHex * Session token obtained from signIn * @param name * Device name * @param type * Device type (mobile|desktop) * @param [options] * Extra device options * @param [options.availableCommands] * Available commands for this device * @param [options.pushCallback] * `pushCallback` push endpoint callback * @param [options.pushPublicKey] * `pushPublicKey` push public key (URLSafe Base64 string) * @param [options.pushAuthKey] * `pushAuthKey` push auth secret (URLSafe Base64 string) * @return Promise * Resolves to an object: * { * id: Device identifier * createdAt: Creation time (milliseconds since epoch) * name: Name of device * type: Type of device (mobile|desktop) * } */ registerDevice(sessionTokenHex, name, type, options = {}) { let path = "/account/device"; let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); let body = { name, type }; if (options.pushCallback) { body.pushCallback = options.pushCallback; } if (options.pushPublicKey && options.pushAuthKey) { body.pushPublicKey = options.pushPublicKey; body.pushAuthKey = options.pushAuthKey; } body.availableCommands = options.availableCommands; return this._request(path, "POST", creds, body); }, /** * Sends a message to other devices. Must conform with the push payload schema: * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json * * @method notifyDevice * @param sessionTokenHex * Session token obtained from signIn * @param deviceIds * Devices to send the message to. If null, will be sent to all devices. * @param excludedIds * Devices to exclude when sending to all devices (deviceIds must be null). * @param payload * Data to send with the message * @return Promise * Resolves to an empty object: * {} */ notifyDevices(sessionTokenHex, deviceIds, excludedIds, payload, TTL = 0) { if (deviceIds && excludedIds) { throw new Error("You cannot specify excluded devices if deviceIds is set."); } const body = { to: deviceIds || "all", payload, TTL, }; if (excludedIds) { body.excluded = excludedIds; } return this._request("/account/devices/notify", "POST", deriveHawkCredentials(sessionTokenHex, "sessionToken"), body); }, /** * Retrieves pending commands for our device. * * @method getCommands * @param sessionTokenHex - Session token obtained from signIn * @param [index] - If specified, only messages received after the one who * had that index will be retrieved. * @param [limit] - Maximum number of messages to retrieve. */ getCommands(sessionTokenHex, {index, limit}) { const params = new URLSearchParams(); if (index != undefined) { params.set("index", index); } if (limit != undefined) { params.set("limit", limit); } const path = `/account/device/commands?${params.toString()}`; return this._request(path, "GET", deriveHawkCredentials(sessionTokenHex, "sessionToken")); }, /** * Invokes a command on another device. * * @method invokeCommand * @param sessionTokenHex - Session token obtained from signIn * @param command - Name of the command to invoke * @param target - Recipient device ID. * @param payload * @return Promise * Resolves to the request's response, (which should be an empty object) */ invokeCommand(sessionTokenHex, command, target, payload) { const body = { command, target, payload, }; return this._request("/account/devices/invoke_command", "POST", deriveHawkCredentials(sessionTokenHex, "sessionToken"), body); }, /** * Update the session or name for an existing device * * @method updateDevice * @param sessionTokenHex * Session token obtained from signIn * @param id * Device identifier * @param name * Device name * @param [options] * Extra device options * @param [options.availableCommands] * Available commands for this device * @param [options.pushCallback] * `pushCallback` push endpoint callback * @param [options.pushPublicKey] * `pushPublicKey` push public key (URLSafe Base64 string) * @param [options.pushAuthKey] * `pushAuthKey` push auth secret (URLSafe Base64 string) * @return Promise * Resolves to an object: * { * id: Device identifier * name: Device name * } */ updateDevice(sessionTokenHex, id, name, options = {}) { let path = "/account/device"; let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); let body = { id, name }; if (options.pushCallback) { body.pushCallback = options.pushCallback; } if (options.pushPublicKey && options.pushAuthKey) { body.pushPublicKey = options.pushPublicKey; body.pushAuthKey = options.pushAuthKey; } body.availableCommands = options.availableCommands; return this._request(path, "POST", creds, body); }, /** * Get a list of currently registered devices * * @method getDeviceList * @param sessionTokenHex * Session token obtained from signIn * @return Promise * Resolves to an array of objects: * [ * { * id: Device id * isCurrentDevice: Boolean indicating whether the item * represents the current device * name: Device name * type: Device type (mobile|desktop) * }, * ... * ] */ getDeviceList(sessionTokenHex) { let path = "/account/devices"; let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken"); return this._request(path, "GET", creds, {}); }, _clearBackoff() { this.backoffError = null; }, /** * A general method for sending raw API calls to the FxA auth server. * All request bodies and responses are JSON. * * @param path * API endpoint path * @param method * The HTTP request method * @param credentials * Hawk credentials * @param jsonPayload * A JSON payload * @return Promise * Returns a promise that resolves to the JSON response of the API call, * or is rejected with an error. Error responses have the following properties: * { * "code": 400, // matches the HTTP status code * "errno": 107, // stable application-level error number * "error": "Bad Request", // string description of the error type * "message": "the value of salt is not allowed to be undefined", * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error * } */ async _request(path, method, credentials, jsonPayload) { // We were asked to back off. if (this.backoffError) { log.debug("Received new request during backoff, re-rejecting."); throw this.backoffError; } let response; try { response = await this.hawk.request(path, method, credentials, jsonPayload); } catch (error) { log.error("error " + method + "ing " + path + ": " + JSON.stringify(error)); if (error.retryAfter) { log.debug("Received backoff response; caching error as flag."); this.backoffError = error; // Schedule clearing of cached-error-as-flag. CommonUtils.namedTimer( this._clearBackoff, error.retryAfter * 1000, this, "fxaBackoffTimer" ); } throw error; } try { return JSON.parse(response.body); } catch (error) { log.error("json parse error on response: " + response.body); // eslint-disable-next-line no-throw-literal throw {error}; } }, }; function isInvalidTokenError(error) { if (error.code != 401) { return false; } switch (error.errno) { case ERRNO_INVALID_AUTH_TOKEN: case ERRNO_INVALID_AUTH_TIMESTAMP: case ERRNO_INVALID_AUTH_NONCE: return true; } return false; }