X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fcommunication.js;h=d52d784690917d503e852309f4256650cfc4c60e;hb=HEAD;hp=d160707ee3bb157559bf594da2f16d12cfcf0db3;hpb=6c3dbdc13dc125d4faa30d5138a8ada7d14e0ed1;p=squeep-indieauth-helper diff --git a/lib/communication.js b/lib/communication.js index d160707..c1c1128 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -4,31 +4,40 @@ const { mf2 } = require('microformats-parser'); const { parse: parseLinkHeader } = require('@squeep/web-linking'); const { Iconv } = require('iconv'); const { version: packageVersion, name: packageName } = require('../package.json'); -const { randomBytes, createHash } = require('crypto'); -const { promisify } = require('util'); +const { randomBytes, createHash } = require('node:crypto'); +const { promisify } = require('node:util'); const randomBytesAsync = promisify(randomBytes); const { Address4, Address6 } = require('ip-address'); -const dns = require('dns'); +const dns = require('node: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; + +/** + * @typedef {object} ConsoleLike + * @property {Function } debug debug log function + * @property {Function } error error log function + */ class Communication { /** - * @param {Console} logger - * @param {Object} options - * @param {Number=} options.timeout - * @param {Object=} options.userAgent - * @param {String=} options.userAgent.product - * @param {String=} options.userAgent.version - * @param {String=} options.userAgent.implementation + * @param {ConsoleLike} logger logger + * @param {object} options options + * @param {number=} options.timeout request timeout + * @param {object=} options.userAgent user agent object + * @param {string=} options.userAgent.product user agent product + * @param {string=} options.userAgent.version user agent version + * @param {string=} options.userAgent.implementation user agent implementation */ constructor(logger, options = {}) { this.logger = logger; @@ -44,11 +53,13 @@ class Communication { /** * Do a little dance to support this ESM client. + * @param {...any} args request args + * @returns {Promise} response */ async _init(...args) { if (!this.Got) { // For some reason eslint is confused about import being supported here. - // eslint-disable-next-line + this.Got = await import('got'); this.got = this.Got.got.extend({ headers: { @@ -60,7 +71,7 @@ class Communication { }, hooks: { beforeRetry: [ - this._onRetry, + this._onRetry.bind(this), ], }, }); @@ -73,8 +84,8 @@ class Communication { /** * Take notes on transient retries. - * @param {*} error - * @param {*} retryCount + * @param {*} error error + * @param {*} retryCount retry count */ _onRetry(error, retryCount) { const _scope = _fileScope('_onRetry'); @@ -82,10 +93,12 @@ class Communication { } + /** + * @typedef {string|Buffer|DataView} BinaryLike /** * Encode hashed verifier data for PKCE. - * @param {BinaryLike} verifier - * @returns {String} + * @param {BinaryLike} verifier verifier + * @returns {string} challenge */ static _challengeFromVerifier(verifier) { const hash = createHash('sha256'); @@ -96,14 +109,14 @@ class Communication { /** * @typedef PKCEData - * @property {String} codeChallengeMethod - * @property {String} codeVerifier - * @property {String} codeChallenge + * @property {string} codeChallengeMethod challenge method + * @property {string} codeVerifier code verifier + * @property {string} codeChallenge code challenge */ /** * Create a code verifier and its challenge. - * @param {Number} length of verifier string, between 43 and 128 - * @returns {Promise} + * @param {number} length of verifier string, between 43 and 128 + * @returns {Promise} data */ static async generatePKCE(length = 128) { if (length < 43 || length > 128) { @@ -126,10 +139,10 @@ class Communication { /** * Check a challenge with a verifier. - * @param {String} codeChallenge - * @param {String} codeVerifier - * @param {String} codeChallengeMethod - * @returns {Boolean} + * @param {string} codeChallenge challenge + * @param {string} codeVerifier verifier + * @param {string} codeChallengeMethod method + * @returns {boolean} is valid */ static verifyChallenge(codeChallenge, codeVerifier, codeChallengeMethod) { switch (codeChallengeMethod) { @@ -147,11 +160,11 @@ class Communication { /** * Assemble a suitable User-Agent value. - * @param {Object} userAgentConfig - * @param {String=} userAgentConfig.product - * @param {String=} userAgentConfig.version - * @param {String=} userAgentConfig.implementation - * @returns {String} + * @param {object} userAgentConfig user agent object + * @param {string=} userAgentConfig.product product + * @param {string=} userAgentConfig.version version + * @param {string=} userAgentConfig.implementation implementation + * @returns {string} UA string */ static _userAgentString(userAgentConfig) { // eslint-disable-next-line security/detect-object-injection @@ -169,8 +182,8 @@ class Communication { /** * Isolate the base of a url. * mf2 parser needs this so that relative links can be made absolute. - * @param {URL} urlObj - * @returns {String} + * @param {URL} urlObj url + * @returns {string} url base */ static _baseUrlString(urlObj) { const baseUrl = new URL(urlObj); @@ -182,16 +195,20 @@ class Communication { } + /** + * @typedef {object} ParsedContentType + * @property {string} mediaType media type + * @property {object} params other parameters + */ /** * Convert a Content-Type string to normalized components. * RFC7231 §3.1.1 * N.B. this ill-named non-parsing implementation will not work * if a parameter value for some reason includes a ; or = within * a quoted-string. - * @param {String} contentTypeHeader - * @returns {Object} contentType - * @returns {String} contentType.mediaType - * @returns {Object} contentType.params + * @param {string} contentTypeHeader content-type header value + * @param {string} defaultContentType default type if none present + * @returns {ParsedContentType} contentType */ static _parseContentType(contentTypeHeader, defaultContentType = Enum.ContentType.ApplicationOctetStream) { const [ mediaType, ...params ] = (contentTypeHeader || '').split(/ *; */); @@ -209,9 +226,9 @@ class Communication { /** * Parse and add any header link relations from response to microformat data. - * @param {Object} microformat - * @param {Object} response - * @param {Object} response.headers + * @param {object} microformat microformat + * @param {object} response response + * @param {object} response.headers response headers */ _mergeLinkHeader(microformat, response) { const _scope = _fileScope('_mergeLinkHeader'); @@ -263,8 +280,8 @@ class Communication { /** * Retrieve and parse microformat data from url. * N.B. this absorbs any errors! - * @param {URL} urlObj - * @returns {Promise} + * @param {URL} urlObj url + * @returns {Promise} microformat */ async fetchMicroformat(urlObj) { const _scope = _fileScope('fetchMicroformat'); @@ -291,7 +308,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'); @@ -327,8 +344,8 @@ class Communication { /** * Retrieve and parse JSON. * N.B. this absorbs any errors! - * @param {URL} urlObj - * @returns {Promise} + * @param {URL} urlObj url + * @returns {Promise} parsed json */ async fetchJSON(urlObj) { const _scope = _fileScope('fetchJSON'); @@ -351,7 +368,6 @@ class Communication { this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData }); return; } - logInfoData.response = common.gotResponseLogData(response); return response.body; } @@ -359,8 +375,8 @@ class Communication { /** * Validate a url has a specific schema. - * @param {URL} urlObj - * @param {String[]} validSchemes + * @param {URL} urlObj url + * @param {string[]} validSchemes url schemes */ static _urlValidScheme(urlObj, validSchemes = ['http:', 'https:']) { if (!validSchemes.includes(urlObj.protocol)) { @@ -371,8 +387,8 @@ class Communication { /** * Validate a url does not include some components. - * @param {URL} urlObj - * @param {String[]} disallowed + * @param {URL} urlObj url + * @param {string[]} disallowed component names */ static _urlPartsDisallowed(urlObj, disallowed) { disallowed.forEach((part) => { @@ -385,7 +401,7 @@ class Communication { /** * Validate a url does not have relative path. - * @param {String} url + * @param {string} url url */ static _urlPathNoDots(url) { if (noDotPathRE.test(url)) { @@ -397,9 +413,9 @@ class Communication { /** * Validate a url does not have a hostname which is an ip address. * N.B. Sets isLoopback on urlObj - * @param {URL} urlObj - * @param {Boolean} allowLoopback - * @param {Boolean} resolveHostname + * @param {URL} urlObj url + * @param {boolean} allowLoopback allow loopback ip + * @param {boolean} resolveHostname resolve hostname for checks * @returns {Promise} */ static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) { @@ -411,15 +427,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 */ + } catch (e) { // eslint-disable-line no-unused-vars + /* Did not parse as ip, carry on */ } } @@ -446,12 +462,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)); }); @@ -487,20 +503,21 @@ class Communication { /** * Ensure a url meets the requirements to be a profile uri. - * @param {String} url - * @param {Object} validationOptions - * @param {Boolean=} validationOptions.allowLoopback default is false, following spec - * @param {Boolean=} validationOptions.resolveHostname default is false, following spec - * @returns {Promise} + * @param {string} url url + * @param {object} validationOptions options + * @param {boolean=} validationOptions.allowLoopback default is false, following spec + * @param {boolean=} validationOptions.resolveHostname default is false, following spec + * @returns {Promise} validated url */ async validateProfile(url, validationOptions) { const _scope = _fileScope('validateProfile'); const errorScope = 'invalid profile url'; - const options = Object.assign({ + const options = { allowLoopback: false, resolveHostname: false, - }, validationOptions); + ...validationOptions, + }; let profile; try { @@ -528,20 +545,21 @@ class Communication { /** * Ensure a url meets the requirements to be a client identifier. * Sets 'isLoopback' on returned URL object to true if hostname is - or resolves to - a loopback ip. - * @param {String} url - * @param {Object} validationOptions - * @param {Boolean=} validationOptions.allowLoopback default is true, following spec - * @param {Boolean=} validationOptions.resolveHostname default is true, following spec - * @returns {Promise} + * @param {string} url url + * @param {object} validationOptions options + * @param {boolean=} validationOptions.allowLoopback default is true, following spec + * @param {boolean=} validationOptions.resolveHostname default is true, following spec + * @returns {Promise} validated client url */ async validateClientIdentifier(url, validationOptions) { 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 { @@ -567,28 +585,29 @@ class Communication { /** - * @typedef {Object} ClientIdentifierData - * @property {Object} rels - keyed by relation to array of uris - * @property {HAppData[]} items + * @typedef {object} HAppData + * @property {string[]=} type list of item types + * @property {object=} properties map of item properties + */ + /** + * @typedef {object} ClientIdentifierData + * @property {object} rels keyed by relation to array of uris + * @property {HAppData[]} items h-app data */ /** * Retrieve and parse client identifier endpoint data. * N.B. Assumes urlObj has passed validateClientIdentifier. - * @param {URL} urlObj + * @param {URL} urlObj url * @returns {Promise} 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); @@ -610,7 +629,7 @@ class Communication { if (hUrl.href === urlObj.href) { urlMatched = true; } - } catch (e) { /**/ } + } catch (e) { /**/ } // eslint-disable-line no-unused-vars }); } return urlMatched; @@ -619,37 +638,40 @@ class Communication { } + /** + * @typedef {object} Metadata + * @property {string} issuer issuer + * @property {string} authorizationEndpoint authorization endpoint + * @property {string} tokenEndpoint token endpoint + * @property {string} ticketEndpoint ticket endpoint + * @property {string} introspectionEndpoint introspection endpoint + * @property {string} introspectionEndpointAuthMethodsSupported auth methods + * @property {string} revocationEndpoint revocation endpoint + * @property {string} revocationEndpointAuthMethodsSupported auth methods + * @property {string} scopesSupported scopes + * @property {string} responseTypesSupported response types + * @property {string} grantTypesSupported grant types + * @property {string} serviceDocumentation service documentation reference + * @property {string} codeChallengeMethodsSupported code challenge methods + * @property {string} authorizationResponseIssParameterSupported iss parameter supported + * @property {string} userinfoEndpoint endpoint + */ /** * @typedef ProfileData - * @property {String} name - * @property {String} photo - * @property {String} url - * @property {String} email - * @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 {string} name name + * @property {string} photo photo + * @property {string} url url + * @property {string} email email + * @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 {Metadata} metadata authorization server metadata for profile */ /** * Fetch the relevant microformat data from profile url h-card information, * and authorization server metadata. * N.B. Assumes urlObj has passed validateProfile - * @param {URL} urlObj + * @param {URL} urlObj valid profile url * @returns {Promise} mf2 data filtered for select fields from h-card */ async fetchProfile(urlObj) { @@ -664,12 +686,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 +706,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 +716,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. @@ -701,43 +725,19 @@ class Communication { let mdURL; try { mdURL = new URL(profile.indieauthMetadata); - } catch (e) /* istanbul ignore next */ { + } catch (e) /* istanbul ignore next */ { // eslint-disable-line no-unused-vars 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', - 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,15 +745,52 @@ class Communication { } + /** + * Fetch the server metadata from an authorization server's metadata endpoint. + * @param {URL} metadataUrl url + * @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! - * @param {URL} urlObj - * @param {String} code - * @param {String} codeVerifier - * @param {String} clientId - * @param {String} redirectURI - * @returns {Promise} + * @param {URL} urlObj url + * @param {string} code code + * @param {string} codeVerifier verifier + * @param {string} clientId client id + * @param {string} redirectURI uri + * @returns {Promise} response */ async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) { const _scope = _fileScope('redeemCode'); @@ -787,12 +824,12 @@ class Communication { /** * Deprecated method name alias. * @see redeemCode - * @param {URL} urlObj - * @param {String} code - * @param {Strin} codeVerifier - * @param {String} clientId - * @param {String} redirectURI - * @returns {Promise} + * @param {URL} urlObj url + * @param {string} code code + * @param {string} codeVerifier verifier + * @param {string} clientId client id + * @param {string} redirectURI uri + * @returns {Promise} response */ async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) { return this.redeemCode(urlObj, code, codeVerifier, clientId, redirectURI); @@ -801,10 +838,10 @@ class Communication { /** * Verify a token with an IdP endpoint, using the Authorization header supplied. - * @param {URL} introspectionUrlObj - * @param {String} authorizationHeader - * @param {String} token - * @returns {Promise} + * @param {URL} introspectionUrlObj url + * @param {string} authorizationHeader authorization + * @param {string} token token + * @returns {Promise} response */ async introspectToken(introspectionUrlObj, authorizationHeader, token) { const _scope = _fileScope('introspectToken'); @@ -824,8 +861,7 @@ class Communication { try { const response = await this.got(postIntrospectConfig); - this.logger.debug(_scope, 'response', { response }); - // check status + // Check status try { const { active, @@ -862,13 +898,14 @@ class Communication { /** * 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} + * @param {URL} ticketEndpointUrlObj url ticket endpoint url + * @param {URL} resourceUrlObj url resource url + * @param {URL} subjectUrlObj url subject url + * @param {string} ticket ticket + * @param {URL=} issuerUrlObj issuer url + * @returns {Promise} response */ - async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) { + async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj) { const _scope = _fileScope('deliverTicket'); try { @@ -879,15 +916,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 url + * @returns {Promise} data + */ + 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) { // eslint-disable-line no-unused-vars + 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) { // eslint-disable-line no-unused-vars + 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 + * @param {string} ticket ticket + * @param {URL} resourceUrlObj resource url + * @param {URL=} issuerUrlObj issuer url + * @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) { // eslint-disable-line no-unused-vars + 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;