michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: this.EXPORTED_SYMBOLS = ["FxAccountsClient"]; michael@0: michael@0: const {classes: Cc, interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://gre/modules/Promise.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://services-common/utils.js"); michael@0: Cu.import("resource://services-common/hawkclient.js"); michael@0: Cu.import("resource://services-crypto/utils.js"); michael@0: Cu.import("resource://gre/modules/FxAccountsCommon.js"); michael@0: Cu.import("resource://gre/modules/Credentials.jsm"); michael@0: michael@0: const HOST = Services.prefs.getCharPref("identity.fxaccounts.auth.uri"); michael@0: michael@0: this.FxAccountsClient = function(host = HOST) { michael@0: this.host = host; michael@0: michael@0: // The FxA auth server expects requests to certain endpoints to be authorized michael@0: // using Hawk. michael@0: this.hawk = new HawkClient(host); michael@0: this.hawk.observerPrefix = "FxA:hawk"; michael@0: michael@0: // Manage server backoff state. C.f. michael@0: // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol michael@0: this.backoffError = null; michael@0: }; michael@0: michael@0: this.FxAccountsClient.prototype = { michael@0: michael@0: /** michael@0: * Return client clock offset, in milliseconds, as determined by hawk client. michael@0: * Provided because callers should not have to know about hawk michael@0: * implementation. michael@0: * michael@0: * The offset is the number of milliseconds that must be added to the client michael@0: * clock to make it equal to the server clock. For example, if the client is michael@0: * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. michael@0: */ michael@0: get localtimeOffsetMsec() { michael@0: return this.hawk.localtimeOffsetMsec; michael@0: }, michael@0: michael@0: /* michael@0: * Return current time in milliseconds michael@0: * michael@0: * Not used by this module, but made available to the FxAccounts.jsm michael@0: * that uses this client. michael@0: */ michael@0: now: function() { michael@0: return this.hawk.now(); michael@0: }, michael@0: michael@0: /** michael@0: * Create a new Firefox Account and authenticate michael@0: * michael@0: * @param email michael@0: * The email address for the account (utf8) michael@0: * @param password michael@0: * The user's password michael@0: * @return Promise michael@0: * Returns a promise that resolves to an object: michael@0: * { michael@0: * uid: the user's unique ID (hex) michael@0: * sessionToken: a session token (hex) michael@0: * keyFetchToken: a key fetch token (hex) michael@0: * } michael@0: */ michael@0: signUp: function(email, password) { michael@0: return Credentials.setup(email, password).then((creds) => { michael@0: let data = { michael@0: email: creds.emailUTF8, michael@0: authPW: CommonUtils.bytesAsHex(creds.authPW), michael@0: }; michael@0: return this._request("/account/create", "POST", null, data); michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Authenticate and create a new session with the Firefox Account API server michael@0: * michael@0: * @param email michael@0: * The email address for the account (utf8) michael@0: * @param password michael@0: * The user's password michael@0: * @param [getKeys=false] michael@0: * If set to true the keyFetchToken will be retrieved michael@0: * @param [retryOK=true] michael@0: * If capitalization of the email is wrong and retryOK is set to true, michael@0: * we will retry with the suggested capitalization from the server michael@0: * @return Promise michael@0: * Returns a promise that resolves to an object: michael@0: * { michael@0: * authAt: authentication time for the session (seconds since epoch) michael@0: * email: the primary email for this account michael@0: * keyFetchToken: a key fetch token (hex) michael@0: * sessionToken: a session token (hex) michael@0: * uid: the user's unique ID (hex) michael@0: * unwrapBKey: used to unwrap kB, derived locally from the michael@0: * password (not revealed to the FxA server) michael@0: * verified: flag indicating verification status of the email michael@0: * } michael@0: */ michael@0: signIn: function signIn(email, password, getKeys=false, retryOK=true) { michael@0: return Credentials.setup(email, password).then((creds) => { michael@0: let data = { michael@0: authPW: CommonUtils.bytesAsHex(creds.authPW), michael@0: email: creds.emailUTF8, michael@0: }; michael@0: let keys = getKeys ? "?keys=true" : ""; michael@0: michael@0: return this._request("/account/login" + keys, "POST", null, data).then( michael@0: // Include the canonical capitalization of the email in the response so michael@0: // the caller can set its signed-in user state accordingly. michael@0: result => { michael@0: result.email = data.email; michael@0: result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey); michael@0: michael@0: return result; michael@0: }, michael@0: error => { michael@0: log.debug("signIn error: " + JSON.stringify(error)); michael@0: // If the user entered an email with different capitalization from michael@0: // what's stored in the database (e.g., Greta.Garbo@gmail.COM as michael@0: // opposed to greta.garbo@gmail.com), the server will respond with a michael@0: // errno 120 (code 400) and the expected capitalization of the email. michael@0: // We retry with this email exactly once. If successful, we use the michael@0: // server's version of the email as the signed-in-user's email. This michael@0: // is necessary because the email also serves as salt; so we must be michael@0: // in agreement with the server on capitalization. michael@0: // michael@0: // API reference: michael@0: // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md michael@0: if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) { michael@0: if (!error.email) { michael@0: log.error("Server returned errno 120 but did not provide email"); michael@0: throw error; michael@0: } michael@0: return this.signIn(error.email, password, getKeys, false); michael@0: } michael@0: throw error; michael@0: } michael@0: ); michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Destroy the current session with the Firefox Account API server michael@0: * michael@0: * @param sessionTokenHex michael@0: * The session token encoded in hex michael@0: * @return Promise michael@0: */ michael@0: signOut: function (sessionTokenHex) { michael@0: return this._request("/session/destroy", "POST", michael@0: this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); michael@0: }, michael@0: michael@0: /** michael@0: * Check the verification status of the user's FxA email address michael@0: * michael@0: * @param sessionTokenHex michael@0: * The current session token encoded in hex michael@0: * @return Promise michael@0: */ michael@0: recoveryEmailStatus: function (sessionTokenHex) { michael@0: return this._request("/recovery_email/status", "GET", michael@0: this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); michael@0: }, michael@0: michael@0: /** michael@0: * Resend the verification email for the user michael@0: * michael@0: * @param sessionTokenHex michael@0: * The current token encoded in hex michael@0: * @return Promise michael@0: */ michael@0: resendVerificationEmail: function(sessionTokenHex) { michael@0: return this._request("/recovery_email/resend_code", "POST", michael@0: this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); michael@0: }, michael@0: michael@0: /** michael@0: * Retrieve encryption keys michael@0: * michael@0: * @param keyFetchTokenHex michael@0: * A one-time use key fetch token encoded in hex michael@0: * @return Promise michael@0: * Returns a promise that resolves to an object: michael@0: * { michael@0: * kA: an encryption key for recevorable data (bytes) michael@0: * wrapKB: an encryption key that requires knowledge of the michael@0: * user's password (bytes) michael@0: * } michael@0: */ michael@0: accountKeys: function (keyFetchTokenHex) { michael@0: let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken"); michael@0: let keyRequestKey = creds.extra.slice(0, 32); michael@0: let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined, michael@0: Credentials.keyWord("account/keys"), 3 * 32); michael@0: let respHMACKey = morecreds.slice(0, 32); michael@0: let respXORKey = morecreds.slice(32, 96); michael@0: michael@0: return this._request("/account/keys", "GET", creds).then(resp => { michael@0: if (!resp.bundle) { michael@0: throw new Error("failed to retrieve keys"); michael@0: } michael@0: michael@0: let bundle = CommonUtils.hexToBytes(resp.bundle); michael@0: let mac = bundle.slice(-32); michael@0: michael@0: let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, michael@0: CryptoUtils.makeHMACKey(respHMACKey)); michael@0: michael@0: let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher); michael@0: if (mac !== bundleMAC) { michael@0: throw new Error("error unbundling encryption keys"); michael@0: } michael@0: michael@0: let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64)); michael@0: michael@0: return { michael@0: kA: keyAWrapB.slice(0, 32), michael@0: wrapKB: keyAWrapB.slice(32) michael@0: }; michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Sends a public key to the FxA API server and returns a signed certificate michael@0: * michael@0: * @param sessionTokenHex michael@0: * The current session token encoded in hex michael@0: * @param serializedPublicKey michael@0: * A public key (usually generated by jwcrypto) michael@0: * @param lifetime michael@0: * The lifetime of the certificate michael@0: * @return Promise michael@0: * Returns a promise that resolves to the signed certificate. The certificate michael@0: * can be used to generate a Persona assertion. michael@0: */ michael@0: signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) { michael@0: let creds = this._deriveHawkCredentials(sessionTokenHex, "sessionToken"); michael@0: michael@0: let body = { publicKey: serializedPublicKey, michael@0: duration: lifetime }; michael@0: return Promise.resolve() michael@0: .then(_ => this._request("/certificate/sign", "POST", creds, body)) michael@0: .then(resp => resp.cert, michael@0: err => { michael@0: log.error("HAWK.signCertificate error: " + JSON.stringify(err)); michael@0: throw err; michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Determine if an account exists michael@0: * michael@0: * @param email michael@0: * The email address to check michael@0: * @return Promise michael@0: * The promise resolves to true if the account exists, or false michael@0: * if it doesn't. The promise is rejected on other errors. michael@0: */ michael@0: accountExists: function (email) { michael@0: return this.signIn(email, "").then( michael@0: (cantHappen) => { michael@0: throw new Error("How did I sign in with an empty password?"); michael@0: }, michael@0: (expectedError) => { michael@0: switch (expectedError.errno) { michael@0: case ERRNO_ACCOUNT_DOES_NOT_EXIST: michael@0: return false; michael@0: break; michael@0: case ERRNO_INCORRECT_PASSWORD: michael@0: return true; michael@0: break; michael@0: default: michael@0: // not so expected, any more ... michael@0: throw expectedError; michael@0: break; michael@0: } michael@0: } michael@0: ); michael@0: }, michael@0: michael@0: /** michael@0: * The FxA auth server expects requests to certain endpoints to be authorized using Hawk. michael@0: * Hawk credentials are derived using shared secrets, which depend on the context michael@0: * (e.g. sessionToken vs. keyFetchToken). michael@0: * michael@0: * @param tokenHex michael@0: * The current session token encoded in hex michael@0: * @param context michael@0: * A context for the credentials michael@0: * @param size michael@0: * The size in bytes of the expected derived buffer michael@0: * @return credentials michael@0: * Returns an object: michael@0: * { michael@0: * algorithm: sha256 michael@0: * id: the Hawk id (from the first 32 bytes derived) michael@0: * key: the Hawk key (from bytes 32 to 64) michael@0: * extra: size - 64 extra bytes michael@0: * } michael@0: */ michael@0: _deriveHawkCredentials: function (tokenHex, context, size) { michael@0: let token = CommonUtils.hexToBytes(tokenHex); michael@0: let out = CryptoUtils.hkdf(token, undefined, Credentials.keyWord(context), size || 3 * 32); michael@0: michael@0: return { michael@0: algorithm: "sha256", michael@0: key: out.slice(32, 64), michael@0: extra: out.slice(64), michael@0: id: CommonUtils.bytesAsHex(out.slice(0, 32)) michael@0: }; michael@0: }, michael@0: michael@0: _clearBackoff: function() { michael@0: this.backoffError = null; michael@0: }, michael@0: michael@0: /** michael@0: * A general method for sending raw API calls to the FxA auth server. michael@0: * All request bodies and responses are JSON. michael@0: * michael@0: * @param path michael@0: * API endpoint path michael@0: * @param method michael@0: * The HTTP request method michael@0: * @param credentials michael@0: * Hawk credentials michael@0: * @param jsonPayload michael@0: * A JSON payload michael@0: * @return Promise michael@0: * Returns a promise that resolves to the JSON response of the API call, michael@0: * or is rejected with an error. Error responses have the following properties: michael@0: * { michael@0: * "code": 400, // matches the HTTP status code michael@0: * "errno": 107, // stable application-level error number michael@0: * "error": "Bad Request", // string description of the error type michael@0: * "message": "the value of salt is not allowed to be undefined", michael@0: * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error michael@0: * } michael@0: */ michael@0: _request: function hawkRequest(path, method, credentials, jsonPayload) { michael@0: let deferred = Promise.defer(); michael@0: michael@0: // We were asked to back off. michael@0: if (this.backoffError) { michael@0: log.debug("Received new request during backoff, re-rejecting."); michael@0: deferred.reject(this.backoffError); michael@0: return deferred.promise; michael@0: } michael@0: michael@0: this.hawk.request(path, method, credentials, jsonPayload).then( michael@0: (responseText) => { michael@0: try { michael@0: let response = JSON.parse(responseText); michael@0: deferred.resolve(response); michael@0: } catch (err) { michael@0: log.error("json parse error on response: " + responseText); michael@0: deferred.reject({error: err}); michael@0: } michael@0: }, michael@0: michael@0: (error) => { michael@0: log.error("error " + method + "ing " + path + ": " + JSON.stringify(error)); michael@0: if (error.retryAfter) { michael@0: log.debug("Received backoff response; caching error as flag."); michael@0: this.backoffError = error; michael@0: // Schedule clearing of cached-error-as-flag. michael@0: CommonUtils.namedTimer( michael@0: this._clearBackoff, michael@0: error.retryAfter * 1000, michael@0: this, michael@0: "fxaBackoffTimer" michael@0: ); michael@0: } michael@0: deferred.reject(error); michael@0: } michael@0: ); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: }; michael@0: