update devDependencies, eslint config, address lint issues
[squeep-indieauth-helper] / lib / communication.js
index d160707ee3bb157559bf594da2f16d12cfcf0db3..9446b0013483cfbfc701746345038a83e6f85930 100644 (file)
@@ -11,14 +11,17 @@ const { Address4, Address6 } = require('ip-address');
 const dns = require('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;
 
 class Communication {
   /**
@@ -60,7 +63,7 @@ class Communication {
         },
         hooks: {
           beforeRetry: [
-            this._onRetry,
+            this._onRetry.bind(this),
           ],
         },
       });
@@ -291,7 +294,7 @@ class Communication {
     let body;
     const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]);
     // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8.
-    const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset;
+    const nonUTF8Charset = !utf8CharsetRE.test(contentType.params.charset) && contentType.params.charset;
     if (nonUTF8Charset) {
       try {
         const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore');
@@ -351,7 +354,6 @@ class Communication {
       this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
       return;
     }
-    logInfoData.response = common.gotResponseLogData(response);
 
     return response.body;
   }
@@ -411,15 +413,15 @@ class Communication {
        * call would already have failed.
        */
       address = new Address6(urlObj.hostname.slice(1, urlObj.hostname.length - 1));
-      /* succeeded parsing as ipv6, reject unless loopback */
+      /* 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 */
+        /* Succeeded parsing as ipv4, reject unless loopback */
         urlObj.isLoopback = address.isInSubnet(loopback4);
       } catch (e) {
-        /* did not parse as ip, carry on */
+        /* Did not parse as ip, carry on */
       }
     }
 
@@ -446,12 +448,12 @@ class Communication {
         throw new ValidationError('could not resolve hostname');
       }
 
-      // extract each resolution value, array of {address,family}
+      // 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
+        // Create set of addresses for each resolution
         const addressSets = resolutions.map((addrs) => {
           return new Set((addrs || []).map((a) => a.address));
         });
@@ -497,10 +499,11 @@ class Communication {
     const _scope = _fileScope('validateProfile');
     const errorScope = 'invalid profile url';
 
-    const options = Object.assign({
+    const options = {
       allowLoopback: false,
       resolveHostname: false,
-    }, validationOptions);
+      ...validationOptions,
+    };
 
     let profile;
     try {
@@ -538,10 +541,11 @@ class Communication {
     const _scope = _fileScope('validateClientIdentifier');
     const errorScope = 'invalid client identifier url';
 
-    const options = Object.assign({
+    const options = {
       allowLoopback: true,
       resolveHostname: true,
-    }, validationOptions);
+      ...validationOptions,
+    };
 
     let clientId;
     try {
@@ -578,17 +582,13 @@ class Communication {
    * @returns {Promise<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;
+      // Loopback address will eschew client fetch, return empty data.
+      return {
+        rels: {},
+        items: [],
+      };
     }
 
     const mfData = await this.fetchMicroformat(urlObj);
@@ -619,6 +619,24 @@ class Communication {
   }
 
 
+  /**
+   * @typedef {Object} Metadata
+   * @property {String} issuer
+   * @property {String} authorizationEndpoint
+   * @property {String} tokenEndpoint
+   * @property {String} ticketEndpoint
+   * @property {String} introspectionEndpoint
+   * @property {String} introspectionEndpointAuthMethodsSupported
+   * @property {String} revocationEndpoint
+   * @property {String} revocationEndpointAuthMethodsSupported
+   * @property {String} scopesSupported
+   * @property {String} responseTypesSupported
+   * @property {String} grantTypesSupported
+   * @property {String} serviceDocumentation
+   * @property {String} codeChallengeMethodsSupported
+   * @property {String} authorizationResponseIssParameterSupported
+   * @property {String} userinfoEndpoint
+   */
   /**
    * @typedef ProfileData
    * @property {String} name
@@ -628,22 +646,7 @@ class Communication {
    * @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
+   * @property {Metadata} metadata - authorization server metadata for profile
    */
   /**
    * Fetch the relevant microformat data from profile url h-card information,
@@ -664,12 +667,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?.includes('h-card') &&
-        item.properties && item.properties.url && item.properties.url.includes(urlObj.href));
+        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
@@ -682,9 +687,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
-      ticketEndpoint: 'ticket_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
@@ -692,8 +697,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.
@@ -706,38 +711,14 @@ class Communication {
       }
       /* 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
-            }
-          });
+        profile.metadata = await this.fetchMetadata(mdURL);
 
-          // 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
-            }
-          });
-        }
+        // 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
+          }
+        });
       }
     }
 
@@ -745,6 +726,43 @@ class Communication {
   }
 
 
+  /**
+   * Fetch the server metadata from an authorization server's metadata endpoint.
+   * @param {URL} metadataUrl
+   * @returns {Promise<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!
@@ -824,8 +842,7 @@ class Communication {
 
     try {
       const response = await this.got(postIntrospectConfig);
-      this.logger.debug(_scope, 'response', { response });
-      // check status
+      // Check status
       try {
         const {
           active,
@@ -865,10 +882,11 @@ class Communication {
    * @param {URL} ticketEndpointUrlObj
    * @param {URL} resourceUrlObj
    * @param {URL} subjectUrlObj
+   * @param {URL=} issuerUrlObj
    * @param {String} ticket
    * @returns {Promise<Response>}
    */
-  async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) {
+  async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj) {
     const _scope = _fileScope('deliverTicket');
 
     try {
@@ -879,15 +897,110 @@ class Communication {
           ticket,
           resource: resourceUrlObj.href,
           subject: subjectUrlObj.href,
+          ...( issuerUrlObj && { iss: issuerUrlObj.href }),
         },
       };
-      return await this.got(ticketConfig);
+      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
+   * @returns {Promise<Object>}
+   */
+  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) {
+          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) {
+            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
+   * @property {String} ticket
+   * @property {URL} resourceUrlObj
+   * @property {URL=} issuerUrlObj
+   * @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) {
+        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;