api/auth.js

// Copyright 2018 MaidSafe.net limited.
//
// This SAFE Network Software is licensed to you under
// the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT> or
// the Modified BSD license <LICENSE-BSD or https://opensource.org/licenses/BSD-3-Clause>,
// at your option.
//
// This file may not be copied, modified, or distributed except according to those terms.
//
// Please review the Licences for the specific language governing permissions and limitations
// relating to use of the SAFE Network Software.


const lib = require('../native/lib');
const nativeH = require('../native/helpers');
const types = require('../native/types');
const { useMockByDefault } = require('../helpers');
const { validateShareMDataPermissions } = require('../helpers');
const errConst = require('../error_const');
const makeError = require('../native/_error.js');

const makeAppInfo = nativeH.makeAppInfo;
const makePermissions = nativeH.makePermissions;
const makeShareMDataPermissions = nativeH.makeShareMDataPermissions;

/**
* @private
* Generates the app's URI converting the string into a base64 format, removing
* characters or symbols which are not valid for a URL like '=' sign,
* and making it lower case.
*/
const genAppUri = (str) => {
  const urlSafeBase64 = (Buffer.from(str))
                          .toString('base64')
                          .replace(/\+/g, '-') // Convert '+' to '-'
                          .replace(/\//g, '_') // Convert '/' to '_'
                          .replace(/=+$/, '') // Remove ending '='
                          .toLowerCase();
  return `safe-${urlSafeBase64}`;
};

/**
* @private
* Prefix the URI with safe-auth protocol
*/
const addSafeAuthProtocol = (response) => {
  response.uri = `safe-auth:${response.uri}`; // eslint-disable-line no-param-reassign
  return response;
};

/**
* @private
* Remove 'safe' protocol from URI in order to be able to decode it.
* Also, remove any '/' characters that could have been added after the ':' by
* some OS like Fedora, making the URI invalid for decoding.
* This characters are not added by the authenticator, we therefore
* don't have much choice than just make sure we remove them from here.
*/
const removeSafeProtocol = (uri) => uri.replace(/^safe-[^:]*:?[/]*/g, '');

/**
* Contains all authentication related functionality
*/
class AuthInterface {

  /**
  * @hideconstructor
  */
  constructor(app) {
    this.app = app;
    this._registered = false;
    this.setupUri();
  }

  /**
  * @private
  * Generate the app's URI for the IPC protocol using the app's id
  * and register the URI scheme.
  */
  setupUri() {
    const appInfo = this.app.appInfo;
    const opts = this.app.options;
    let scheme;
    if (opts.registerScheme) {
      scheme = genAppUri(appInfo.id);
    }
    if (opts.joinSchemes && opts.joinSchemes.length > 0) {
      scheme = scheme ? [scheme].concat(opts.joinSchemes) : opts.joinSchemes;
    }
    if (scheme) {
      lib.registerUriScheme({
        bundle: appInfo.bundle ? appInfo.bundle : appInfo.id,
        vendor: appInfo.vendor,
        name: appInfo.name,
        icon: 'test',
        exec: appInfo.customExecPath }, scheme);
    }
  }

  /**
   * Whether or not this is a registered/authenticated session.
   *
   * @returns {Boolean} true if this is an authenticated session
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   * const isRegistered = app.auth.registered;
   */
  get registered() {
    return this._registered;
  }

  /**
   * Generate an authentication URI for the app with
   * the given permissions and optional parameters.
   *
   * @param {Object} permissions - mapping the container-names
   *                  to a list of permissions you want to
   *                  request
   * @param {Object} opts
   * @param {Boolean} [opts.own_container=false] - whether or not to request
   *    app's own container to be created
   *
   * @returns {Promise<String>} safe-auth URI
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   *
   * const containerPermissions =
   * {
   *   _public: [
   *     'Read',
   *     'Insert',
   *     'Update',
   *     'Delete'
   *   ],
   *   _publicNames: [
   *     'Read',
   *     'Insert',
   *     'Update',
   *     'Delete'
   *   ]
   * };
   * const authorisationOptions = {own_container: true};
   *
   * const asyncFn = async () => {
   *     try {
   *         const authReqUri = await app.auth.genAuthUri(
   *             containerPermissions,
   *             authorisationOptions
   *         );
   *     } catch (err) {
   *         throw err;
   *     }
   * };
   */
  genAuthUri(permissions, opts) {
    const perm = makePermissions(permissions);
    const appInfo = makeAppInfo(this.app.appInfo);
    return lib.encode_auth_req(new types.AuthReq({
      app: appInfo,
      app_container: !!(opts && opts.own_container),
      containers: perm,
      containers_len: perm.length,
      containers_cap: perm.length
    }).ref())
    .then(addSafeAuthProtocol);
  }

  /**
   * Generate a safe-auth URI to request permissions on arbitrary owned MutableData's.
   * Necessary when an authorised app needs access to a MutableData that was created by
   * another application and is also owned by current account.
   *
   * @param {Object} permissions - mapping the MutableData's XoR names
   *                  to a list of permissions you want to request
   *
   * @returns {Promise<String>} safe-auth URI
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   *
   * const permissions = [
   *   {
   *     typeTag: 15001,
   *     name: mutableDataXorName,
   *     perms: ['Insert']
   *   }
   * ];
   *
   * const asyncFn = async () => {
   *     try {
   *         const shareMDataReqUri = await app.auth.genShareMDataUri(permissions);
   *     } catch (err) {
   *         throw err;
   *     }
   * };
   */
  genShareMDataUri(permissions) {
    validateShareMDataPermissions(permissions);
    const mdatasPerms = makeShareMDataPermissions(permissions);
    const appInfo = makeAppInfo(this.app.appInfo);
    return lib.encode_share_mdata_req(new types.ShareMDataReq({
      app: appInfo,
      mdata: mdatasPerms,
      mdata_len: mdatasPerms.length
    }).ref())
    .then(addSafeAuthProtocol);
  }

  /**
   * Generate an unregistered connection URI for the app,
   * especially for simply browsing and reading data on the network.
   *
   * @returns {Promise<String>} safe-auth URI
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   * const asyncFn = async () => {
   *     const app = await safe.initialiseApp(appInfo);
   *     try {
   *         const unRegisteredUri = await app.auth.genConnUri();
   *     } catch (err) {
   *         throw err;
   *     }
   * };
   */
  genConnUri() { // eslint-disable-line class-methods-use-this
    return lib.encode_unregistered_req(this.app.appInfo.id)
      .then(addSafeAuthProtocol);
  }

  /**
   * Opens URI with system, using respective registered application.
   * @param {String} uri Authententication
   * @returns <Promise>
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   * const asyncFn = async () => {
   *     const app = await safe.initialiseApp(appInfo);
   *     try {
   *         await app.auth.openUri('safe://shouldOpenSafeBrowser');
   *     } catch (err) {
   *         throw err;
   *     }
   * };
   */
  openUri(uri) { // eslint-disable-line class-methods-use-this
    return lib.openUri(uri);
  }

  /**
   * Generate a safe-auth URI to request further container permissions.
   *
   * @param {Object} containers mapping container name to list of permissions
   * @returns {Promise<String>} safe-auth URI
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   * const containerPermissions =
   * {
   *   _videos: [
   *     'Insert'
   *   ]
   * };
   *
   * const asyncFn = async () => {
   *     try {
   *         const contReqUri = await app.auth.genContainerAuthUri(containerPermissions);
   *     } catch (err) {
   *         throw err;
   *     }
   * };
   */
  genContainerAuthUri(containers) {
    const ctnrs = makePermissions(containers);
    const appInfo = makeAppInfo(this.app.appInfo);
    return lib.encode_containers_req(new types.ContainerReq({
      app: appInfo,
      containers: ctnrs,
      containers_len: ctnrs.length,
      containers_cap: ctnrs.length
    }).ref())
    .then(addSafeAuthProtocol);
  }

  /**
   * Refresh the access persmissions from the network. Useful when you just
   * connected or received a response from the authenticator in the IPC protocol.
   * @return {Promise}
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   * const permissions = {
   *     _public: ['Read', 'Insert', 'Update', 'Delete', 'ManagePermissions']
   * };
   *
   * const asyncFn = async () => {
   *     try {
   *         const authReqUri = await app.auth.genAuthUri(permissions, {});
   *         let authUri = await safe.authorise(authReqUri);
   *         await app.auth.refreshContainersPermissions();
   *         const mData = await app.auth.getContainer('_public');
   *         let permsObject = await app.auth.getContainersPermissions();
   *
   *         console.log(permsObject);
   *
   *         const updatePermissions = {
   *           _publicNames: ['Read', 'Insert', 'Update', 'Delete', 'ManagePermissions']
   *         }
   *         let contReqUri = await app.auth.genContainerAuthUri(updatePermissions);
   *         authUri = await safe.authorise(contReqUri);
   *
   *         console.log(permsObject);
   *
   *         await app.auth.refreshContainersPermissions();
   *         permsObject = await app.auth.getContainersPermissions();
   *
   *         console.log(permsObject);
   *     } catch (err) {
   *        throw err;
   *     }
   * };
   */
  refreshContainersPermissions() {
    return lib.access_container_refresh_access_info(this.app.connection);
  }

  /**
   * Get the names of all containers found and the app's granted
   * permissions for each of them.
   *
   * @returns {Promise<Array>}
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   * const asyncFn = async () => {
   *     try {
   *         const containerPermissions = await app.auth.getContainersPermissions();
   *     } catch (err) {
   *         throw err;
   *     }
   * };
   */
  getContainersPermissions() {
    return lib.access_container_fetch(this.app.connection);
  }

  /**
   * Read granted containers permissions from an auth URI
   * without the need to connect to the network.
   *
   * This function appears redundant to app.auth.getContainersPermissions, however the difference
   * is that readGrantedPermissions doesn't require an authorised app connection.
   *
   * @param {String} uri the IPC response string given
   * @throws {NON_AUTH_GRANTED_URI}
   * @returns {Promise<Array>}
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   * const asyncFn = async () => {
   *     try {
   *         const authReqUri = await app.auth.genAuthUri({});
   *         await app.auth.openUri(authReqUri);
   *         const containerPermissions = await app.auth.readGrantedPermissions(
   *             < returned auth URI from openUri >
   *         );
   *     } catch (err) {
   *         throw err;
   *     }
   * };
   */
  readGrantedPermissions(uri) { // eslint-disable-line class-methods-use-this
    const sanitisedUri = removeSafeProtocol(uri);

    return lib.decode_ipc_msg(sanitisedUri)
      .then((resp) => {
        if (resp[0] !== 'granted') {
          throw makeError(errConst.NON_AUTH_GRANTED_URI.code, errConst.NON_AUTH_GRANTED_URI.msg);
        }
        const authGranted = resp[1];
        const contsPerms = {};
        authGranted.access_container_entry.forEach((cont) => {
          contsPerms[cont.name] = {
            Read: cont.permissions.Read,
            Insert: cont.permissions.Insert,
            Update: cont.permissions.Update,
            Delete: cont.permissions.Delete,
            ManagePermissions: cont.permissions.ManagePermissions
          };
        });
        return contsPerms;
      }).catch((err) => Promise.reject(err));
  }

  /**
  * Get the MutableData for the app's root container.
  * When run in tests, this falls back to the randomly generated version
  * @returns {Promise<MutableData>}
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   * const asyncFn = async () => {
   *     try {
   *         const authReqUri = await app.auth.genAuthUri({}, { own_container: true });
   *         await app.auth.openUri(authReqUri);
   *         // After URI is opened by SAFE Authenticator and authorised,
   *         // this snippet assumes that your application has an
   *         // IPC strategy to receive returned authorisation URI.
   *         await app.auth.loginFromUri(authUri);
   *         const mutableDataInterface = await app.auth.getOwnContainer();
   *     } catch (err) {
   *         throw err;
   *     }
   * };
  */
  getOwnContainer() {
    return this.app.getOwnContainerName()
      .then((containerName) => this.getContainer(containerName));
  }

  /**
   * Whether or not this session has specifc access permission for a given container.
   * @param {String} name  name of the container, e.g. `'_public'`
   * @param {(String|Array<String>)} [permissions=['Read']] permissions to check for
   * @returns {Promise<Boolean>}
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   *
   * const containerPermissions =
   * {
   *   _public: ['Read']
   * };
   *
   * const asyncFn = async () => {
   *     try {
   *         const authReqUri = await app.auth.genAuthUri(
   *           containerPermissions
   *         );
   *         await app.auth.openUri(authReqUri);
   *         // After URI is opened by SAFE Authenticator and authorised,
   *         // this snippet assumes that your application has an
   *         // IPC strategy to receive returned authorisation uri.
   *         await app.auth.loginFromUri(authUri);
   *         const container = '_public';
   *         const permissions = ['Read'];
   *         const canAccessContainer = await app.auth.canAccessContainer(container, permissions);
   *     } catch (err) {
   *         throw err;
   *     }
   * };
   */
  canAccessContainer(name, permissions) {
    let perms = ['Read'];
    if (permissions) {
      if (typeof permissions === 'string') {
        perms = [permissions];
      } else {
        perms = permissions;
      }
    }

    return this.getContainersPermissions()
      .then((containersPerms) => {
        const contPerms = containersPerms[name];
        const result = perms.every((perm) => contPerms[perm]);
        return Promise.resolve(result);
      });
  }

  /**
   * Get interface to MutableData underlying account container.
   * @param name {String} name of the container, e.g. `'_public'`
   * @throws {MISSING_CONTAINER_STRING}
   * @returns {Promise<MutableData>}
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   *
   * const containerPermissions =
   * {
   *   _public: ['Read', 'Insert', 'Update']
   * };
   *
   * const asyncFn = async () => {
   *   try {
   *     const app = await safe.initialiseApp(appInfo);
   *     const authReqUri = await app.auth.genAuthUri(containerPermissions);
   *     await app.auth.openUri(authReqUri);
   *     // After URI is opened by SAFE Authenticator and authorised,
   *     // this snippet assumes that your application has an
   *     // IPC strategy to receive returned authorisation uri.
   *     await app.auth.loginFromUri(authUri);
   *     const app = await safe.initialiseApp(appInfo);
   *     const authReqUri = await app.auth.genAuthUri(
   *       containerPermissions
   *     );
   *     const container = '_public';
   *     const mutableDataInterface = await app.auth.getContainer(container);
   *   } catch (err) {
   *     throw err;
   *   };
   * };
   */
  getContainer(name) {
    if (!name) {
      throw makeError(errConst.MISSING_CONTAINER_STRING.code,
                      errConst.MISSING_CONTAINER_STRING.msg);
    }
    return lib.access_container_get_container_mdata_info(this.app.connection, name)
      .then((data) => this.app.mutableData.wrapMdata(data));
  }

  /**
   * Create a new authenticated or unregistered network session
   * using the provided IPC response from SAFE Authenticator.
   * @param {String} uri the IPC response string given
   * @throws {MISSING_AUTH_URI}
   * @returns {Promise<SAFEApp>}
   * @example
   * // Assumes {@link initialiseApp|SAFEApp} interface has been obtained
   *
   * const asyncFn = async () => {
   *   try {
   *     const app = await safe.initialiseApp(appInfo);
   *     const authReqUri = await app.auth.genAuthUri({});
   *     await app.auth.openUri(authReqUri);
   *     // After URI is opened by SAFE Authenticator and authorised,
   *     // this snippet assumes that your application has an
   *     // IPC strategy to receive returned authorisation uri.
   *     await app.auth.loginFromUri(authUri);
   *   } catch (err) {
   *     throw err;
   *   }
   * };
   */
  loginFromUri(uri) {
    if (!uri) throw makeError(errConst.MISSING_AUTH_URI.code, errConst.MISSING_AUTH_URI.msg);
    const sanitisedUri = removeSafeProtocol(uri);
    return lib.decode_ipc_msg(sanitisedUri).then((resp) => {
      const ipcMsgType = resp[0];
      // we handle 'granted', 'unregistered', 'containers' and 'share_mdata' types
      switch (ipcMsgType) {
        case 'unregistered': {
          this._registered = false;
          return lib.app_unregistered(this.app, resp[1]);
        }
        case 'granted': {
          const authGranted = resp[1];
          this._registered = true;
          return lib.app_registered(this.app, authGranted);
            // TODO: in the future: automatically refresh permissions
            //  .then((app) => this.refreshContainersPermissions()
            //    .then(() => app)
            //  );
        }
        case 'containers':
          this._registered = true;
          return Promise.resolve(this.app);
          // TODO: in the future: automatically refresh permissions
          //  return this.refreshContainersPermissions();
        case 'share_mdata':
          this._registered = true;
          return Promise.resolve(this.app);
        default:
          return Promise.reject(resp);
      }
    });
  }

  /**
  * *ONLY AVAILALBE IF RUN in NODE_ENV='test' OR WITH 'forceUseMock' option*
  *
  * Generate a _locally_ registered SAFEApp with the given permissions, or
  * a local unregistered SAFEApp if permissions is `null`.
  * @returns {Promise<SAFEApp>}
  */
  loginForTest(access, opts) {
    if (!useMockByDefault && !this.app.options.forceUseMock) {
      throw makeError(errConst.NON_DEV.code, errConst.NON_DEV.msg);
    }
    if (access) {
      const appInfo = makeAppInfo(this.app.appInfo);
      const perms = makePermissions(access);
      const authReq = new types.AuthReq({
        app: appInfo,
        app_container: !!(opts && opts.own_container),
        containers: perms,
        containers_len: perms.length,
        containers_cap: perms.length
      });
      return lib.test_create_app_with_access(authReq.ref())
        .then((appPtr) => {
          this.app.connection = appPtr;
          this._registered = true;
          return this.app;
        });
    }

    return lib.test_create_app(this.app.appInfo.id)
      .then((appPtr) => {
        this.app.connection = appPtr;
        this._registered = false;
        return this.app;
      });
  }

  /**
  * *ONLY AVAILALBE IF RUN in NODE_ENV='test' OR WITH 'forceUseMock' option*
  *
  * Simulates a network disconnection event. This can be used to
  * test any logic to be executed by an application when a network
  * diconnection notification is received.
  * @throws {NON_DEV}
  * @returns {Promise}
  */
  simulateNetworkDisconnect() {
    if (!useMockByDefault && !this.app.options.forceUseMock) {
      throw makeError(errConst.NON_DEV.code, errConst.NON_DEV.msg);
    }
    return lib.test_simulate_network_disconnect(this.app.connection);
  }
}

module.exports = AuthInterface;