X-Git-Url: https://git.squeep.com/?a=blobdiff_plain;f=lib%2Fcommunication.js;h=6d2334a246f633bfea998820a7507c77829e2654;hb=45d151c0587750c9a8ed3e3180ca79139a6ccb5e;hp=d52d784690917d503e852309f4256650cfc4c60e;hpb=30851a8cb9f8823b1b395ace8f53d62c5c53abd8;p=squeep-indieauth-helper diff --git a/lib/communication.js b/lib/communication.js index d52d784..6d2334a 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -1,18 +1,14 @@ 'use strict'; -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'); -const { performance } = require('perf_hooks'); const { randomBytes, createHash } = require('crypto'); 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'); @@ -23,11 +19,13 @@ 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 { /** * @param {Console} logger * @param {Object} options + * @param {Number=} options.timeout * @param {Object=} options.userAgent * @param {String=} options.userAgent.product * @param {String=} options.userAgent.version @@ -36,20 +34,52 @@ class Communication { constructor(logger, options = {}) { this.logger = logger; this.options = options; - this.axios = axios.create({ - headers: { - [Enum.Header.UserAgent]: Communication._userAgentString(options.userAgent), - [Enum.Header.Accept]: 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1', - }, - }); - this.axios.interceptors.request.use((request) => { - request.startTimestampMs = performance.now(); - return request; - }); - this.axios.interceptors.response.use((response) => { - response.elapsedTimeMs = performance.now() - response.config.startTimestampMs; - return response; - }); + + this._defaultAccept = options?.defaultAccept || 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1'; + this._jsonAccept = options?.jsonAccept || [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', '); + + this.Got = undefined; + this.got = this._init; // Do the dynamic import on first attempt to use client. + } + + + /** + * Do a little dance to support this ESM client. + */ + 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: { + [Enum.Header.UserAgent]: Communication._userAgentString(this.options.userAgent), + [Enum.Header.Accept]: this._defaultAccept, + }, + timeout: { + request: this.options.timeout || 120000, + }, + hooks: { + beforeRetry: [ + this._onRetry, + ], + }, + }); + } + if (args.length) { + return this.got(...args); + } + } + + + /** + * Take notes on transient retries. + * @param {*} error + * @param {*} retryCount + */ + _onRetry(error, retryCount) { + const _scope = _fileScope('_onRetry'); + this.logger.debug(_scope, 'retry', { retryCount, error }); } @@ -61,7 +91,7 @@ class Communication { static _challengeFromVerifier(verifier) { const hash = createHash('sha256'); hash.update(verifier); - return base64ToBase64URL(hash.digest('base64')); + return hash.digest('base64url'); } @@ -73,7 +103,7 @@ class Communication { */ /** * Create a code verifier and its challenge. - * @param {Number} length + * @param {Number} length of verifier string, between 43 and 128 * @returns {Promise} */ static async generatePKCE(length = 128) { @@ -83,7 +113,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); @@ -137,46 +167,6 @@ 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 - * @param {URL} urlObj - * @param {String=} body - * @param {Object=} params - * @param {Object=} headers - * @returns {Object} - */ - static _axiosConfig(method, urlObj, body, params = {}, headers = {}) { - const config = { - method, - url: `${urlObj.origin}${urlObj.pathname}`, - params: urlObj.searchParams, - headers, - ...(body && { data: body }), - // Setting this does not appear to be enough to keep axios from parsing JSON response into object - 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; - } - - /** * Isolate the base of a url. * mf2 parser needs this so that relative links can be made absolute. @@ -210,7 +200,7 @@ class Communication { mediaType: mediaType.toLowerCase() || defaultContentType, params: params.reduce((obj, param) => { const [field, value] = param.split('='); - const isQuoted = value && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"'; + const isQuoted = value?.startsWith('"') && value?.endsWith('"'); obj[field.toLowerCase()] = isQuoted ? value.slice(1, value.length - 1) : value; return obj; }, {}), @@ -219,9 +209,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'); @@ -285,27 +276,34 @@ class Communication { }; let response; try { - const fetchMicroformatConfig = Communication._axiosConfig('GET', urlObj); - response = await this.axios(fetchMicroformatConfig); + const fetchMicroformatConfig = { + method: 'GET', + url: urlObj, + responseType: 'buffer', + }; + response = await this.got(fetchMicroformatConfig); } catch (e) { this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData }); return; } - logInfoData.response = common.axiosResponseLogData(response); + logInfoData.response = common.gotResponseLogData(response); // Normalize to utf8. - let body = response.data; + let body; const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]); - const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset; + // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8. + const nonUTF8Charset = !utf8CharsetRE.test(contentType.params.charset) && contentType.params.charset; if (nonUTF8Charset) { try { const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore'); - body = iconv.convert(body).toString('utf8'); + body = iconv.convert(response.body).toString('utf8'); } catch (e) { // istanbul ignore next this.logger.error(_scope, 'iconv conversion error', { error: e, ...logInfoData }); // Try to carry on, maybe the encoding will work anyhow... } + } else { + body = response.body.toString('utf8'); } let microformat = {}; @@ -341,24 +339,21 @@ class Communication { }; 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); + const fetchJSONConfig = { + method: 'GET', + url: urlObj, + headers: { + [Enum.Header.Accept]: this._jsonAccept, + }, + responseType: 'json', + }; + response = await this.got(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; + return response.body; } @@ -404,6 +399,7 @@ class Communication { * N.B. Sets isLoopback on urlObj * @param {URL} urlObj * @param {Boolean} allowLoopback + * @param {Boolean} resolveHostname * @returns {Promise} */ static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) { @@ -439,7 +435,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, }))); @@ -493,9 +489,9 @@ class Communication { * Ensure a url meets the requirements to be a profile uri. * @param {String} url * @param {Object} validationOptions - * @param {Boolean} validationOptions.allowLoopback - * @param {Boolean} validationOptions.resolveHostname - * @returns {Promise} + * @param {Boolean=} validationOptions.allowLoopback default is false, following spec + * @param {Boolean=} validationOptions.resolveHostname default is false, following spec + * @returns {Promise} */ async validateProfile(url, validationOptions) { const _scope = _fileScope('validateProfile'); @@ -519,7 +515,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}`); @@ -531,11 +527,11 @@ 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. + * 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 - * @param {Boolean} validationOptions.resolveHostname + * @param {Boolean=} validationOptions.allowLoopback default is true, following spec + * @param {Boolean=} validationOptions.resolveHostname default is true, following spec * @returns {Promise} */ async validateClientIdentifier(url, validationOptions) { @@ -579,7 +575,7 @@ class Communication { * Retrieve and parse client identifier endpoint data. * N.B. Assumes urlObj has passed validateClientIdentifier. * @param {URL} urlObj - * @returns {ClientIdentifierData|undefined} mf2 data filtered for h-app items, or undefined if url could not be fetched + * @returns {Promise} mf2 data filtered for h-app items, or undefined if url could not be fetched */ async fetchClientIdentifier(urlObj) { const _scope = _fileScope('fetchClientIdentifier'); @@ -607,7 +603,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); @@ -623,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 @@ -632,28 +646,14 @@ 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, * and authorization server metadata. + * N.B. Assumes urlObj has passed validateProfile * @param {URL} urlObj - * @returns {ProfileData} mf2 data filtered for select fields from h-card + * @returns {Promise} mf2 data filtered for select fields from h-card */ async fetchProfile(urlObj) { const _scope = _fileScope('fetchProfile'); @@ -671,7 +671,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) => { @@ -709,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 + } + }); } } @@ -749,70 +725,123 @@ class Communication { /** - * POST to the auth endpoint, to redeem a code for a profile object. - * FIXME: [name] this isn't specific to profile redemption, it works for tokens too + * 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! * @param {URL} urlObj * @param {String} code * @param {String} codeVerifier * @param {String} clientId * @param {String} redirectURI - * @returns {Object} + * @returns {Promise} */ - async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) { - const _scope = _fileScope('redeemProfileCode'); - - const formData = common.formData({ - 'grant_type': 'authorization_code', - code, - 'client_id': clientId, - 'redirect_uri': redirectURI, - 'code_verifier': codeVerifier, - }); + async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) { + const _scope = _fileScope('redeemCode'); - const postRedeemProfileCodeConfig = Communication._axiosConfig('POST', urlObj, formData, {}, { - [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, - [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`, - }); + const postRedeemCodeConfig = { + url: urlObj, + method: 'POST', + headers: { + [Enum.Header.Accept]: this._jsonAccept, + }, + form: { + 'grant_type': 'authorization_code', + code, + 'client_id': clientId, + 'redirect_uri': redirectURI, + 'code_verifier': codeVerifier, + }, + responseType: 'json', + }; try { - const response = await this.axios(postRedeemProfileCodeConfig); - try { - return JSON.parse(response.data); - } catch (e) { - this.logger.error(_scope, 'failed to parse json', { error: e, response }); - throw e; - } + const response = await this.got(postRedeemCodeConfig); + return response.body; } 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; } } /** - * Verify a token with an IdP endpoint, using the Authentication header supplied. + * Deprecated method name alias. + * @see redeemCode + * @param {URL} urlObj + * @param {String} code + * @param {Strin} codeVerifier + * @param {String} clientId + * @param {String} redirectURI + * @returns {Promise} + */ + 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} authenticationHeader + * @param {String} authorizationHeader * @param {String} token + * @returns {Promise} */ - async introspectToken(introspectionUrlObj, authenticationHeader, token) { + async introspectToken(introspectionUrlObj, authorizationHeader, token) { const _scope = _fileScope('introspectToken'); - const formData = common.formData({ token }); - const postIntrospectConfig = Communication._axiosConfig('POST', introspectionUrlObj, formData, {}, { - [Enum.Header.Authentication]: authenticationHeader, - [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, - [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`, - }); - delete postIntrospectConfig.validateStatus; // only accept success + const postIntrospectConfig = { + url: introspectionUrlObj, + method: 'POST', + headers: { + [Enum.Header.Authorization]: authorizationHeader, + [Enum.Header.Accept]: this._jsonAccept, + }, + form: { + token, + }, + responseType: 'json', + }; - let tokenInfo; try { - const response = await this.axios(postIntrospectConfig); - this.logger.debug(_scope, 'response', { response }); + const response = await this.got(postIntrospectConfig); // check status try { - tokenInfo = JSON.parse(response.data); const { active, me, @@ -820,7 +849,11 @@ class Communication { scope, exp, iat, - } = tokenInfo; + } = response.body; + + if (![true, false].includes(active)) { + throw new RangeError('missing required response field "active"'); + } return { active, @@ -831,7 +864,7 @@ class Communication { ...(iat && { iat: Number(iat) }), }; } catch (e) { - this.logger.error(_scope, 'failed to parse json', { error: e, response }); + this.logger.error(_scope, 'failed to parse json', { error: e, response: common.gotResponseLogData(response) }); throw e; } } catch (e) { @@ -844,31 +877,126 @@ class Communication { /** * Attempt to deliver a ticket to an endpoint. * N.B. does not absorb errors - * @param {*} ticketEndpointUrlObj - * @param {*} resourceUrlObj - * @param {*} subjectUrlObj - * @param {*} ticket - * @returns {Promise} + * @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 { - const ticketPayload = { - ticket, - resource: resourceUrlObj.href, - subject: subjectUrlObj.href, + const ticketConfig = { + method: 'POST', + url: ticketEndpointUrlObj, + form: { + ticket, + resource: resourceUrlObj.href, + subject: subjectUrlObj.href, + ...( issuerUrlObj && { iss: issuerUrlObj.href }), + }, }; - const ticketConfig = Communication._axiosConfig('POST', ticketEndpointUrlObj, ticketPayload, {}, { - [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, - }); - return await this.axios(ticketConfig); + return await this.got(ticketConfig); } 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 + * @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;