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 { 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');
const _fileScope = common.fileScope(__filename);
+const noDotPathRE = /(\/\.\/|\/\.\.\/)/;
+const v6HostRE = /\[[0-9a-f:]+\]/;
+const loopback4 = new Address4('127.0.0.0/8');
+const scopeSplitRE = / +/;
+
class Communication {
/**
* @param {Console} logger
}
+ /**
+ * Encode hashed verifier data for PKCE.
+ * @param {BinaryLike} verifier
+ * @returns {String}
+ */
static _challengeFromVerifier(verifier) {
const hash = createHash('sha256');
hash.update(verifier);
- return common.base64ToBase64URL(hash.digest('base64'));
+ return base64ToBase64URL(hash.digest('base64'));
}
+
+ /**
+ * @typedef PKCEData
+ * @property {String} codeChallengeMethod
+ * @property {String} codeVerifier
+ * @property {String} codeChallenge
+ */
/**
* Create a code verifier and its challenge.
* @param {Number} length
- * @returns {Object}
+ * @returns {Promise<PKCEData>}
*/
static async generatePKCE(length = 128) {
if (length < 43 || length > 128) {
const bufferLength = Math.floor(length * 3 / 4);
const randomBuffer = await randomBytesAsync(bufferLength);
- const verifier = common.base64ToBase64URL(randomBuffer.toString('base64'));
+ const verifier = base64ToBase64URL(randomBuffer.toString('base64'));
const challenge = Communication._challengeFromVerifier(verifier);
}
+ /**
+ * 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
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;
* Retrieve and parse microformat data from url.
* N.B. this absorbs any errors!
* @param {URL} urlObj
- * @returns {Object}
+ * @returns {Promise<Object>}
*/
async fetchMicroformat(urlObj) {
const _scope = _fileScope('fetchMicroformat');
}
+ /**
+ * Retrieve and parse JSON.
+ * N.B. this absorbs any errors!
+ * @param {URL} urlObj
+ * @returns {Promise<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;
+ }
+
+
+ /**
+ * Validate a url has a specific schema.
+ * @param {URL} urlObj
+ * @param {String[]} validSchemes
+ */
+ static _urlValidScheme(urlObj, validSchemes = ['http:', 'https:']) {
+ if (!validSchemes.includes(urlObj.protocol)) {
+ throw new ValidationError(`unsupported url scheme '${urlObj.protocol}'`);
+ }
+ }
+
+
+ /**
+ * Validate a url does not include some components.
+ * @param {URL} urlObj
+ * @param {String[]} disallowed
+ */
+ static _urlPartsDisallowed(urlObj, disallowed) {
+ disallowed.forEach((part) => {
+ if (urlObj[part]) { // eslint-disable-line security/detect-object-injection
+ throw new ValidationError(`url cannot contain ${common.properURLComponentName(part)}`);
+ }
+ });
+ }
+
+
+ /**
+ * Validate a url does not have relative path.
+ * @param {String} url
+ */
+ static _urlPathNoDots(url) {
+ if (noDotPathRE.test(url)) {
+ throw new ValidationError('relative path segment not valid');
+ }
+ }
+
+
+ /**
+ * 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
+ * @returns {Promise<void>}
+ */
+ static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) {
+ let address;
+ if (v6HostRE.test(urlObj.hostname)) {
+ /**
+ * We do not need to worry about the Address6() failing to parse,
+ * as if it looks like an ipv6 addr but is not valid, the URL()
+ * call would already have failed.
+ */
+ address = new Address6(urlObj.hostname.slice(1, urlObj.hostname.length - 1));
+ /* 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 */
+ urlObj.isLoopback = address.isInSubnet(loopback4);
+ } catch (e) {
+ /* did not parse as ip, carry on */
+ }
+ }
+
+ if (resolveHostname && !urlObj.isLoopback) {
+ /**
+ * Resolve hostname to check for localhost.
+ * This is more complicated due to SSRF mitigation:
+ * If the hostname does not end with a ., we also resolve that,
+ * and complain if the two resolutions do not match, assuming
+ * malicious intent for the server to resolve a local record.
+ */
+ const hostnames = [urlObj.hostname];
+ if (!urlObj.hostname.endsWith('.')) {
+ hostnames.push(urlObj.hostname + '.');
+ }
+ const settledResolutions = await Promise.allSettled(hostnames.map((hostname) => dns.lookupAsync(hostname, {
+ all: true,
+ verbatim: true,
+ })));
+ // If any resolution failed, bail.
+ if (settledResolutions
+ .map((resolution) => resolution.status)
+ .includes('rejected')) {
+ throw new ValidationError('could not resolve hostname');
+ }
+
+ // 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
+ const addressSets = resolutions.map((addrs) => {
+ return new Set((addrs || []).map((a) => a.address));
+ });
+ const differences = common.setSymmetricDifference(...addressSets);
+ if (differences.size) {
+ throw new ValidationError('inconsistent hostname resolution');
+ }
+ }
+ const resolvedHost = resolutions[0] || [];
+
+ // Persist the loopback state
+ urlObj.isLoopback = resolvedHost.reduce((acc, resolved) => {
+ let addr;
+ switch (resolved.family) {
+ case 4:
+ addr = new Address4(resolved.address);
+ return acc || addr.isInSubnet(loopback4);
+ case 6:
+ addr = new Address6(resolved.address);
+ return acc || addr.isLoopback();
+ default:
+ return acc;
+ }
+ }, false);
+ }
+
+ if (address
+ && (!urlObj.isLoopback || !allowLoopback)) {
+ throw new ValidationError('hostname cannot be IP');
+ }
+ }
+
+
+ /**
+ * 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<void>}
+ */
+ async validateProfile(url, validationOptions) {
+ const _scope = _fileScope('validateProfile');
+ const errorScope = 'invalid profile url';
+
+ const options = Object.assign({
+ allowLoopback: false,
+ resolveHostname: false,
+ }, validationOptions);
+
+ let profile;
+ try {
+ profile = new URL(url);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to parse url', { error: e, url });
+ throw new ValidationError(`${errorScope}: unparsable`);
+ }
+ profile.isLoopback = false;
+
+ try {
+ Communication._urlValidScheme(profile);
+ Communication._urlPartsDisallowed(profile, ['hash', 'username', 'password', 'port']);
+ Communication._urlPathNoDots(url);
+ 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}`);
+ }
+
+ return profile;
+ }
+
+
+ /**
+ * 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
+ * @param {Boolean} validationOptions.resolveHostname
+ * @returns {Promise<URL>}
+ */
+ async validateClientIdentifier(url, validationOptions) {
+ const _scope = _fileScope('validateClientIdentifier');
+ const errorScope = 'invalid client identifier url';
+
+ const options = Object.assign({
+ allowLoopback: true,
+ resolveHostname: true,
+ }, validationOptions);
+
+ let clientId;
+ try {
+ clientId = new URL(url);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to parse url', { error: e, url });
+ throw new ValidationError('invalid client identifier url: unparsable');
+ }
+ clientId.isLoopback = false;
+
+ try {
+ Communication._urlValidScheme(clientId);
+ Communication._urlPartsDisallowed(clientId, ['hash', 'username', 'password']);
+ Communication._urlPathNoDots(url);
+ await Communication._urlNamedHost(clientId, options.allowLoopback, options.resolveHostname);
+ } catch (e) {
+ this.logger.debug(_scope, 'client identifier url not valid', { url, error: e });
+ throw new ValidationError(`${errorScope}: ${e.message}`);
+ }
+
+ return clientId;
+ }
+
+
/**
* @typedef {Object} ClientIdentifierData
* @property {Object} rels - keyed by relation to array of uris
*/
/**
* 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
*/
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;
+ }
+
const mfData = await this.fetchMicroformat(urlObj);
if (!mfData) {
return undefined;
* @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.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
*/
/**
- * 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') &&
});
});
}
+
+ // 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
+ ticketEndpoint: 'ticket_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',
+ 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
+ }
+ });
+
+ // 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
+ }
+ });
+ }
+ }
+ }
+
return profile;
}
/**
* 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
* @param {URL} urlObj
* @param {String} code
* @param {String} codeVerifier
async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
const _scope = _fileScope('redeemProfileCode');
- const data = new URLSearchParams();
- Object.entries({
+ const formData = common.formData({
'grant_type': 'authorization_code',
code,
'client_id': clientId,
'redirect_uri': redirectURI,
'code_verifier': codeVerifier,
- }).forEach(([name, value]) => data.set(name, value));
+ });
- const postRedeemProfileCodeConfig = Communication._axiosConfig('POST', urlObj, data.toString(), {}, {
+ 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`,
});
}
}
+
+ /**
+ * Verify a token with an IdP endpoint, using the Authentication header supplied.
+ * @param {URL} introspectionUrlObj
+ * @param {String} authorizationHeader
+ * @param {String} token
+ */
+ async introspectToken(introspectionUrlObj, authorizationHeader, token) {
+ const _scope = _fileScope('introspectToken');
+
+ const formData = common.formData({ token });
+ const postIntrospectConfig = Communication._axiosConfig('POST', introspectionUrlObj, formData, {}, {
+ [Enum.Header.Authorization]: authorizationHeader,
+ [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
+ [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`,
+ });
+ delete postIntrospectConfig.validateStatus; // only accept success
+
+ let tokenInfo;
+ try {
+ const response = await this.axios(postIntrospectConfig);
+ this.logger.debug(_scope, 'response', { response });
+ // check status
+ try {
+ tokenInfo = JSON.parse(response.data);
+ const {
+ active,
+ me,
+ client_id: clientId,
+ scope,
+ exp,
+ iat,
+ } = tokenInfo;
+
+ return {
+ active,
+ ...(me && { me }),
+ ...(clientId && { clientId }),
+ ...(scope && { scope: scope.split(scopeSplitRE) }),
+ ...(exp && { exp: Number(exp) }),
+ ...(iat && { iat: Number(iat) }),
+ };
+ } catch (e) {
+ this.logger.error(_scope, 'failed to parse json', { error: e, response });
+ throw e;
+ }
+ } catch (e) {
+ this.logger.error(_scope, 'introspect token request failed', { error: e, url: introspectionUrlObj.href });
+ throw e;
+ }
+ }
+
+
+ /**
+ * Attempt to deliver a ticket to an endpoint.
+ * N.B. does not absorb errors
+ * @param {*} ticketEndpointUrlObj
+ * @param {*} resourceUrlObj
+ * @param {*} subjectUrlObj
+ * @param {*} ticket
+ * @returns {Promise<AxiosResponse>}
+ */
+ async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) {
+ const _scope = _fileScope('deliverTicket');
+
+ try {
+ const ticketPayload = {
+ ticket,
+ resource: resourceUrlObj.href,
+ subject: subjectUrlObj.href,
+ };
+ const ticketConfig = Communication._axiosConfig('POST', ticketEndpointUrlObj, ticketPayload, {}, {
+ [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
+ });
+ return await this.axios(ticketConfig);
+ } catch (e) {
+ this.logger.error(_scope, 'ticket delivery request failed', { error: e, url: ticketEndpointUrlObj.href });
+ throw e;
+ }
+ }
+
}
-module.exports = Communication;
\ No newline at end of file
+module.exports = Communication;