summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/FxAccountsClient.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--services/fxaccounts/FxAccountsClient.sys.mjs815
1 files changed, 815 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsClient.sys.mjs b/services/fxaccounts/FxAccountsClient.sys.mjs
new file mode 100644
index 0000000000..1b50c59f4b
--- /dev/null
+++ b/services/fxaccounts/FxAccountsClient.sys.mjs
@@ -0,0 +1,815 @@
+/* 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/. */
+
+import { CommonUtils } from "resource://services-common/utils.sys.mjs";
+
+import { HawkClient } from "resource://services-common/hawkclient.sys.mjs";
+import { deriveHawkCredentials } from "resource://services-common/hawkrequest.sys.mjs";
+import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
+
+const {
+ ERRNO_ACCOUNT_DOES_NOT_EXIST,
+ ERRNO_INCORRECT_EMAIL_CASE,
+ ERRNO_INCORRECT_PASSWORD,
+ ERRNO_INVALID_AUTH_NONCE,
+ ERRNO_INVALID_AUTH_TIMESTAMP,
+ ERRNO_INVALID_AUTH_TOKEN,
+ log,
+} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+import { Credentials } from "resource://gre/modules/Credentials.sys.mjs";
+
+const HOST_PREF = "identity.fxaccounts.auth.uri";
+
+const SIGNIN = "/account/login";
+const SIGNUP = "/account/create";
+// Devices older than this many days will not appear in the devices list
+const DEVICES_FILTER_DAYS = 21;
+
+export 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;
+};
+
+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
+ */
+ async sessionStatus(sessionTokenHex) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ return this._request("/session/status", "GET", credentials).then(
+ () => Promise.resolve(true),
+ error => {
+ if (isInvalidTokenError(error)) {
+ return Promise.resolve(false);
+ }
+ throw error;
+ }
+ );
+ },
+
+ /**
+ * List all the clients connected to the authenticated user's account,
+ * including devices, OAuth clients, and web sessions.
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @return Promise
+ */
+ async attachedClients(sessionTokenHex) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ return this._requestWithHeaders(
+ "/account/attached_clients",
+ "GET",
+ credentials
+ );
+ },
+
+ /**
+ * Retrieves an OAuth authorization code.
+ *
+ * @param String sessionTokenHex
+ * The session token encoded in hex
+ * @param {Object} options
+ * @param options.client_id
+ * @param options.state
+ * @param options.scope
+ * @param options.access_type
+ * @param options.code_challenge_method
+ * @param options.code_challenge
+ * @param [options.keys_jwe]
+ * @returns {Promise<Object>} Object containing `code` and `state`.
+ */
+ async oauthAuthorize(sessionTokenHex, options) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ const body = {
+ client_id: options.client_id,
+ response_type: "code",
+ state: options.state,
+ scope: options.scope,
+ access_type: options.access_type,
+ code_challenge: options.code_challenge,
+ code_challenge_method: options.code_challenge_method,
+ };
+ if (options.keys_jwe) {
+ body.keys_jwe = options.keys_jwe;
+ }
+ return this._request("/oauth/authorization", "POST", credentials, body);
+ },
+
+ /**
+ * Destroy an OAuth access token or refresh token.
+ *
+ * @param String clientId
+ * @param String token The token to be revoked.
+ */
+ async oauthDestroy(clientId, token) {
+ const body = {
+ client_id: clientId,
+ token,
+ };
+ return this._request("/oauth/destroy", "POST", null, body);
+ },
+
+ /**
+ * Query for the information required to derive
+ * scoped encryption keys requested by the specified OAuth client.
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @param clientId
+ * @param scope
+ * Space separated list of scopes
+ * @return Promise
+ */
+ async getScopedKeyData(sessionTokenHex, clientId, scope) {
+ if (!clientId) {
+ throw new Error("Missing 'clientId' parameter");
+ }
+ if (!scope) {
+ throw new Error("Missing 'scope' parameter");
+ }
+ const params = {
+ client_id: clientId,
+ scope,
+ };
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ return this._request(
+ "/account/scoped-key-data",
+ "POST",
+ credentials,
+ params
+ );
+ },
+
+ /**
+ * Destroy the current session with the Firefox Account API server and its
+ * associated device.
+ *
+ * @param sessionTokenHex
+ * The session token encoded in hex
+ * @return Promise
+ */
+ async signOut(sessionTokenHex, options = {}) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ let path = "/session/destroy";
+ if (options.service) {
+ path += "?service=" + encodeURIComponent(options.service);
+ }
+ return this._request(path, "POST", credentials);
+ },
+
+ /**
+ * Check the verification status of the user's FxA email address
+ *
+ * @param sessionTokenHex
+ * The current session token encoded in hex
+ * @return Promise
+ */
+ async recoveryEmailStatus(sessionTokenHex, options = {}) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ let path = "/recovery_email/status";
+ if (options.reason) {
+ path += "?reason=" + encodeURIComponent(options.reason);
+ }
+
+ return this._request(path, "GET", credentials);
+ },
+
+ /**
+ * Resend the verification email for the user
+ *
+ * @param sessionTokenHex
+ * The current token encoded in hex
+ * @return Promise
+ */
+ async resendVerificationEmail(sessionTokenHex) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ return this._request("/recovery_email/resend_code", "POST", credentials);
+ },
+
+ /**
+ * 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)
+ * }
+ */
+ async accountKeys(keyFetchTokenHex) {
+ let creds = await deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
+ let keyRequestKey = creds.extra.slice(0, 32);
+ let morecreds = await CryptoUtils.hkdfLegacy(
+ keyRequestKey,
+ undefined,
+ Credentials.keyWord("account/keys"),
+ 3 * 32
+ );
+ let respHMACKey = morecreds.slice(0, 32);
+ let respXORKey = morecreds.slice(32, 96);
+
+ const resp = await this._request("/account/keys", "GET", creds);
+ if (!resp.bundle) {
+ throw new Error("failed to retrieve keys");
+ }
+
+ let bundle = CommonUtils.hexToBytes(resp.bundle);
+ let mac = bundle.slice(-32);
+ let key = CommonUtils.byteStringToArrayBuffer(respHMACKey);
+ // CryptoUtils.hmac takes ArrayBuffers as inputs for the key and data and
+ // returns an ArrayBuffer.
+ let bundleMAC = await CryptoUtils.hmac(
+ "SHA-256",
+ key,
+ CommonUtils.byteStringToArrayBuffer(bundle.slice(0, -32))
+ );
+ if (mac !== CommonUtils.arrayBufferToByteString(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),
+ };
+ },
+
+ /**
+ * Obtain an OAuth access token by authenticating using a session token.
+ *
+ * @param {String} sessionTokenHex
+ * The session token encoded in hex
+ * @param {String} clientId
+ * @param {String} scope
+ * List of space-separated scopes.
+ * @param {Number} ttl
+ * Token time to live.
+ * @return {Promise<Object>} Object containing an `access_token`.
+ */
+ async accessTokenWithSessionToken(sessionTokenHex, clientId, scope, ttl) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ const body = {
+ client_id: clientId,
+ grant_type: "fxa-credentials",
+ scope,
+ ttl,
+ };
+ return this._request("/oauth/token", "POST", credentials, body);
+ },
+
+ /**
+ * 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", 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)
+ * }
+ */
+ async registerDevice(sessionTokenHex, name, type, options = {}) {
+ let path = "/account/device";
+
+ let creds = await 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:
+ * {}
+ */
+ async notifyDevices(
+ sessionTokenHex,
+ deviceIds,
+ excludedIds,
+ payload,
+ TTL = 0
+ ) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ 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", credentials, 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.
+ */
+ async getCommands(sessionTokenHex, { index, limit }) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ 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", credentials);
+ },
+
+ /**
+ * 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)
+ */
+ async invokeCommand(sessionTokenHex, command, target, payload) {
+ const credentials = await deriveHawkCredentials(
+ sessionTokenHex,
+ "sessionToken"
+ );
+ const body = {
+ command,
+ target,
+ payload,
+ };
+ return this._request(
+ "/account/devices/invoke_command",
+ "POST",
+ credentials,
+ 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
+ * }
+ */
+ async updateDevice(sessionTokenHex, id, name, options = {}) {
+ let path = "/account/device";
+
+ let creds = await 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 that have been accessed
+ * in the last `DEVICES_FILTER_DAYS` days
+ *
+ * @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)
+ * },
+ * ...
+ * ]
+ */
+ async getDeviceList(sessionTokenHex) {
+ let timestamp = Date.now() - 1000 * 60 * 60 * 24 * DEVICES_FILTER_DAYS;
+ let path = `/account/devices?filterIdleDevicesTimestamp=${timestamp}`;
+ let creds = await 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 _requestWithHeaders(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}`, 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 { body: JSON.parse(response.body), headers: response.headers };
+ } catch (error) {
+ log.error("json parse error on response: " + response.body);
+ // eslint-disable-next-line no-throw-literal
+ throw { error };
+ }
+ },
+
+ async _request(path, method, credentials, jsonPayload) {
+ const response = await this._requestWithHeaders(
+ path,
+ method,
+ credentials,
+ jsonPayload
+ );
+ return response.body;
+ },
+};
+
+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;
+}