/* 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";

import {
  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,
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";

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.getStringPref(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.sys.mjs
   * 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);
  },
  /**
   * Exchanges an OAuth authorization code with a refresh token, access tokens and an optional JWE representing scoped keys
   *  Takes in the sessionToken to tie the device record associated with the session, with the device record associated with the refreshToken
   *
   * @param string sessionTokenHex: The session token encoded in hex
   * @param String code: OAuth authorization code
   * @param String verifier: OAuth PKCE verifier
   * @param String clientId: OAuth client ID
   *
   * @returns { Object } object containing `refresh_token`, `access_token` and `keys_jwe`
   **/
  async oauthToken(sessionTokenHex, code, verifier, clientId) {
    const credentials = await deriveHawkCredentials(
      sessionTokenHex,
      "sessionToken"
    );
    const body = {
      grant_type: "authorization_code",
      code,
      client_id: clientId,
      code_verifier: verifier,
    };
    return this._request("/oauth/token", "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(
      () => {
        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;
}
