add some support for tickets, introspection method, minor fixes
[squeep-indieauth-helper] / lib / communication.js
index 51f5b430c9fc160687c07111f1fe4727e759800b..d52d784690917d503e852309f4256650cfc4c60e 100644 (file)
@@ -2,6 +2,7 @@
 
 const axios = require('axios');
 const { mf2 } = require('microformats-parser');
+const { base64ToBase64URL } = require('@squeep/base64url');
 const { parse: parseLinkHeader } = require('@squeep/web-linking');
 const { Iconv } = require('iconv');
 const { version: packageVersion, name: packageName } = require('../package.json');
@@ -9,11 +10,20 @@ const { performance } = require('perf_hooks');
 const { randomBytes, createHash } = require('crypto');
 const { promisify } = require('util');
 const randomBytesAsync = promisify(randomBytes);
+const { Address4, Address6 } = require('ip-address');
+const dns = require('dns');
+dns.lookupAsync = dns.lookupAsync || promisify(dns.lookup);
 const common = require('./common');
 const Enum = require('./enum');
+const { ValidationError } = require('./errors');
 
 const _fileScope  = common.fileScope(__filename);
 
+const noDotPathRE = /(\/\.\/|\/\.\.\/)/;
+const v6HostRE = /\[[0-9a-f:]+\]/;
+const loopback4 = new Address4('127.0.0.0/8');
+const scopeSplitRE = / +/;
+
 class Communication {
   /**
    * @param {Console} logger
@@ -43,16 +53,28 @@ class Communication {
   }
 
 
+  /**
+   * Encode hashed verifier data for PKCE.
+   * @param {BinaryLike} verifier
+   * @returns {String}
+   */
   static _challengeFromVerifier(verifier) {
     const hash = createHash('sha256');
     hash.update(verifier);
-    return common.base64ToBase64URL(hash.digest('base64'));
+    return base64ToBase64URL(hash.digest('base64'));
   }
 
+
+  /**
+   * @typedef PKCEData
+   * @property {String} codeChallengeMethod
+   * @property {String} codeVerifier
+   * @property {String} codeChallenge
+   */
   /**
    * Create a code verifier and its challenge.
    * @param {Number} length
-   * @returns {Object}
+   * @returns {Promise<PKCEData>}
    */
   static async generatePKCE(length = 128) {
     if (length < 43 || length > 128) {
@@ -61,7 +83,7 @@ class Communication {
 
     const bufferLength = Math.floor(length * 3 / 4);
     const randomBuffer = await randomBytesAsync(bufferLength);
-    const verifier = common.base64ToBase64URL(randomBuffer.toString('base64'));
+    const verifier = base64ToBase64URL(randomBuffer.toString('base64'));
   
     const challenge = Communication._challengeFromVerifier(verifier);
 
@@ -115,6 +137,18 @@ class Communication {
   }
 
 
+  /**
+   * Valid response statuses.
+   * Allow 401 as a workaround for one specific client which return such on
+   * its client identifier endpoint when not yet authenticated.
+   * @param {Number} status
+   * @returns {Boolean}
+   */
+  static _validateStatus(status) {
+    return (status >= 200 && status < 300) || status == 401;
+  }
+
+
   /**
    * A request config skeleton.
    * @param {String} method
@@ -135,6 +169,8 @@ class Communication {
       responseType: 'text',
       // So force the matter by eliding all response transformations
       transformResponse: [ (res) => res ],
+
+      validateStatus: Communication._validateStatus,
     };
     Object.entries(params).map(([k, v]) => config.params.set(k, v));
     return config;
@@ -238,7 +274,7 @@ class Communication {
    * Retrieve and parse microformat data from url.
    * N.B. this absorbs any errors!
    * @param {URL} urlObj
-   * @returns {Object}
+   * @returns {Promise<Object>}
    */
   async fetchMicroformat(urlObj) {
     const _scope = _fileScope('fetchMicroformat');
@@ -291,6 +327,249 @@ class Communication {
   }
 
 
+  /**
+   * Retrieve and parse JSON.
+   * N.B. this absorbs any errors!
+   * @param {URL} urlObj
+   * @returns {Promise<Object>}
+   */
+  async fetchJSON(urlObj) {
+    const _scope = _fileScope('fetchJSON');
+    const logInfoData = {
+      url: urlObj.href,
+      response: undefined,
+    };
+    let response;
+    try {
+      const fetchJSONConfig = Communication._axiosConfig('GET', urlObj, undefined, undefined, {
+        [Enum.Header.Accept]: [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', '),
+      });
+      response = await this.axios(fetchJSONConfig);
+    } catch (e) {
+      this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
+      return;
+    }
+    logInfoData.response = common.axiosResponseLogData(response);
+
+    let data;
+    try {
+      data = JSON.parse(response.data);
+    } catch (e) {
+      this.logger.error(_scope, 'json parsing failed', { error: e, ...logInfoData });
+    }
+
+    return data;
+  }
+
+
+  /**
+   * Validate a url has a specific schema.
+   * @param {URL} urlObj
+   * @param {String[]} validSchemes
+   */
+  static _urlValidScheme(urlObj, validSchemes = ['http:', 'https:']) {
+    if (!validSchemes.includes(urlObj.protocol)) {
+      throw new ValidationError(`unsupported url scheme '${urlObj.protocol}'`);
+    }
+  }
+
+
+  /**
+   * Validate a url does not include some components.
+   * @param {URL} urlObj
+   * @param {String[]} disallowed
+   */
+  static _urlPartsDisallowed(urlObj, disallowed) {
+    disallowed.forEach((part) => {
+      if (urlObj[part]) { // eslint-disable-line security/detect-object-injection
+        throw new ValidationError(`url cannot contain ${common.properURLComponentName(part)}`);
+      }
+    });
+  }
+
+
+  /**
+   * Validate a url does not have relative path.
+   * @param {String} url
+   */
+  static _urlPathNoDots(url) {
+    if (noDotPathRE.test(url)) {
+      throw new ValidationError('relative path segment not valid');
+    }
+  }
+
+
+  /**
+   * Validate a url does not have a hostname which is an ip address.
+   * N.B. Sets isLoopback on urlObj
+   * @param {URL} urlObj
+   * @param {Boolean} allowLoopback
+   * @returns {Promise<void>}
+   */
+  static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) {
+    let address;
+    if (v6HostRE.test(urlObj.hostname)) {
+      /**
+       * We do not need to worry about the Address6() failing to parse,
+       * as if it looks like an ipv6 addr but is not valid, the URL()
+       * call would already have failed.
+       */
+      address = new Address6(urlObj.hostname.slice(1, urlObj.hostname.length - 1));
+      /* succeeded parsing as ipv6, reject unless loopback */
+      urlObj.isLoopback = address.isLoopback();
+    } else {
+      try {
+        address = new Address4(urlObj.hostname);
+        /* succeeded parsing as ipv4, reject unless loopback */
+        urlObj.isLoopback = address.isInSubnet(loopback4);
+      } catch (e) {
+        /* did not parse as ip, carry on */
+      }
+    }
+
+    if (resolveHostname && !urlObj.isLoopback) {
+      /**
+       * Resolve hostname to check for localhost.
+       * This is more complicated due to SSRF mitigation:
+       * If the hostname does not end with a ., we also resolve that,
+       * and complain if the two resolutions do not match, assuming
+       * malicious intent for the server to resolve a local record.
+       */
+      const hostnames = [urlObj.hostname];
+      if (!urlObj.hostname.endsWith('.')) {
+        hostnames.push(urlObj.hostname + '.');
+      }
+      const settledResolutions = await Promise.allSettled(hostnames.map((hostname) => dns.lookupAsync(hostname, {
+        all: true,
+        verbatim: true,
+      })));
+      // If any resolution failed, bail.
+      if (settledResolutions
+        .map((resolution) => resolution.status)
+        .includes('rejected')) {
+        throw new ValidationError('could not resolve hostname');
+      }
+
+      // extract each resolution value, array of {address,family}
+      const resolutions = settledResolutions.map((resolution) => resolution.value);
+
+      // If there were two resolutions, ensure they returned identical results.
+      if (resolutions.length > 1) {
+        // create set of addresses for each resolution
+        const addressSets = resolutions.map((addrs) => {
+          return new Set((addrs || []).map((a) => a.address));
+        });
+        const differences = common.setSymmetricDifference(...addressSets);
+        if (differences.size) {
+          throw new ValidationError('inconsistent hostname resolution');
+        }
+      }
+      const resolvedHost = resolutions[0] || [];
+
+      // Persist the loopback state
+      urlObj.isLoopback = resolvedHost.reduce((acc, resolved) => {
+        let addr;
+        switch (resolved.family) {
+          case 4:
+            addr = new Address4(resolved.address);
+            return acc || addr.isInSubnet(loopback4);
+          case 6:
+            addr = new Address6(resolved.address);
+            return acc || addr.isLoopback();
+          default:
+            return acc;
+        }
+      }, false);
+    }
+
+    if (address
+    &&  (!urlObj.isLoopback || !allowLoopback)) {
+      throw new ValidationError('hostname cannot be IP');
+    }
+  }
+
+
+  /**
+   * Ensure a url meets the requirements to be a profile uri.
+   * @param {String} url
+   * @param {Object} validationOptions
+   * @param {Boolean} validationOptions.allowLoopback
+   * @param {Boolean} validationOptions.resolveHostname
+   * @returns {Promise<void>}
+   */
+  async validateProfile(url, validationOptions) {
+    const _scope = _fileScope('validateProfile');
+    const errorScope = 'invalid profile url';
+
+    const options = Object.assign({
+      allowLoopback: false,
+      resolveHostname: false,
+    }, validationOptions);
+
+    let profile;
+    try {
+      profile = new URL(url);
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to parse url', { error: e, url });
+      throw new ValidationError(`${errorScope}: unparsable`);
+    }
+    profile.isLoopback = false;
+
+    try {
+      Communication._urlValidScheme(profile);
+      Communication._urlPartsDisallowed(profile, ['hash', 'username', 'password', 'port']);
+      Communication._urlPathNoDots(url);
+      Communication._urlNamedHost(profile, options.allowLoopback, options.resolveHostname);
+    } catch (e) {
+      this.logger.debug(_scope, 'profile url not valid', { url, error: e });
+      throw new ValidationError(`${errorScope}: ${e.message}`);
+    }
+
+    return profile;
+  }
+
+
+  /**
+   * Ensure a url meets the requirements to be a client identifier.
+   * Sets 'isLoopback' on returned URL object to true if hostname is or resolves to a loopback ip.
+   * @param {String} url
+   * @param {Object} validationOptions
+   * @param {Boolean} validationOptions.allowLoopback
+   * @param {Boolean} validationOptions.resolveHostname
+   * @returns {Promise<URL>}
+   */
+  async validateClientIdentifier(url, validationOptions) {
+    const _scope = _fileScope('validateClientIdentifier');
+    const errorScope = 'invalid client identifier url';
+
+    const options = Object.assign({
+      allowLoopback: true,
+      resolveHostname: true,
+    }, validationOptions);
+
+    let clientId;
+    try {
+      clientId = new URL(url);
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to parse url', { error: e, url });
+      throw new ValidationError('invalid client identifier url: unparsable');
+    }
+    clientId.isLoopback = false;
+
+    try {
+      Communication._urlValidScheme(clientId);
+      Communication._urlPartsDisallowed(clientId, ['hash', 'username', 'password']);
+      Communication._urlPathNoDots(url);
+      await Communication._urlNamedHost(clientId, options.allowLoopback, options.resolveHostname);
+    } catch (e) {
+      this.logger.debug(_scope, 'client identifier url not valid', { url, error: e });
+      throw new ValidationError(`${errorScope}: ${e.message}`);
+    }
+
+    return clientId;
+  }
+
+
   /**
    * @typedef {Object} ClientIdentifierData
    * @property {Object} rels - keyed by relation to array of uris
@@ -298,10 +577,24 @@ class Communication {
    */
   /**
    * Retrieve and parse client identifier endpoint data.
+   * N.B. Assumes urlObj has passed validateClientIdentifier.
    * @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 _scope = _fileScope('fetchClientIdentifier');
+
+    // Loopback address will eschew client fetch, return empty data.
+    const isLoopbackResult = {
+      rels: {},
+      items: [],
+    };
+
+    // Set by validation method in case of loopback ip hostname
+    if (urlObj.isLoopback) {
+      return isLoopbackResult;
+    }
+
     const mfData = await this.fetchMicroformat(urlObj);
     if (!mfData) {
       return undefined;
@@ -336,23 +629,46 @@ class Communication {
    * @property {String} photo
    * @property {String} url
    * @property {String} email
-   * @property {String} authorizationEndpoint
-   * @property {String} tokenEndpoint
+   * @property {String} authorizationEndpoint - deprecated, backwards compatibility for 20201126 spec
+   * @property {String} tokenEndpoint - deprecated, backwards compatibility for 20201126 spec
+   * @property {String} indieauthMetadata authorization server metadata endpoint
+   * @property {Object} metadata - authorization server metadata for profile
+   * @property {String} metadata.issuer
+   * @property {String} metadata.authorizationEndpoint
+   * @property {String} metadata.tokenEndpoint
+   * @property {String} metadata.ticketEndpoint
+   * @property {String} metadata.introspectionEndpoint
+   * @property {String} metadata.introspectionEndpointAuthMethodsSupported
+   * @property {String} metadata.revocationEndpoint
+   * @property {String} metadata.revocationEndpointAuthMethodsSupported
+   * @property {String} metadata.scopesSupported
+   * @property {String} metadata.responseTypesSupported
+   * @property {String} metadata.grantTypesSupported
+   * @property {String} metadata.serviceDocumentation
+   * @property {String} metadata.codeChallengeMethodsSupported
+   * @property {String} metadata.authorizationResponseIssParameterSupported
+   * @property {String} metadata.userinfoEndpoint
    */
   /**
-   * Fetch all the microformat data from url, but only return the relevant h-card profile information.
+   * Fetch the relevant microformat data from profile url h-card information,
+   * and authorization server metadata.
    * @param {URL} urlObj
    * @returns {ProfileData} mf2 data filtered for select fields from h-card
    */
   async fetchProfile(urlObj) {
+    const _scope = _fileScope('fetchProfile');
+
     const mfData = await this.fetchMicroformat(urlObj);
     const profile = {
       name: undefined,
       photo: undefined,
       url: undefined,
       email: undefined,
+      metadata: {},
     };
 
+    // Locate h-card mf2 items with url field matching profile url,
+    // and populate profile fields with first-encountered card values.
     if (mfData && 'items' in mfData) {
       const hCards = mfData.items.filter((item) =>
         item.type && item.type.includes('h-card') &&
@@ -365,21 +681,76 @@ class Communication {
         });
       });
     }
+
+    // Populate legacy mf2 fields from relation links.
+    // These will be overwritten if they also exist in server metadata.
     Object.entries({
-      authorizationEndpoint: 'authorization_endpoint',
-      tokenEndpoint: 'token_endpoint',
+      authorizationEndpoint: 'authorization_endpoint', // backwards compatibility
+      tokenEndpoint: 'token_endpoint', // backwards compatibility
+      ticketEndpoint: 'ticket_endpoint', // backwards compatibility
     }).forEach(([p, r]) => {
       if (mfData && r in mfData.rels) {
-        profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection
+        profile.metadata[p] = profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection
       }
     });
 
+    // Set metadata field.
+    if (mfData && 'indieauth-metadata' in mfData.rels) {
+      profile.indieauthMetadata = mfData.rels['indieauth-metadata'][0];
+    }
+
+    // Attempt to populate metadata from authorization server.
+    if (profile.indieauthMetadata) {
+      let mdURL;
+      try {
+        mdURL = new URL(profile.indieauthMetadata);
+      } catch (e) /* istanbul ignore next */ {
+        this.logger.error(_scope, 'invalid authorization server metadata url', { profile });
+      }
+      /* istanbul ignore else */
+      if (mdURL) {
+        const metadataResponse = await this.fetchJSON(mdURL);
+        if (metadataResponse) {
+          // Map snake_case fields to camelCase.
+          Object.entries({
+            issuer: 'issuer',
+            authorizationEndpoint: 'authorization_endpoint',
+            tokenEndpoint: 'token_endpoint',
+            ticketEndpoint: 'ticket_endpoint',
+            introspectionEndpoint: 'introspection_endpoint',
+            introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
+            revocationEndpoint: 'revocation_endpoint',
+            revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
+            scopesSupported: 'scopes_supported',
+            responseTypesSupported: 'response_types_supported',
+            grantTypesSupported: 'grant_types_supported',
+            serviceDocumentation: 'service_documentation',
+            codeChallengeMethodsSupported: 'code_challenge_methods_supported',
+            authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
+            userinfoEndpoint: 'userinfo_endpoint',
+          }).forEach(([c, s]) => {
+            if (s in metadataResponse) {
+              profile.metadata[c] = metadataResponse[s]; // eslint-disable-line security/detect-object-injection
+            }
+          });
+
+          // Populate legacy profile fields.
+          ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f) => {
+            if (f in profile.metadata) {
+              profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection
+            }
+          });
+        }
+      }
+    }
+
     return profile;
   }
 
 
   /**
    * POST to the auth endpoint, to redeem a code for a profile object.
+   * FIXME: [name] this isn't specific to profile redemption, it works for tokens too
    * @param {URL} urlObj
    * @param {String} code
    * @param {String} codeVerifier
@@ -390,16 +761,15 @@ class Communication {
   async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
     const _scope = _fileScope('redeemProfileCode');
 
-    const data = new URLSearchParams();
-    Object.entries({
+    const formData = common.formData({
       '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(), {}, {
+    const postRedeemProfileCodeConfig = Communication._axiosConfig('POST', urlObj, formData, {}, {
       [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
       [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`,
     });
@@ -418,6 +788,87 @@ class Communication {
     }
   }
 
+
+  /**
+   * Verify a token with an IdP endpoint, using the Authentication header supplied.
+   * @param {URL} introspectionUrlObj
+   * @param {String} authenticationHeader
+   * @param {String} token
+   */
+  async introspectToken(introspectionUrlObj, authenticationHeader, token) {
+    const _scope = _fileScope('introspectToken');
+
+    const formData = common.formData({ token });
+    const postIntrospectConfig = Communication._axiosConfig('POST', introspectionUrlObj, formData, {}, {
+      [Enum.Header.Authentication]: authenticationHeader,
+      [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
+      [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`,
+    });
+    delete postIntrospectConfig.validateStatus;  // only accept success
+
+    let tokenInfo;
+    try {
+      const response = await this.axios(postIntrospectConfig);
+      this.logger.debug(_scope, 'response', { response });
+      // check status
+      try {
+        tokenInfo = JSON.parse(response.data);
+        const {
+          active,
+          me,
+          client_id: clientId,
+          scope,
+          exp,
+          iat,
+        } = tokenInfo;
+
+        return {
+          active,
+          ...(me && { me }),
+          ...(clientId && { clientId }),
+          ...(scope && { scope: scope.split(scopeSplitRE) }),
+          ...(exp && { exp: Number(exp) }),
+          ...(iat && { iat: Number(iat) }),
+        };
+      } catch (e) {
+        this.logger.error(_scope, 'failed to parse json', { error: e, response });
+        throw e;
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'introspect token request failed', { error: e, url: introspectionUrlObj.href });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Attempt to deliver a ticket to an endpoint.
+   * N.B. does not absorb errors
+   * @param {*} ticketEndpointUrlObj
+   * @param {*} resourceUrlObj
+   * @param {*} subjectUrlObj
+   * @param {*} ticket
+   * @returns {Promise<AxiosResponse>}
+   */
+  async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) {
+    const _scope = _fileScope('deliverTicket');
+
+    try {
+      const ticketPayload = {
+        ticket,
+        resource: resourceUrlObj.href,
+        subject: subjectUrlObj.href,
+      };
+      const ticketConfig = Communication._axiosConfig('POST', ticketEndpointUrlObj, ticketPayload, {}, {
+        [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
+      });
+      return await this.axios(ticketConfig);
+    } catch (e) {
+      this.logger.error(_scope, 'ticket delivery request failed', { error: e, url: ticketEndpointUrlObj.href });
+      throw e;
+    }
+  }
+
 }
 
-module.exports = Communication;
\ No newline at end of file
+module.exports = Communication;