X-Git-Url: http://git.squeep.com/?p=squeep-indieauth-helper;a=blobdiff_plain;f=lib%2Fcommunication.js;fp=lib%2Fcommunication.js;h=6d2334a246f633bfea998820a7507c77829e2654;hp=d160707ee3bb157559bf594da2f16d12cfcf0db3;hb=45d151c0587750c9a8ed3e3180ca79139a6ccb5e;hpb=1d9686ba6995243a939f9438045f92e7573e9b85 diff --git a/lib/communication.js b/lib/communication.js index d160707..6d2334a 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -19,6 +19,7 @@ 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 { /** @@ -291,7 +292,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 +352,6 @@ class Communication { this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData }); return; } - logInfoData.response = common.gotResponseLogData(response); return response.body; } @@ -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, @@ -706,38 +709,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 +724,43 @@ class Communication { } + /** + * Fetch the server metadata from an authorization server's metadata endpoint. + * @param {URL} metadataUrl + * @returns {Promise} + */ + 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,7 +840,6 @@ class Communication { try { const response = await this.got(postIntrospectConfig); - this.logger.debug(_scope, 'response', { response }); // check status try { const { @@ -865,10 +880,11 @@ class Communication { * @param {URL} ticketEndpointUrlObj * @param {URL} resourceUrlObj * @param {URL} subjectUrlObj + * @param {URL=} issuerUrlObj * @param {String} ticket * @returns {Promise} */ - async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) { + async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj) { const _scope = _fileScope('deliverTicket'); try { @@ -879,6 +895,7 @@ class Communication { ticket, resource: resourceUrlObj.href, subject: subjectUrlObj.href, + ...( issuerUrlObj && { iss: issuerUrlObj.href }), }, }; return await this.got(ticketConfig); @@ -888,6 +905,98 @@ class Communication { } } + + /** + * Attempt to fetch some link relations from a url. + * @param {URL} urlObj + * @returns {Promise} + */ + async _fetchMetadataOrTokenEndpoint(urlObj) { + const _scope = _fileScope('_fetchMetadataOrTokenEndpoint'); + + let metadataUrl, tokenUrl; + if (urlObj) { + const mfData = await this.fetchMicroformat(urlObj); + const metadataRel = mfData?.rels?.['indieauth-metadata']?.[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?.['token_endpoint']?.[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} 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;