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 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;
/**
* Do a little dance to support this ESM client.
+ * @param {...any} args request args
+ * @returns {Promise<any>} 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: {
},
hooks: {
beforeRetry: [
- this._onRetry,
+ this._onRetry.bind(this),
],
},
});
/**
* Take notes on transient retries.
- * @param {*} error
- * @param {*} retryCount
+ * @param {*} error error
+ * @param {*} retryCount retry count
*/
_onRetry(error, retryCount) {
const _scope = _fileScope('_onRetry');
}
+ /**
+ * @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');
/**
* @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<PKCEData>}
+ * @param {number} length of verifier string, between 43 and 128
+ * @returns {Promise<PKCEData>} data
*/
static async generatePKCE(length = 128) {
if (length < 43 || length > 128) {
/**
* 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) {
/**
* 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
/**
* 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);
}
+ /**
+ * @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(/ *; */);
/**
* 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');
/**
* Retrieve and parse microformat data from url.
* N.B. this absorbs any errors!
- * @param {URL} urlObj
- * @returns {Promise<Object>}
+ * @param {URL} urlObj url
+ * @returns {Promise<object>} microformat
*/
async fetchMicroformat(urlObj) {
const _scope = _fileScope('fetchMicroformat');
/**
* Retrieve and parse JSON.
* N.B. this absorbs any errors!
- * @param {URL} urlObj
- * @returns {Promise<Object>}
+ * @param {URL} urlObj url
+ * @returns {Promise<object>} parsed json
*/
async fetchJSON(urlObj) {
const _scope = _fileScope('fetchJSON');
/**
* 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)) {
/**
* 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) => {
/**
* Validate a url does not have relative path.
- * @param {String} url
+ * @param {string} url url
*/
static _urlPathNoDots(url) {
if (noDotPathRE.test(url)) {
/**
* 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<void>}
*/
static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) {
* 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 */
}
}
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));
});
/**
* 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<URL>}
+ * @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<URL>} 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 {
/**
* 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<URL>}
+ * @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<URL>} 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 {
/**
- * @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<ClientIdentifierData|undefined>} 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);
if (hUrl.href === urlObj.href) {
urlMatched = true;
}
- } catch (e) { /**/ }
+ } catch (e) { /**/ } // eslint-disable-line no-unused-vars
});
}
return urlMatched;
/**
- * @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 {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 {Metadata} metadata - authorization server metadata for profile
+ * @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<ProfileData>} mf2 data filtered for select fields from h-card
*/
async fetchProfile(urlObj) {
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
// 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
});
// 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.
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 */
/**
* Fetch the server metadata from an authorization server's metadata endpoint.
- * @param {URL} metadataUrl
- * @returns {Promise<Metadata>}
+ * @param {URL} metadataUrl url
+ * @returns {Promise<Metadata>} metadata
*/
async fetchMetadata(metadataUrl) {
const metadataResponse = await this.fetchJSON(metadataUrl);
/**
* 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<Object>}
+ * @param {URL} urlObj url
+ * @param {string} code code
+ * @param {string} codeVerifier verifier
+ * @param {string} clientId client id
+ * @param {string} redirectURI uri
+ * @returns {Promise<object>} response
*/
async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) {
const _scope = _fileScope('redeemCode');
/**
* Deprecated method name alias.
* @see redeemCode
- * @param {URL} urlObj
- * @param {String} code
- * @param {Strin} codeVerifier
- * @param {String} clientId
- * @param {String} redirectURI
- * @returns {Promise<Object>}
+ * @param {URL} urlObj url
+ * @param {string} code code
+ * @param {string} codeVerifier verifier
+ * @param {string} clientId client id
+ * @param {string} redirectURI uri
+ * @returns {Promise<object>} response
*/
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} authorizationHeader
- * @param {String} token
- * @returns {Promise<Object>}
+ * @param {URL} introspectionUrlObj url
+ * @param {string} authorizationHeader authorization
+ * @param {string} token token
+ * @returns {Promise<object>} response
*/
async introspectToken(introspectionUrlObj, authorizationHeader, token) {
const _scope = _fileScope('introspectToken');
try {
const response = await this.got(postIntrospectConfig);
- // check status
+ // Check status
try {
const {
active,
/**
* Attempt to deliver a ticket to an endpoint.
* N.B. does not absorb errors
- * @param {URL} ticketEndpointUrlObj
- * @param {URL} resourceUrlObj
- * @param {URL} subjectUrlObj
- * @param {URL=} issuerUrlObj
- * @param {String} ticket
- * @returns {Promise<Response>}
+ * @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>} response
*/
async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj) {
const _scope = _fileScope('deliverTicket');
...( 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
- * @returns {Promise<Object>}
+ * @param {URL} urlObj url
+ * @returns {Promise<object>} data
*/
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];
+ const metadataRel = mfData?.rels?.[MF2Rel.IndieauthMetadata]?.[0];
if (metadataRel) {
try {
metadataUrl = new URL(metadataRel);
- } catch (e) {
+ } 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?.['token_endpoint']?.[0];
+ // No metadata rel, try old-style token endpoint
+ const tokenRel = mfData?.rels?.[MF2Rel.TokenEndpoint]?.[0];
if (tokenRel) {
try {
tokenUrl = new URL(tokenRel);
- } catch (e) {
+ } catch (e) { // eslint-disable-line no-unused-vars
this.logger.debug(_scope, 'invalid token rel url', { url: urlObj.href, tokenRel });
}
}
/**
* 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<Object>} response body
+ * @param {string} ticket ticket
+ * @param {URL} resourceUrlObj resource url
+ * @param {URL=} issuerUrlObj issuer url
+ * @returns {Promise<object>} response body
*/
async redeemTicket(ticket, resourceUrlObj, issuerUrlObj) {
const _scope = _fileScope('redeemTicket');
const metadata = await this.fetchMetadata(metadataUrl);
try {
tokenUrl = new URL(metadata?.tokenEndpoint);
- } catch (e) {
+ } 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 });
}
}