use native dns promises
[squeep-indieauth-helper] / lib / communication.js
index 0116548d6539eb70fe229df71aee35cf86305034..ce2bc4e25b8bc05b281b86f970cd1e152b30c35c 100644 (file)
@@ -2,7 +2,6 @@
 
 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');
@@ -12,7 +11,6 @@ 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');
@@ -22,6 +20,7 @@ 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 {
   /**
@@ -52,16 +51,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 base64ToBase64URL(hash.digest('base64'));
+    return hash.digest('base64url');
   }
 
+
+  /**
+   * @typedef PKCEData
+   * @property {String} codeChallengeMethod
+   * @property {String} codeVerifier
+   * @property {String} codeChallenge
+   */
   /**
    * 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>}
    */
   static async generatePKCE(length = 128) {
     if (length < 43 || length > 128) {
@@ -70,7 +81,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);
 
@@ -124,6 +135,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
@@ -144,6 +167,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;
@@ -192,9 +217,10 @@ class Communication {
 
 
   /**
-   * Parse and add any header link relations to mf data.
+   * Parse and add any header link relations from response to microformat data.
    * @param {Object} microformat
    * @param {Object} response
+   * @param {Object} response.headers
    */
   _mergeLinkHeader(microformat, response) {
     const _scope = _fileScope('_mergeLinkHeader');
@@ -247,7 +273,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');
@@ -304,7 +330,7 @@ class Communication {
    * Retrieve and parse JSON.
    * N.B. this absorbs any errors!
    * @param {URL} urlObj
-   * @returns {Object}
+   * @returns {Promise<Object>}
    */
   async fetchJSON(urlObj) {
     const _scope = _fileScope('fetchJSON');
@@ -377,6 +403,7 @@ class Communication {
    * N.B. Sets isLoopback on urlObj
    * @param {URL} urlObj
    * @param {Boolean} allowLoopback
+   * @returns {Promise<void>}
    */
   static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) {
     let address;
@@ -411,7 +438,7 @@ class Communication {
       if (!urlObj.hostname.endsWith('.')) {
         hostnames.push(urlObj.hostname + '.');
       }
-      const settledResolutions = await Promise.allSettled(hostnames.map((hostname) => dns.lookupAsync(hostname, {
+      const settledResolutions = await Promise.allSettled(hostnames.map((hostname) => dns.promises.lookup(hostname, {
         all: true,
         verbatim: true,
       })));
@@ -466,12 +493,14 @@ class Communication {
    * @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({}, {
+    const options = Object.assign({
       allowLoopback: false,
       resolveHostname: false,
     }, validationOptions);
@@ -489,7 +518,7 @@ class Communication {
       Communication._urlValidScheme(profile);
       Communication._urlPartsDisallowed(profile, ['hash', 'username', 'password', 'port']);
       Communication._urlPathNoDots(url);
-      Communication._urlNamedHost(profile, options.allowLoopback, options.resolveHostname);
+      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}`);
@@ -506,13 +535,13 @@ class Communication {
    * @param {Object} validationOptions
    * @param {Boolean} validationOptions.allowLoopback
    * @param {Boolean} validationOptions.resolveHostname
-   * @returns {URL}
+   * @returns {Promise<URL>}
    */
   async validateClientIdentifier(url, validationOptions) {
     const _scope = _fileScope('validateClientIdentifier');
     const errorScope = 'invalid client identifier url';
 
-    const options = Object.assign({}, {
+    const options = Object.assign({
       allowLoopback: true,
       resolveHostname: true,
     }, validationOptions);
@@ -577,7 +606,7 @@ 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);
@@ -606,6 +635,7 @@ class Communication {
    * @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
@@ -640,7 +670,7 @@ class Communication {
     // 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?.type?.includes('h-card') &&
         item.properties && item.properties.url && item.properties.url.includes(urlObj.href));
       hCards.forEach((hCard) => {
         Object.keys(profile).forEach((key) => {
@@ -656,6 +686,7 @@ class Communication {
     Object.entries({
       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.metadata[p] = profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection
@@ -684,6 +715,7 @@ class Communication {
             issuer: 'issuer',
             authorizationEndpoint: 'authorization_endpoint',
             tokenEndpoint: 'token_endpoint',
+            ticketEndpoint: 'ticket_endpoint',
             introspectionEndpoint: 'introspection_endpoint',
             introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
             revocationEndpoint: 'revocation_endpoint',
@@ -702,7 +734,7 @@ class Communication {
           });
 
           // Populate legacy profile fields.
-          ['authorizationEndpoint', 'tokenEndpoint'].forEach((f) => {
+          ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f) => {
             if (f in profile.metadata) {
               profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection
             }
@@ -716,7 +748,8 @@ class Communication {
 
 
   /**
-   * POST to the auth endpoint, to redeem a code for a profile object.
+   * POST to the auth endpoint, to redeem a code for a profile or token.
+   * N.B. this absorbs any errors!
    * @param {URL} urlObj
    * @param {String} code
    * @param {String} codeVerifier
@@ -724,25 +757,24 @@ class Communication {
    * @param {String} redirectURI
    * @returns {Object}
    */
-  async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
-    const _scope = _fileScope('redeemProfileCode');
+  async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) {
+    const _scope = _fileScope('redeemCode');
 
-    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 postRedeemCodeConfig = Communication._axiosConfig('POST', urlObj, formData, {}, {
       [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);
+      const response = await this.axios(postRedeemCodeConfig);
       try {
         return JSON.parse(response.data);
       } catch (e) {
@@ -750,11 +782,107 @@ class Communication {
         throw e;
       }
     } catch (e) {
-      this.logger.error(_scope, 'redeem profile code request failed', { error: e, url: urlObj.href });
+      this.logger.error(_scope, 'redeem code request failed', { error: e, url: urlObj.href });
       return;
     }
   }
 
+
+  /**
+   * Deprecated method name.
+   * @see redeemCode
+   * @param {URL} urlObj
+   * @param {String} code
+   * @param {Strin} codeVerifier
+   * @param {String} clientId
+   * @param {String} redirectURI
+   * @returns {Object}
+   */
+  async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
+    return this.redeemCode(urlObj, code, codeVerifier, clientId, redirectURI);
+  }
+
+
+  /**
+   * Verify a token with an IdP endpoint, using the Authorization header supplied.
+   * @param {URL} introspectionUrlObj
+   * @param {String} authorizationHeader
+   * @param {String} token
+   */
+  async introspectToken(introspectionUrlObj, authorizationHeader, token) {
+    const _scope = _fileScope('introspectToken');
+
+    const formData = common.formData({ token });
+    const postIntrospectConfig = Communication._axiosConfig('POST', introspectionUrlObj, formData, {}, {
+      [Enum.Header.Authorization]: authorizationHeader,
+      [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 {URL} ticketEndpointUrlObj
+   * @param {URL} resourceUrlObj
+   * @param {URL} subjectUrlObj
+   * @param {String} 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;