3 const axios
= require('axios');
4 const { mf2
} = require('microformats-parser');
5 const { base64ToBase64URL
} = require('@squeep/base64url');
6 const { parse: parseLinkHeader
} = require('@squeep/web-linking');
7 const { Iconv
} = require('iconv');
8 const { version: packageVersion
, name: packageName
} = require('../package.json');
9 const { performance
} = require('perf_hooks');
10 const { randomBytes
, createHash
} = require('crypto');
11 const { promisify
} = require('util');
12 const randomBytesAsync
= promisify(randomBytes
);
13 const common
= require('./common');
14 const Enum
= require('./enum');
16 const _fileScope
= common
.fileScope(__filename
);
20 * @param {Console} logger
21 * @param {Object} options
22 * @param {Object=} options.userAgent
23 * @param {String=} options.userAgent.product
24 * @param {String=} options.userAgent.version
25 * @param {String=} options.userAgent.implementation
27 constructor(logger
, options
= {}) {
29 this.options
= options
;
30 this.axios
= axios
.create({
32 [Enum
.Header
.UserAgent
]: Communication
._userAgentString(options
.userAgent
),
33 [Enum
.Header
.Accept
]: 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1',
36 this.axios
.interceptors
.request
.use((request
) => {
37 request
.startTimestampMs
= performance
.now();
40 this.axios
.interceptors
.response
.use((response
) => {
41 response
.elapsedTimeMs
= performance
.now() - response
.config
.startTimestampMs
;
47 static _challengeFromVerifier(verifier
) {
48 const hash
= createHash('sha256');
49 hash
.update(verifier
);
50 return base64ToBase64URL(hash
.digest('base64'));
54 * Create a code verifier and its challenge.
55 * @param {Number} length
58 static async
generatePKCE(length
= 128) {
59 if (length
< 43 || length
> 128) {
60 throw new RangeError('InvalidLength');
63 const bufferLength
= Math
.floor(length
* 3 / 4);
64 const randomBuffer
= await
randomBytesAsync(bufferLength
);
65 const verifier
= base64ToBase64URL(randomBuffer
.toString('base64'));
67 const challenge
= Communication
._challengeFromVerifier(verifier
);
70 codeChallengeMethod: 'S256',
71 codeVerifier: verifier
,
72 codeChallenge: challenge
,
78 * Check a challenge with a verifier.
79 * @param {String} codeChallenge
80 * @param {String} codeVerifier
81 * @param {String} codeChallengeMethod
84 static verifyChallenge(codeChallenge
, codeVerifier
, codeChallengeMethod
) {
85 switch (codeChallengeMethod
) {
88 const challenge
= Communication
._challengeFromVerifier(codeVerifier
);
89 return challenge
=== codeChallenge
;
93 throw new Error('unsupported challenge method');
99 * Assemble a suitable User-Agent value.
100 * @param {Object} userAgentConfig
101 * @param {String=} userAgentConfig.product
102 * @param {String=} userAgentConfig.version
103 * @param {String=} userAgentConfig.implementation
106 static _userAgentString(userAgentConfig
) {
107 // eslint-disable-next-line security/detect-object-injection
108 const _conf
= (field
, def
) => (userAgentConfig
&& field
in userAgentConfig
) ? userAgentConfig
[field
] : def
;
109 const product
= _conf('product', packageName
).split('/').pop();
110 const version
= _conf('version', packageVersion
);
111 let implementation
= _conf('implementation', Enum
.Specification
);
112 if (implementation
) {
113 implementation
= ` (${implementation})`;
115 return `${product}/${version}${implementation}`;
120 * A request config skeleton.
121 * @param {String} method
122 * @param {URL} urlObj
123 * @param {String=} body
124 * @param {Object=} params
125 * @param {Object=} headers
128 static _axiosConfig(method
, urlObj
, body
, params
= {}, headers
= {}) {
131 url: `${urlObj.origin}${urlObj.pathname}`,
132 params: urlObj
.searchParams
,
134 ...(body
&& { data: body
}),
135 // Setting this does not appear to be enough to keep axios from parsing JSON response into object
136 responseType: 'text',
137 // So force the matter by eliding all response transformations
138 transformResponse: [ (res
) => res
],
140 Object
.entries(params
).map(([k
, v
]) => config
.params
.set(k
, v
));
146 * Isolate the base of a url.
147 * mf2 parser needs this so that relative links can be made absolute.
148 * @param {URL} urlObj
151 static _baseUrlString(urlObj
) {
152 const baseUrl
= new URL(urlObj
);
153 const lastSlashIdx
= baseUrl
.pathname
.lastIndexOf('/');
154 if (lastSlashIdx
> 0) {
155 baseUrl
.pathname
= baseUrl
.pathname
.slice(0, lastSlashIdx
+ 1);
162 * Convert a Content-Type string to normalized components.
164 * N.B. this ill-named non-parsing implementation will not work
165 * if a parameter value for some reason includes a ; or = within
167 * @param {String} contentTypeHeader
168 * @returns {Object} contentType
169 * @returns {String} contentType.mediaType
170 * @returns {Object} contentType.params
172 static _parseContentType(contentTypeHeader
, defaultContentType
= Enum
.ContentType
.ApplicationOctetStream
) {
173 const [ mediaType
, ...params
] = (contentTypeHeader
|| '').split(/ *; */
);
175 mediaType: mediaType
.toLowerCase() || defaultContentType
,
176 params: params
.reduce((obj
, param
) => {
177 const [field
, value
] = param
.split('=');
178 const isQuoted
= value
&& value
.charAt(0) === '"' && value
.charAt(value
.length
- 1) === '"';
179 obj
[field
.toLowerCase()] = isQuoted
? value
.slice(1, value
.length
- 1) : value
;
187 * Parse and add any header link relations to mf data.
188 * @param {Object} microformat
189 * @param {Object} response
191 _mergeLinkHeader(microformat
, response
) {
192 const _scope
= _fileScope('_mergeLinkHeader');
194 // Establish that microformat has expected structure
195 ['rels', 'rel-urls'].forEach((p
) => {
196 if (!(p
in microformat
)) {
197 microformat
[p
] = {}; // eslint-disable-line security/detect-object-injection
200 if (!('items' in microformat
)) {
201 microformat
.items
= [];
204 const linkHeader
= response
.headers
[Enum
.Header
.Link
.toLowerCase()];
208 links
.push(...parseLinkHeader(linkHeader
));
210 this.logger
.error(_scope
, 'failed to parse link header', { error: e
, linkHeader
});
215 // Push header link rels into microformat form.
216 // Inserted at front of lists, as headers take precedence.
217 links
.forEach((link
) => {
218 link
.attributes
.forEach((attr
) => {
219 if (attr
.name
=== 'rel') {
220 if (!(attr
.value
in microformat
.rels
)) {
221 microformat
.rels
[attr
.value
] = [];
223 microformat
.rels
[attr
.value
].unshift(link
.target
);
225 if (!(link
.target
in microformat
['rel-urls'])) {
226 microformat
['rel-urls'][link
.target
] = {
231 microformat
['rel-urls'][link
.target
].rels
.unshift(attr
.value
);
239 * Retrieve and parse microformat data from url.
240 * N.B. this absorbs any errors!
241 * @param {URL} urlObj
244 async
fetchMicroformat(urlObj
) {
245 const _scope
= _fileScope('fetchMicroformat');
246 const logInfoData
= {
248 microformat: undefined,
253 const fetchMicroformatConfig
= Communication
._axiosConfig('GET', urlObj
);
254 response
= await
this.axios(fetchMicroformatConfig
);
256 this.logger
.error(_scope
, 'microformat request failed', { error: e
, ...logInfoData
});
259 logInfoData
.response
= common
.axiosResponseLogData(response
);
261 // Normalize to utf8.
262 let body
= response
.data
;
263 const contentType
= Communication
._parseContentType(response
.headers
[Enum
.Header
.ContentType
.toLowerCase()]);
264 const nonUTF8Charset
= !/utf-*8/i.test(contentType
.params
.charset
) && contentType
.params
.charset
;
265 if (nonUTF8Charset
) {
267 const iconv
= new Iconv(nonUTF8Charset
, 'utf-8//translit//ignore');
268 body
= iconv
.convert(body
).toString('utf8');
270 // istanbul ignore next
271 this.logger
.error(_scope
, 'iconv conversion error', { error: e
, ...logInfoData
});
272 // Try to carry on, maybe the encoding will work anyhow...
276 let microformat
= {};
278 microformat
= mf2(body
, {
279 baseUrl: Communication
._baseUrlString(urlObj
),
282 this.logger
.error(_scope
, 'failed to parse microformat data', { error: e
, ...logInfoData
});
283 // Try to carry on, maybe there are link headers...
286 this._mergeLinkHeader(microformat
, response
);
288 logInfoData
.microformat
= microformat
;
290 this.logger
.debug(_scope
, 'parsed microformat data', logInfoData
);
296 * Retrieve and parse JSON.
297 * N.B. this absorbs any errors!
298 * @param {URL} urlObj
301 async
fetchJSON(urlObj
) {
302 const _scope
= _fileScope('fetchJSON');
303 const logInfoData
= {
309 const fetchJSONConfig
= Communication
._axiosConfig('GET', urlObj
, undefined, undefined, {
310 [Enum
.Header
.Accept
]: [Enum
.ContentType
.ApplicationJson
, Enum
.ContentType
.Any
+ ';q=0.1'].join(', '),
312 response
= await
this.axios(fetchJSONConfig
);
314 this.logger
.error(_scope
, 'json request failed', { error: e
, ...logInfoData
});
317 logInfoData
.response
= common
.axiosResponseLogData(response
);
321 data
= JSON
.parse(response
.data
);
323 this.logger
.error(_scope
, 'json parsing failed', { error: e
, ...logInfoData
});
331 * @typedef {Object} ClientIdentifierData
332 * @property {Object} rels - keyed by relation to array of uris
333 * @property {HAppData[]} items
336 * Retrieve and parse client identifier endpoint data.
337 * @param {URL} urlObj
338 * @returns {ClientIdentifierData|undefined} mf2 data filtered for h-app items, or undefined if url could not be fetched
340 async
fetchClientIdentifier(urlObj
) {
341 const mfData
= await
this.fetchMicroformat(urlObj
);
346 // Only return h-app items with matching url field.
348 rels: mfData
.rels
|| {},
349 items: (mfData
.items
|| []).filter((item
) => {
350 let urlMatched
= false;
351 const itemType
= item
.type
|| [];
352 if ((itemType
.includes('h-app') || itemType
.includes('h-x-app'))
353 && (item
.properties
&& item
.properties
.url
)) {
354 item
.properties
.url
.forEach((url
) => {
356 const hUrl
= new URL(url
);
357 if (hUrl
.href
=== urlObj
.href
) {
370 * @typedef ProfileData
371 * @property {String} name
372 * @property {String} photo
373 * @property {String} url
374 * @property {String} email
375 * @property {String} authorizationEndpoint - deprecated, backwards compatibility for 20201126 spec
376 * @property {String} tokenEndpoint - deprecated, backwards compatibility for 20201126 spec
377 * @property {String} indieauthMetadata authorization server metadata endpoint
378 * @property {Object} metadata - authorization server metadata for profile
379 * @property {String} metadata.issuer
380 * @property {String} metadata.authorizationEndpoint
381 * @property {String} metadata.tokenEndpoint
382 * @property {String} metadata.introspectionEndpoint
383 * @property {String} metadata.introspectionEndpointAuthMethodsSupported
384 * @property {String} metadata.revocationEndpoint
385 * @property {String} metadata.revocationEndpointAuthMethodsSupported
386 * @property {String} metadata.scopesSupported
387 * @property {String} metadata.responseTypesSupported
388 * @property {String} metadata.grantTypesSupported
389 * @property {String} metadata.serviceDocumentation
390 * @property {String} metadata.codeChallengeMethodsSupported
391 * @property {String} metadata.authorizationResponseIssParameterSupported
392 * @property {String} metadata.userinfoEndpoint
395 * Fetch the relevant microformat data from profile url h-card information,
396 * and authorization server metadata.
397 * @param {URL} urlObj
398 * @returns {ProfileData} mf2 data filtered for select fields from h-card
400 async
fetchProfile(urlObj
) {
401 const _scope
= _fileScope('fetchProfile');
403 const mfData
= await
this.fetchMicroformat(urlObj
);
412 // Locate h-card mf2 items with url field matching profile url,
413 // and populate profile fields with first-encountered card values.
414 if (mfData
&& 'items' in mfData
) {
415 const hCards
= mfData
.items
.filter((item
) =>
416 item
.type
&& item
.type
.includes('h-card') &&
417 item
.properties
&& item
.properties
.url
&& item
.properties
.url
.includes(urlObj
.href
));
418 hCards
.forEach((hCard
) => {
419 Object
.keys(profile
).forEach((key
) => {
420 if (!profile
[key
] && key
in hCard
.properties
) { // eslint-disable-line security/detect-object-injection
421 profile
[key
] = hCard
.properties
[key
][0]; // eslint-disable-line security/detect-object-injection
427 // Populate legacy mf2 fields from relation links.
428 // These will be overwritten if they also exist in server metadata.
430 authorizationEndpoint: 'authorization_endpoint', // backwards compatibility
431 tokenEndpoint: 'token_endpoint', // backwards compatibility
432 }).forEach(([p
, r
]) => {
433 if (mfData
&& r
in mfData
.rels
) {
434 profile
.metadata
[p
] = profile
[p
] = mfData
.rels
[r
][0]; // eslint-disable-line security/detect-object-injection
438 // Set metadata field.
439 if (mfData
&& 'indieauth-metadata' in mfData
.rels
) {
440 profile
.indieauthMetadata
= mfData
.rels
['indieauth-metadata'][0];
443 // Attempt to populate metadata from authorization server.
444 if (profile
.indieauthMetadata
) {
447 mdURL
= new URL(profile
.indieauthMetadata
);
448 } catch (e
) /* istanbul ignore next */ {
449 this.logger
.error(_scope
, 'invalid authorization server metadata url', { profile
});
451 /* istanbul ignore else */
453 const metadataResponse
= await
this.fetchJSON(mdURL
);
454 if (metadataResponse
) {
455 // Map snake_case fields to camelCase.
458 authorizationEndpoint: 'authorization_endpoint',
459 tokenEndpoint: 'token_endpoint',
460 introspectionEndpoint: 'introspection_endpoint',
461 introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
462 revocationEndpoint: 'revocation_endpoint',
463 revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
464 scopesSupported: 'scopes_supported',
465 responseTypesSupported: 'response_types_supported',
466 grantTypesSupported: 'grant_types_supported',
467 serviceDocumentation: 'service_documentation',
468 codeChallengeMethodsSupported: 'code_challenge_methods_supported',
469 authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
470 userinfoEndpoint: 'userinfo_endpoint',
471 }).forEach(([c
, s
]) => {
472 if (s
in metadataResponse
) {
473 profile
.metadata
[c
] = metadataResponse
[s
]; // eslint-disable-line security/detect-object-injection
477 // Populate legacy profile fields.
478 ['authorizationEndpoint', 'tokenEndpoint'].forEach((f
) => {
479 if (f
in profile
.metadata
) {
480 profile
[f
] = profile
.metadata
[f
]; // eslint-disable-line security/detect-object-injection
492 * POST to the auth endpoint, to redeem a code for a profile object.
493 * @param {URL} urlObj
494 * @param {String} code
495 * @param {String} codeVerifier
496 * @param {String} clientId
497 * @param {String} redirectURI
500 async
redeemProfileCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
) {
501 const _scope
= _fileScope('redeemProfileCode');
503 const data
= new URLSearchParams();
505 'grant_type': 'authorization_code',
507 'client_id': clientId
,
508 'redirect_uri': redirectURI
,
509 'code_verifier': codeVerifier
,
510 }).forEach(([name
, value
]) => data
.set(name
, value
));
512 const postRedeemProfileCodeConfig
= Communication
._axiosConfig('POST', urlObj
, data
.toString(), {}, {
513 [Enum
.Header
.ContentType
]: Enum
.ContentType
.ApplicationForm
,
514 [Enum
.Header
.Accept
]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`,
518 const response
= await
this.axios(postRedeemProfileCodeConfig
);
520 return JSON
.parse(response
.data
);
522 this.logger
.error(_scope
, 'failed to parse json', { error: e
, response
});
526 this.logger
.error(_scope
, 'redeem profile code request failed', { error: e
, url: urlObj
.href
});
533 module
.exports
= Communication
;