X-Git-Url: http://git.squeep.com/?p=squeep-indieauth-helper;a=blobdiff_plain;f=lib%2Fcommunication.js;h=2b0c621388aa322a599766064a0658afba50c97d;hp=51f5b430c9fc160687c07111f1fe4727e759800b;hb=8daa668e5a0485b5f0e2b07203d9fb237d7f761c;hpb=a15c4051aee22b76eca268e6a53b0944a9e40d0c diff --git a/lib/communication.js b/lib/communication.js index 51f5b43..2b0c621 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -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; }