initial commit
[squeep-indieauth-helper] / lib / communication.js
diff --git a/lib/communication.js b/lib/communication.js
new file mode 100644 (file)
index 0000000..51f5b43
--- /dev/null
@@ -0,0 +1,423 @@
+'use strict';
+
+const axios = require('axios');
+const { mf2 } = require('microformats-parser');
+const { parse: parseLinkHeader } = require('@squeep/web-linking');
+const { Iconv } = require('iconv');
+const { version: packageVersion, name: packageName } = require('../package.json');
+const { performance } = require('perf_hooks');
+const { randomBytes, createHash } = require('crypto');
+const { promisify } = require('util');
+const randomBytesAsync = promisify(randomBytes);
+const common = require('./common');
+const Enum = require('./enum');
+
+const _fileScope  = common.fileScope(__filename);
+
+class Communication {
+  /**
+   * @param {Console} logger
+   * @param {Object} options
+   * @param {Object=} options.userAgent
+   * @param {String=} options.userAgent.product
+   * @param {String=} options.userAgent.version
+   * @param {String=} options.userAgent.implementation 
+   */
+  constructor(logger, options = {}) {
+    this.logger = logger;
+    this.options = options;
+    this.axios = axios.create({
+      headers: {
+        [Enum.Header.UserAgent]: Communication._userAgentString(options.userAgent),
+        [Enum.Header.Accept]: 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1',
+      },
+    });
+    this.axios.interceptors.request.use((request) => {
+      request.startTimestampMs = performance.now();
+      return request;
+    });
+    this.axios.interceptors.response.use((response) => {
+      response.elapsedTimeMs = performance.now() - response.config.startTimestampMs;
+      return response;
+    });
+  }
+
+
+  static _challengeFromVerifier(verifier) {
+    const hash = createHash('sha256');
+    hash.update(verifier);
+    return common.base64ToBase64URL(hash.digest('base64'));
+  }
+
+  /**
+   * Create a code verifier and its challenge.
+   * @param {Number} length
+   * @returns {Object}
+   */
+  static async generatePKCE(length = 128) {
+    if (length < 43 || length > 128) {
+      throw new RangeError('InvalidLength');
+    }
+
+    const bufferLength = Math.floor(length * 3 / 4);
+    const randomBuffer = await randomBytesAsync(bufferLength);
+    const verifier = common.base64ToBase64URL(randomBuffer.toString('base64'));
+  
+    const challenge = Communication._challengeFromVerifier(verifier);
+
+    return {
+      codeChallengeMethod: 'S256',
+      codeVerifier: verifier,
+      codeChallenge: challenge,
+    };  
+  }
+
+
+  /**
+   * Check a challenge with a verifier.
+   * @param {String} codeChallenge
+   * @param {String} codeVerifier
+   * @param {String} codeChallengeMethod
+   * @returns {Boolean}
+   */
+  static verifyChallenge(codeChallenge, codeVerifier, codeChallengeMethod) {
+    switch (codeChallengeMethod) {
+      case 'SHA256':
+      case 'S256': {
+        const challenge = Communication._challengeFromVerifier(codeVerifier);
+        return challenge === codeChallenge;
+      }
+
+      default:
+        throw new Error('unsupported challenge method');
+    }
+  }
+
+
+  /**
+   * Assemble a suitable User-Agent value.
+   * @param {Object} userAgentConfig
+   * @param {String=} userAgentConfig.product
+   * @param {String=} userAgentConfig.version
+   * @param {String=} userAgentConfig.implementation 
+   * @returns {String}
+   */
+  static _userAgentString(userAgentConfig) {
+    // eslint-disable-next-line security/detect-object-injection
+    const _conf = (field, def) => (userAgentConfig && field in userAgentConfig) ? userAgentConfig[field] : def;
+    const product = _conf('product', packageName).split('/').pop();
+    const version = _conf('version', packageVersion);
+    let implementation = _conf('implementation', Enum.Specification);
+    if (implementation) {
+      implementation = ` (${implementation})`;
+    }
+    return `${product}/${version}${implementation}`;
+  }
+
+
+  /**
+   * A request config skeleton.
+   * @param {String} method
+   * @param {URL} urlObj
+   * @param {String=} body
+   * @param {Object=} params
+   * @param {Object=} headers
+   * @returns {Object}
+   */
+  static _axiosConfig(method, urlObj, body, params = {}, headers = {}) {
+    const config = {
+      method,
+      url: `${urlObj.origin}${urlObj.pathname}`,
+      params: urlObj.searchParams,
+      headers,
+      ...(body && { data: body }),
+      // Setting this does not appear to be enough to keep axios from parsing JSON response into object
+      responseType: 'text',
+      // So force the matter by eliding all response transformations
+      transformResponse: [ (res) => res ],
+    };
+    Object.entries(params).map(([k, v]) => config.params.set(k, v));
+    return config;
+  }
+
+
+  /**
+   * Isolate the base of a url.
+   * mf2 parser needs this so that relative links can be made absolute.
+   * @param {URL} urlObj
+   * @returns {String}
+   */
+  static _baseUrlString(urlObj) {
+    const baseUrl = new URL(urlObj);
+    const lastSlashIdx = baseUrl.pathname.lastIndexOf('/');
+    if (lastSlashIdx > 0) {
+      baseUrl.pathname = baseUrl.pathname.slice(0, lastSlashIdx + 1);
+    }
+    return baseUrl.href;
+  }
+
+
+  /**
+   * Convert a Content-Type string to normalized components.
+   * RFC7231 ยง3.1.1
+   * N.B. this ill-named non-parsing implementation will not work
+   * if a parameter value for some reason includes a ; or = within
+   * a quoted-string.
+   * @param {String} contentTypeHeader
+   * @returns {Object} contentType
+   * @returns {String} contentType.mediaType
+   * @returns {Object} contentType.params
+   */
+  static _parseContentType(contentTypeHeader, defaultContentType = Enum.ContentType.ApplicationOctetStream) {
+    const [ mediaType, ...params ] = (contentTypeHeader || '').split(/ *; */);
+    return {
+      mediaType: mediaType.toLowerCase() || defaultContentType,
+      params: params.reduce((obj, param) => {
+        const [field, value] = param.split('=');
+        const isQuoted = value && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"';
+        obj[field.toLowerCase()] = isQuoted ? value.slice(1, value.length - 1) : value;
+        return obj;
+      }, {}),
+    };
+  }
+
+
+  /**
+   * Parse and add any header link relations to mf data.
+   * @param {Object} microformat
+   * @param {Object} response
+   */
+  _mergeLinkHeader(microformat, response) {
+    const _scope = _fileScope('_mergeLinkHeader');
+
+    // Establish that microformat has expected structure
+    ['rels', 'rel-urls'].forEach((p) => {
+      if (!(p in microformat)) {
+        microformat[p] = {}; // eslint-disable-line security/detect-object-injection
+      }
+    });
+    if (!('items' in microformat)) {
+      microformat.items = [];
+    }
+
+    const linkHeader = response.headers[Enum.Header.Link.toLowerCase()];
+    const links = [];
+    if (linkHeader) {
+      try {
+        links.push(...parseLinkHeader(linkHeader));
+      } catch (e) {
+        this.logger.error(_scope, 'failed to parse link header', { error: e, linkHeader });
+        return;
+      }
+    }
+
+    // Push header link rels into microformat form.
+    // Inserted at front of lists, as headers take precedence.
+    links.forEach((link) => {
+      link.attributes.forEach((attr) => {
+        if (attr.name === 'rel') {
+          if (!(attr.value in microformat.rels)) {
+            microformat.rels[attr.value] = [];
+          }
+          microformat.rels[attr.value].unshift(link.target);
+
+          if (!(link.target in microformat['rel-urls'])) {
+            microformat['rel-urls'][link.target] = {
+              text: '',
+              rels: [],
+            };
+          }
+          microformat['rel-urls'][link.target].rels.unshift(attr.value);
+        }
+      });
+    });
+  }
+
+
+  /**
+   * Retrieve and parse microformat data from url.
+   * N.B. this absorbs any errors!
+   * @param {URL} urlObj
+   * @returns {Object}
+   */
+  async fetchMicroformat(urlObj) {
+    const _scope = _fileScope('fetchMicroformat');
+    const logInfoData = {
+      url: urlObj.href,
+      microformat: undefined,
+      response: undefined,
+    };
+    let response;
+    try {
+      const fetchMicroformatConfig = Communication._axiosConfig('GET', urlObj);
+      response = await this.axios(fetchMicroformatConfig);
+    } catch (e) {
+      this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData });
+      return;
+    }
+    logInfoData.response = common.axiosResponseLogData(response);
+
+    // Normalize to utf8.
+    let body = response.data;
+    const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]);
+    const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset;
+    if (nonUTF8Charset) {
+      try {
+        const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore');
+        body = iconv.convert(body).toString('utf8');
+      } catch (e) {
+        // istanbul ignore next
+        this.logger.error(_scope, 'iconv conversion error', { error: e, ...logInfoData });
+        // Try to carry on, maybe the encoding will work anyhow...
+      }
+    }
+
+    let microformat = {};
+    try {
+      microformat = mf2(body, {
+        baseUrl: Communication._baseUrlString(urlObj),
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed to parse microformat data', { error: e, ...logInfoData });
+      // Try to carry on, maybe there are link headers...
+    }
+
+    this._mergeLinkHeader(microformat, response);
+
+    logInfoData.microformat = microformat;
+
+    this.logger.debug(_scope, 'parsed microformat data', logInfoData);
+    return microformat;
+  }
+
+
+  /**
+   * @typedef {Object} ClientIdentifierData
+   * @property {Object} rels - keyed by relation to array of uris
+   * @property {HAppData[]} items
+   */
+  /**
+   * Retrieve and parse client identifier endpoint data.
+   * @param {URL} urlObj
+   * @returns {ClientIdentifierData|undefined} mf2 data filtered for h-app items, or undefined if url could not be fetched
+   */
+  async fetchClientIdentifier(urlObj) {
+    const mfData = await this.fetchMicroformat(urlObj);
+    if (!mfData) {
+      return undefined;
+    }
+
+    // Only return h-app items with matching url field.
+    return {
+      rels: mfData.rels || {},
+      items: (mfData.items || []).filter((item) => {
+        let urlMatched = false;
+        const itemType = item.type || [];
+        if ((itemType.includes('h-app') || itemType.includes('h-x-app'))
+        &&  (item.properties && item.properties.url)) {
+          item.properties.url.forEach((url) => {
+            try {
+              const hUrl = new URL(url);
+              if (hUrl.href === urlObj.href) {
+                urlMatched = true;
+              }
+            } catch (e) { /**/ }
+          });
+        }
+        return urlMatched;
+      }),
+    };
+  }
+
+
+  /**
+   * @typedef ProfileData
+   * @property {String} name
+   * @property {String} photo
+   * @property {String} url
+   * @property {String} email
+   * @property {String} authorizationEndpoint
+   * @property {String} tokenEndpoint
+   */
+  /**
+   * Fetch all the microformat data from url, but only return the relevant h-card profile information.
+   * @param {URL} urlObj
+   * @returns {ProfileData} mf2 data filtered for select fields from h-card
+   */
+  async fetchProfile(urlObj) {
+    const mfData = await this.fetchMicroformat(urlObj);
+    const profile = {
+      name: undefined,
+      photo: undefined,
+      url: undefined,
+      email: undefined,
+    };
+
+    if (mfData && 'items' in mfData) {
+      const hCards = mfData.items.filter((item) =>
+        item.type && item.type.includes('h-card') &&
+        item.properties && item.properties.url && item.properties.url.includes(urlObj.href));
+      hCards.forEach((hCard) => {
+        Object.keys(profile).forEach((key) => {
+          if (!profile[key] && key in hCard.properties) { // eslint-disable-line security/detect-object-injection
+            profile[key] = hCard.properties[key][0]; // eslint-disable-line security/detect-object-injection
+          }
+        });
+      });
+    }
+    Object.entries({
+      authorizationEndpoint: 'authorization_endpoint',
+      tokenEndpoint: 'token_endpoint',
+    }).forEach(([p, r]) => {
+      if (mfData && r in mfData.rels) {
+        profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection
+      }
+    });
+
+    return profile;
+  }
+
+
+  /**
+   * POST to the auth endpoint, to redeem a code for a profile object.
+   * @param {URL} urlObj
+   * @param {String} code
+   * @param {String} codeVerifier
+   * @param {String} clientId
+   * @param {String} redirectURI
+   * @returns {Object}
+   */
+  async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
+    const _scope = _fileScope('redeemProfileCode');
+
+    const data = new URLSearchParams();
+    Object.entries({
+      'grant_type': 'authorization_code',
+      code,
+      'client_id': clientId,
+      'redirect_uri': redirectURI,
+      'code_verifier': codeVerifier,
+    }).forEach(([name, value]) => data.set(name, value));
+
+    const postRedeemProfileCodeConfig = Communication._axiosConfig('POST', urlObj, data.toString(), {}, {
+      [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
+      [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`,
+    });
+
+    try {
+      const response = await this.axios(postRedeemProfileCodeConfig);
+      try {
+        return JSON.parse(response.data);
+      } catch (e) {
+        this.logger.error(_scope, 'failed to parse json', { error: e, response });
+        throw e;
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'redeem profile code request failed', { error: e, url: urlObj.href });
+      return;
+    }
+  }
+
+}
+
+module.exports = Communication;
\ No newline at end of file