gecko-dev/services/fxaccounts/FxAccountsClient.jsm

637 строки
21 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/. */
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;
}