bump package version to 1.4.2
[squeep-indieauth-helper] / lib / communication.js
index 540eaafa14623ca251a2a7b8d1267bf26a57105b..c1c11285ad668ec864aadc69a678ffe3664811b7 100644 (file)
 'use strict';
 
-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');
-const { performance } = require('perf_hooks');
-const { randomBytes, createHash } = require('crypto');
-const { promisify } = require('util');
+const { randomBytes, createHash } = require('node:crypto');
+const { promisify } = require('node:util');
 const randomBytesAsync = promisify(randomBytes);
+const { Address4, Address6 } = require('ip-address');
+const dns = require('node:dns');
 const common = require('./common');
 const Enum = require('./enum');
+const { Microformat2: { Relation: MF2Rel } } = Enum;
+const { ValidationError } = require('./errors');
+const { fileScope } = require('@squeep/log-helper');
 
-const _fileScope  = common.fileScope(__filename);
+const _fileScope  = fileScope(__filename);
+
+const noDotPathRE = /(\/\.\/|\/\.\.\/)/;
+const v6HostRE = /\[[0-9a-f:]+\]/;
+const loopback4 = new Address4('127.0.0.0/8');
+const scopeSplitRE = / +/;
+const utf8CharsetRE = /utf-*8/i;
+
+/**
+ * @typedef {object} ConsoleLike
+ * @property {Function } debug debug log function
+ * @property {Function } error error log function
+ */
 
 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 
+   * @param {ConsoleLike} logger logger
+   * @param {object} options options
+   * @param {number=} options.timeout request timeout
+   * @param {object=} options.userAgent user agent object
+   * @param {string=} options.userAgent.product user agent product
+   * @param {string=} options.userAgent.version user agent version
+   * @param {string=} options.userAgent.implementation user agent 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;
-    });
+
+    this._defaultAccept = options?.defaultAccept || 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1';
+    this._jsonAccept = options?.jsonAccept || [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', ');
+
+    this.Got = undefined;
+    this.got = this._init; // Do the dynamic import on first attempt to use client.
   }
 
 
+  /**
+   * Do a little dance to support this ESM client.
+   * @param {...any} args request args
+   * @returns {Promise<any>} response
+   */
+  async _init(...args) {
+    if (!this.Got) {
+      // For some reason eslint is confused about import being supported here.
+       
+      this.Got = await import('got');
+      this.got = this.Got.got.extend({
+        headers: {
+          [Enum.Header.UserAgent]: Communication._userAgentString(this.options.userAgent),
+          [Enum.Header.Accept]: this._defaultAccept,
+        },
+        timeout: {
+          request: this.options.timeout || 120000,
+        },
+        hooks: {
+          beforeRetry: [
+            this._onRetry.bind(this),
+          ],
+        },
+      });
+    }
+    if (args.length) {
+      return this.got(...args);
+    }
+  }
+
+
+  /**
+   * Take notes on transient retries.
+   * @param {*} error error
+   * @param {*} retryCount retry count
+   */
+  _onRetry(error, retryCount) {
+    const _scope = _fileScope('_onRetry');
+    this.logger.debug(_scope, 'retry', { retryCount, error });
+  }
+
+
+  /**
+   * @typedef {string|Buffer|DataView} BinaryLike
+  /**
+   * Encode hashed verifier data for PKCE.
+   * @param {BinaryLike} verifier verifier
+   * @returns {string} challenge
+   */
   static _challengeFromVerifier(verifier) {
     const hash = createHash('sha256');
     hash.update(verifier);
-    return base64ToBase64URL(hash.digest('base64'));
+    return hash.digest('base64url');
   }
 
+
+  /**
+   * @typedef PKCEData
+   * @property {string} codeChallengeMethod challenge method
+   * @property {string} codeVerifier code verifier
+   * @property {string} codeChallenge code challenge
+   */
   /**
    * Create a code verifier and its challenge.
-   * @param {Number} length
-   * @returns {Object}
+   * @param {number} length of verifier string, between 43 and 128
+   * @returns {Promise<PKCEData>} data
    */
   static async generatePKCE(length = 128) {
     if (length < 43 || length > 128) {
@@ -62,7 +125,7 @@ class Communication {
 
     const bufferLength = Math.floor(length * 3 / 4);
     const randomBuffer = await randomBytesAsync(bufferLength);
-    const verifier = base64ToBase64URL(randomBuffer.toString('base64'));
+    const verifier = randomBuffer.toString('base64url');
   
     const challenge = Communication._challengeFromVerifier(verifier);
 
@@ -76,10 +139,10 @@ class Communication {
 
   /**
    * Check a challenge with a verifier.
-   * @param {String} codeChallenge
-   * @param {String} codeVerifier
-   * @param {String} codeChallengeMethod
-   * @returns {Boolean}
+   * @param {string} codeChallenge challenge
+   * @param {string} codeVerifier verifier
+   * @param {string} codeChallengeMethod method
+   * @returns {boolean} is valid
    */
   static verifyChallenge(codeChallenge, codeVerifier, codeChallengeMethod) {
     switch (codeChallengeMethod) {
@@ -97,11 +160,11 @@ class Communication {
 
   /**
    * Assemble a suitable User-Agent value.
-   * @param {Object} userAgentConfig
-   * @param {String=} userAgentConfig.product
-   * @param {String=} userAgentConfig.version
-   * @param {String=} userAgentConfig.implementation 
-   * @returns {String}
+   * @param {object} userAgentConfig user agent object
+   * @param {string=} userAgentConfig.product product
+   * @param {string=} userAgentConfig.version version
+   * @param {string=} userAgentConfig.implementation implementation
+   * @returns {string} UA string
    */
   static _userAgentString(userAgentConfig) {
     // eslint-disable-next-line security/detect-object-injection
@@ -116,37 +179,11 @@ class Communication {
   }
 
 
-  /**
-   * 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}
+   * @param {URL} urlObj url
+   * @returns {string} url base
    */
   static _baseUrlString(urlObj) {
     const baseUrl = new URL(urlObj);
@@ -158,16 +195,20 @@ class Communication {
   }
 
 
+  /**
+   * @typedef {object} ParsedContentType
+   * @property {string} mediaType media type
+   * @property {object} params other parameters
+   */
   /**
    * 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
+   * @param {string} contentTypeHeader content-type header value
+   * @param {string} defaultContentType default type if none present
+   * @returns {ParsedContentType} contentType 
    */
   static _parseContentType(contentTypeHeader, defaultContentType = Enum.ContentType.ApplicationOctetStream) {
     const [ mediaType, ...params ] = (contentTypeHeader || '').split(/ *; */);
@@ -175,7 +216,7 @@ class Communication {
       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) === '"';
+        const isQuoted = value?.startsWith('"') && value?.endsWith('"');
         obj[field.toLowerCase()] = isQuoted ? value.slice(1, value.length - 1) : value;
         return obj;
       }, {}),
@@ -184,9 +225,10 @@ class Communication {
 
 
   /**
-   * Parse and add any header link relations to mf data.
-   * @param {Object} microformat
-   * @param {Object} response
+   * Parse and add any header link relations from response to microformat data.
+   * @param {object} microformat microformat
+   * @param {object} response response
+   * @param {object} response.headers response headers
    */
   _mergeLinkHeader(microformat, response) {
     const _scope = _fileScope('_mergeLinkHeader');
@@ -238,8 +280,8 @@ class Communication {
   /**
    * Retrieve and parse microformat data from url.
    * N.B. this absorbs any errors!
-   * @param {URL} urlObj
-   * @returns {Object}
+   * @param {URL} urlObj url
+   * @returns {Promise<object>} microformat
    */
   async fetchMicroformat(urlObj) {
     const _scope = _fileScope('fetchMicroformat');
@@ -250,27 +292,34 @@ class Communication {
     };
     let response;
     try {
-      const fetchMicroformatConfig = Communication._axiosConfig('GET', urlObj);
-      response = await this.axios(fetchMicroformatConfig);
+      const fetchMicroformatConfig = {
+        method: 'GET',
+        url: urlObj,
+        responseType: 'buffer',
+      };
+      response = await this.got(fetchMicroformatConfig);
     } catch (e) {
       this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData });
       return;
     }
-    logInfoData.response = common.axiosResponseLogData(response);
+    logInfoData.response = common.gotResponseLogData(response);
 
     // Normalize to utf8.
-    let body = response.data;
+    let body;
     const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]);
-    const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset;
+    // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8.
+    const nonUTF8Charset = !utf8CharsetRE.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');
+        body = iconv.convert(response.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...
       }
+    } else {
+      body = response.body.toString('utf8');
     }
 
     let microformat = {};
@@ -295,8 +344,8 @@ class Communication {
   /**
    * Retrieve and parse JSON.
    * N.B. this absorbs any errors!
-   * @param {URL} urlObj
-   * @returns {Object}
+   * @param {URL} urlObj url
+   * @returns {Promise<object>} parsed json
    */
   async fetchJSON(urlObj) {
     const _scope = _fileScope('fetchJSON');
@@ -306,38 +355,261 @@ class Communication {
     };
     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);
+      const fetchJSONConfig = {
+        method: 'GET',
+        url: urlObj,
+        headers: {
+          [Enum.Header.Accept]: this._jsonAccept,
+        },
+        responseType: 'json',
+      };
+      response = await this.got(fetchJSONConfig);
     } catch (e) {
       this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
       return;
     }
-    logInfoData.response = common.axiosResponseLogData(response);
 
-    let data;
+    return response.body;
+  }
+
+
+  /**
+   * Validate a url has a specific schema.
+   * @param {URL} urlObj url
+   * @param {string[]} validSchemes url schemes
+   */
+  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 url
+   * @param {string[]} disallowed component names
+   */
+  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 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 url
+   * @param {boolean} allowLoopback allow loopback ip
+   * @param {boolean} resolveHostname resolve hostname for checks
+   * @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) { // eslint-disable-line no-unused-vars
+        /* 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.promises.lookup(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 url
+   * @param {object} validationOptions options
+   * @param {boolean=} validationOptions.allowLoopback default is false, following spec
+   * @param {boolean=} validationOptions.resolveHostname default is false, following spec
+   * @returns {Promise<URL>} validated url
+   */
+  async validateProfile(url, validationOptions) {
+    const _scope = _fileScope('validateProfile');
+    const errorScope = 'invalid profile url';
+
+    const options = {
+      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);
+      await 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 url
+   * @param {object} validationOptions options
+   * @param {boolean=} validationOptions.allowLoopback default is true, following spec
+   * @param {boolean=} validationOptions.resolveHostname default is true, following spec
+   * @returns {Promise<URL>} validated client url
+   */
+  async validateClientIdentifier(url, validationOptions) {
+    const _scope = _fileScope('validateClientIdentifier');
+    const errorScope = 'invalid client identifier url';
+
+    const options = {
+      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 {
-      data = JSON.parse(response.data);
+      Communication._urlValidScheme(clientId);
+      Communication._urlPartsDisallowed(clientId, ['hash', 'username', 'password']);
+      Communication._urlPathNoDots(url);
+      await Communication._urlNamedHost(clientId, options.allowLoopback, options.resolveHostname);
     } catch (e) {
-      this.logger.error(_scope, 'json parsing failed', { error: e, ...logInfoData });
+      this.logger.debug(_scope, 'client identifier url not valid', { url, error: e });
+      throw new ValidationError(`${errorScope}: ${e.message}`);
     }
 
-    return data;
+    return clientId;
   }
 
 
   /**
-   * @typedef {Object} ClientIdentifierData
-   * @property {Object} rels - keyed by relation to array of uris
-   * @property {HAppData[]} items
+   * @typedef {object} HAppData
+   *  @property {string[]=} type list of item types
+   *  @property {object=} properties map of item properties
+   */
+  /**
+   * @typedef {object} ClientIdentifierData
+   * @property {object} rels keyed by relation to array of uris
+   * @property {HAppData[]} items h-app data
    */
   /**
    * 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
+   * N.B. Assumes urlObj has passed validateClientIdentifier.
+   * @param {URL} urlObj url
+   * @returns {Promise<ClientIdentifierData|undefined>} mf2 data filtered for h-app items, or undefined if url could not be fetched
    */
   async fetchClientIdentifier(urlObj) {
+    // Set by validation method in case of loopback ip hostname
+    if (urlObj.isLoopback) {
+      // Loopback address will eschew client fetch, return empty data.
+      return {
+        rels: {},
+        items: [],
+      };
+    }
+
     const mfData = await this.fetchMicroformat(urlObj);
     if (!mfData) {
       return undefined;
@@ -350,14 +622,14 @@ class Communication {
         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)) {
           item.properties.url.forEach((url) => {
             try {
               const hUrl = new URL(url);
               if (hUrl.href === urlObj.href) {
                 urlMatched = true;
               }
-            } catch (e) { /**/ }
+            } catch (e) { /**/ } // eslint-disable-line no-unused-vars
           });
         }
         return urlMatched;
@@ -366,36 +638,41 @@ class Communication {
   }
 
 
+  /**
+   * @typedef {object} Metadata
+   * @property {string} issuer issuer
+   * @property {string} authorizationEndpoint authorization endpoint
+   * @property {string} tokenEndpoint token endpoint
+   * @property {string} ticketEndpoint ticket endpoint
+   * @property {string} introspectionEndpoint introspection endpoint
+   * @property {string} introspectionEndpointAuthMethodsSupported auth methods
+   * @property {string} revocationEndpoint revocation endpoint
+   * @property {string} revocationEndpointAuthMethodsSupported auth methods
+   * @property {string} scopesSupported scopes
+   * @property {string} responseTypesSupported response types
+   * @property {string} grantTypesSupported grant types
+   * @property {string} serviceDocumentation service documentation reference
+   * @property {string} codeChallengeMethodsSupported code challenge methods
+   * @property {string} authorizationResponseIssParameterSupported iss parameter supported
+   * @property {string} userinfoEndpoint endpoint
+   */
   /**
    * @typedef ProfileData
-   * @property {String} name
-   * @property {String} photo
-   * @property {String} url
-   * @property {String} email
-   * @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.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
+   * @property {string} name name
+   * @property {string} photo photo
+   * @property {string} url url
+   * @property {string} email email
+   * @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 {Metadata} metadata authorization server metadata for profile
    */
   /**
    * 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
+   * N.B. Assumes urlObj has passed validateProfile
+   * @param {URL} urlObj valid profile url
+   * @returns {Promise<ProfileData>} mf2 data filtered for select fields from h-card
    */
   async fetchProfile(urlObj) {
     const _scope = _fileScope('fetchProfile');
@@ -409,12 +686,14 @@ class Communication {
       metadata: {},
     };
 
-    // Locate h-card mf2 items with url field matching profile url,
-    // and populate profile fields with first-encountered card values.
+    /**
+     * 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') &&
-        item.properties && item.properties.url && item.properties.url.includes(urlObj.href));
+        item?.type?.includes('h-card') &&
+        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
@@ -427,8 +706,9 @@ 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', // backwards compatibility
-      tokenEndpoint: 'token_endpoint', // backwards compatibility
+      authorizationEndpoint: MF2Rel.AuthorizationEndpoint, // Backwards compatibility
+      tokenEndpoint: MF2Rel.TokenEndpoint, // Backwards compatibility
+      ticketEndpoint: MF2Rel.TicketEndpoint, // Backwards compatibility
     }).forEach(([p, r]) => {
       if (mfData && r in mfData.rels) {
         profile.metadata[p] = profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection
@@ -436,8 +716,8 @@ class Communication {
     });
 
     // Set metadata field.
-    if (mfData && 'indieauth-metadata' in mfData.rels) {
-      profile.indieauthMetadata = mfData.rels['indieauth-metadata'][0];
+    if (mfData && MF2Rel.IndieauthMetadata in mfData.rels) {
+      profile.indieauthMetadata = mfData.rels[MF2Rel.IndieauthMetadata][0];
     }
 
     // Attempt to populate metadata from authorization server.
@@ -445,42 +725,19 @@ class Communication {
       let mdURL;
       try {
         mdURL = new URL(profile.indieauthMetadata);
-      } catch (e) /* istanbul ignore next */ {
+      } catch (e) /* istanbul ignore next */ { // eslint-disable-line no-unused-vars
         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',
-            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
-            }
-          });
+        profile.metadata = await this.fetchMetadata(mdURL);
 
-          // Populate legacy profile fields.
-          ['authorizationEndpoint', 'tokenEndpoint'].forEach((f) => {
-            if (f in profile.metadata) {
-              profile[f] = profile.metadata[f]; // 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
+          }
+        });
       }
     }
 
@@ -489,45 +746,280 @@ class Communication {
 
 
   /**
-   * 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}
+   * Fetch the server metadata from an authorization server's metadata endpoint.
+   * @param {URL} metadataUrl url
+   * @returns {Promise<Metadata>} metadata
+   */
+  async fetchMetadata(metadataUrl) {
+    const metadataResponse = await this.fetchJSON(metadataUrl);
+    const metadata = {};
+    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) {
+          metadata[c] = metadataResponse[s]; // eslint-disable-line security/detect-object-injection
+        }
+      });
+    }
+
+    return metadata;
+  }
+
+
+  /**
+   * POST to the auth endpoint, to redeem a code for a profile or token.
+   * N.B. this absorbs any errors!
+   * @param {URL} urlObj url
+   * @param {string} code code
+   * @param {string} codeVerifier verifier
+   * @param {string} clientId client id
+   * @param {string} redirectURI uri
+   * @returns {Promise<object>} response
+   */
+  async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) {
+    const _scope = _fileScope('redeemCode');
+
+    const postRedeemCodeConfig = {
+      url: urlObj,
+      method: 'POST',
+      headers: {
+        [Enum.Header.Accept]: this._jsonAccept,
+      },
+      form: {
+        'grant_type': 'authorization_code',
+        code,
+        'client_id': clientId,
+        'redirect_uri': redirectURI,
+        'code_verifier': codeVerifier,
+      },
+      responseType: 'json',
+    };
+
+    try {
+      const response = await this.got(postRedeemCodeConfig);
+      return response.body;
+    } catch (e) {
+      this.logger.error(_scope, 'redeem code request failed', { error: e, url: urlObj.href });
+      return;
+    }
+  }
+
+
+  /**
+   * Deprecated method name alias.
+   * @see redeemCode
+   * @param {URL} urlObj url
+   * @param {string} code code
+   * @param {string} codeVerifier verifier
+   * @param {string} clientId client id
+   * @param {string} redirectURI uri
+   * @returns {Promise<object>} response
    */
   async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
-    const _scope = _fileScope('redeemProfileCode');
+    return this.redeemCode(urlObj, code, codeVerifier, clientId, redirectURI);
+  }
 
-    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`,
-    });
+
+  /**
+   * Verify a token with an IdP endpoint, using the Authorization header supplied.
+   * @param {URL} introspectionUrlObj url
+   * @param {string} authorizationHeader authorization
+   * @param {string} token token
+   * @returns {Promise<object>} response
+   */
+  async introspectToken(introspectionUrlObj, authorizationHeader, token) {
+    const _scope = _fileScope('introspectToken');
+
+    const postIntrospectConfig = {
+      url: introspectionUrlObj,
+      method: 'POST',
+      headers: {
+        [Enum.Header.Authorization]: authorizationHeader,
+        [Enum.Header.Accept]: this._jsonAccept,
+      },
+      form: {
+        token,
+      },
+      responseType: 'json',
+    };
 
     try {
-      const response = await this.axios(postRedeemProfileCodeConfig);
+      const response = await this.got(postIntrospectConfig);
+      // Check status
       try {
-        return JSON.parse(response.data);
+        const {
+          active,
+          me,
+          client_id: clientId,
+          scope,
+          exp,
+          iat,
+        } = response.body;
+
+        if (![true, false].includes(active)) {
+          throw new RangeError('missing required response field "active"');
+        }
+
+        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 });
+        this.logger.error(_scope, 'failed to parse json', { error: e, response: common.gotResponseLogData(response) });
         throw e;
       }
     } catch (e) {
-      this.logger.error(_scope, 'redeem profile code request failed', { error: e, url: urlObj.href });
-      return;
+      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 {URL} ticketEndpointUrlObj url ticket endpoint url
+   * @param {URL} resourceUrlObj url resource url
+   * @param {URL} subjectUrlObj url subject url
+   * @param {string} ticket ticket
+   * @param {URL=} issuerUrlObj issuer url
+   * @returns {Promise<Response>} response
+   */
+  async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj) {
+    const _scope = _fileScope('deliverTicket');
+
+    try {
+      const ticketConfig = {
+        method: 'POST',
+        url: ticketEndpointUrlObj,
+        form: {
+          ticket,
+          resource: resourceUrlObj.href,
+          subject: subjectUrlObj.href,
+          ...( issuerUrlObj && { iss: issuerUrlObj.href }),
+        },
+      };
+      const result = await this.got(ticketConfig);
+      this.logger.debug(_scope, 'success', { ...common.gotResponseLogData(result) });
+      return result;
+    } catch (e) {
+      this.logger.error(_scope, 'ticket delivery request failed', { error: e, url: ticketEndpointUrlObj.href });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Attempt to fetch some link relations from a url.
+   * @param {URL} urlObj url
+   * @returns {Promise<object>} data
+   */
+  async _fetchMetadataOrTokenEndpoint(urlObj) {
+    const _scope = _fileScope('_fetchMetadataOrTokenEndpoint');
+
+    let metadataUrl, tokenUrl;
+    if (urlObj) {
+      const mfData = await this.fetchMicroformat(urlObj);
+      const metadataRel = mfData?.rels?.[MF2Rel.IndieauthMetadata]?.[0];
+      if (metadataRel) {
+        try {
+          metadataUrl = new URL(metadataRel);
+        } catch (e) { // eslint-disable-line no-unused-vars
+          this.logger.debug(_scope, 'invalid metadata rel url', { url: urlObj.href, metadataRel });
+        }
+      }
+      if (!metadataUrl) {
+        // No metadata rel, try old-style token endpoint
+        const tokenRel = mfData?.rels?.[MF2Rel.TokenEndpoint]?.[0];
+        if (tokenRel) {
+          try {
+            tokenUrl = new URL(tokenRel);
+          } catch (e) { // eslint-disable-line no-unused-vars
+            this.logger.debug(_scope, 'invalid token rel url', { url: urlObj.href, tokenRel });
+          }
+        }
+      }
     }
+    return { metadataUrl, tokenUrl };
   }
 
+
+  /**
+   * Attempt to redeem a ticket for a token.
+   * N.B. does not absorb errors
+   * @param {string} ticket ticket
+   * @param {URL} resourceUrlObj resource url
+   * @param {URL=} issuerUrlObj issuer url
+   * @returns {Promise<object>} response body
+   */
+  async redeemTicket(ticket, resourceUrlObj, issuerUrlObj) {
+    const _scope = _fileScope('redeemTicket');
+
+    let metadataUrl, tokenUrl;
+    // Attempt to determine metadata or token endpoint from issuer MF data
+    if (issuerUrlObj) {
+      ({ metadataUrl, tokenUrl } = await this._fetchMetadataOrTokenEndpoint(issuerUrlObj));
+    }
+
+    // Fallback to resource MF data
+    if (!metadataUrl && !tokenUrl) {
+      ({ metadataUrl, tokenUrl } = await this._fetchMetadataOrTokenEndpoint(resourceUrlObj));
+    }
+
+    if (metadataUrl) {
+      const metadata = await this.fetchMetadata(metadataUrl);
+      try {
+        tokenUrl = new URL(metadata?.tokenEndpoint);
+      } catch (e) { // eslint-disable-line no-unused-vars
+        this.logger.debug(_scope, 'invalid token endpoint url from metadata', { resourceUrl: resourceUrlObj.href, issuerUrl: issuerUrlObj.href, tokenEndpoint: metadata?.tokenEndpoint });
+      }
+    }
+
+    if (!tokenUrl) {
+      throw new ValidationError('could not determine endpoint for ticket redemption');
+    }
+
+    const postRedeemTicketConfig = {
+      url: tokenUrl,
+      method: 'POST',
+      headers: {
+        [Enum.Header.Accept]: this._jsonAccept,
+      },
+      form: {
+        'grant_type': 'ticket',
+        ticket,
+      },
+      responseType: 'json',
+    };
+
+    try {
+      const response = await this.got(postRedeemTicketConfig);
+      return response.body;
+    } catch (e) {
+      this.logger.error(_scope, 'ticket redemption failed', { error: e, resource: resourceUrlObj.href, issuer: issuerUrlObj?.href });
+      throw e;
+    }
+  }
 }
 
-module.exports = Communication;
\ No newline at end of file
+module.exports = Communication;