support indieauth-metadata relation on profiles, per spec 20220212
[squeep-indieauth-helper] / lib / communication.js
index 51f5b430c9fc160687c07111f1fe4727e759800b..2b0c621388aa322a599766064a0658afba50c97d 100644 (file)
@@ -291,6 +291,41 @@ class Communication {
   }
 
 
+  /**
+   * Retrieve and parse JSON.
+   * N.B. this absorbs any errors!
+   * @param {URL} urlObj
+   * @returns {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;
+  }
+
+
   /**
    * @typedef {Object} ClientIdentifierData
    * @property {Object} rels - keyed by relation to array of uris
@@ -336,23 +371,45 @@ 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.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,15 +422,67 @@ 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
     }).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',
+            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'].forEach((f) => {
+            if (f in profile.metadata) {
+              profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection
+            }
+          });
+        }
+      }
+    }
+
     return profile;
   }