support indieauth-metadata relation on profiles, per spec 20220212
[squeep-indieauth-helper] / lib / communication.js
1 'use strict';
2
3 const axios = require('axios');
4 const { mf2 } = require('microformats-parser');
5 const { parse: parseLinkHeader } = require('@squeep/web-linking');
6 const { Iconv } = require('iconv');
7 const { version: packageVersion, name: packageName } = require('../package.json');
8 const { performance } = require('perf_hooks');
9 const { randomBytes, createHash } = require('crypto');
10 const { promisify } = require('util');
11 const randomBytesAsync = promisify(randomBytes);
12 const common = require('./common');
13 const Enum = require('./enum');
14
15 const _fileScope = common.fileScope(__filename);
16
17 class Communication {
18 /**
19 * @param {Console} logger
20 * @param {Object} options
21 * @param {Object=} options.userAgent
22 * @param {String=} options.userAgent.product
23 * @param {String=} options.userAgent.version
24 * @param {String=} options.userAgent.implementation
25 */
26 constructor(logger, options = {}) {
27 this.logger = logger;
28 this.options = options;
29 this.axios = axios.create({
30 headers: {
31 [Enum.Header.UserAgent]: Communication._userAgentString(options.userAgent),
32 [Enum.Header.Accept]: 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1',
33 },
34 });
35 this.axios.interceptors.request.use((request) => {
36 request.startTimestampMs = performance.now();
37 return request;
38 });
39 this.axios.interceptors.response.use((response) => {
40 response.elapsedTimeMs = performance.now() - response.config.startTimestampMs;
41 return response;
42 });
43 }
44
45
46 static _challengeFromVerifier(verifier) {
47 const hash = createHash('sha256');
48 hash.update(verifier);
49 return common.base64ToBase64URL(hash.digest('base64'));
50 }
51
52 /**
53 * Create a code verifier and its challenge.
54 * @param {Number} length
55 * @returns {Object}
56 */
57 static async generatePKCE(length = 128) {
58 if (length < 43 || length > 128) {
59 throw new RangeError('InvalidLength');
60 }
61
62 const bufferLength = Math.floor(length * 3 / 4);
63 const randomBuffer = await randomBytesAsync(bufferLength);
64 const verifier = common.base64ToBase64URL(randomBuffer.toString('base64'));
65
66 const challenge = Communication._challengeFromVerifier(verifier);
67
68 return {
69 codeChallengeMethod: 'S256',
70 codeVerifier: verifier,
71 codeChallenge: challenge,
72 };
73 }
74
75
76 /**
77 * Check a challenge with a verifier.
78 * @param {String} codeChallenge
79 * @param {String} codeVerifier
80 * @param {String} codeChallengeMethod
81 * @returns {Boolean}
82 */
83 static verifyChallenge(codeChallenge, codeVerifier, codeChallengeMethod) {
84 switch (codeChallengeMethod) {
85 case 'SHA256':
86 case 'S256': {
87 const challenge = Communication._challengeFromVerifier(codeVerifier);
88 return challenge === codeChallenge;
89 }
90
91 default:
92 throw new Error('unsupported challenge method');
93 }
94 }
95
96
97 /**
98 * Assemble a suitable User-Agent value.
99 * @param {Object} userAgentConfig
100 * @param {String=} userAgentConfig.product
101 * @param {String=} userAgentConfig.version
102 * @param {String=} userAgentConfig.implementation
103 * @returns {String}
104 */
105 static _userAgentString(userAgentConfig) {
106 // eslint-disable-next-line security/detect-object-injection
107 const _conf = (field, def) => (userAgentConfig && field in userAgentConfig) ? userAgentConfig[field] : def;
108 const product = _conf('product', packageName).split('/').pop();
109 const version = _conf('version', packageVersion);
110 let implementation = _conf('implementation', Enum.Specification);
111 if (implementation) {
112 implementation = ` (${implementation})`;
113 }
114 return `${product}/${version}${implementation}`;
115 }
116
117
118 /**
119 * A request config skeleton.
120 * @param {String} method
121 * @param {URL} urlObj
122 * @param {String=} body
123 * @param {Object=} params
124 * @param {Object=} headers
125 * @returns {Object}
126 */
127 static _axiosConfig(method, urlObj, body, params = {}, headers = {}) {
128 const config = {
129 method,
130 url: `${urlObj.origin}${urlObj.pathname}`,
131 params: urlObj.searchParams,
132 headers,
133 ...(body && { data: body }),
134 // Setting this does not appear to be enough to keep axios from parsing JSON response into object
135 responseType: 'text',
136 // So force the matter by eliding all response transformations
137 transformResponse: [ (res) => res ],
138 };
139 Object.entries(params).map(([k, v]) => config.params.set(k, v));
140 return config;
141 }
142
143
144 /**
145 * Isolate the base of a url.
146 * mf2 parser needs this so that relative links can be made absolute.
147 * @param {URL} urlObj
148 * @returns {String}
149 */
150 static _baseUrlString(urlObj) {
151 const baseUrl = new URL(urlObj);
152 const lastSlashIdx = baseUrl.pathname.lastIndexOf('/');
153 if (lastSlashIdx > 0) {
154 baseUrl.pathname = baseUrl.pathname.slice(0, lastSlashIdx + 1);
155 }
156 return baseUrl.href;
157 }
158
159
160 /**
161 * Convert a Content-Type string to normalized components.
162 * RFC7231 ยง3.1.1
163 * N.B. this ill-named non-parsing implementation will not work
164 * if a parameter value for some reason includes a ; or = within
165 * a quoted-string.
166 * @param {String} contentTypeHeader
167 * @returns {Object} contentType
168 * @returns {String} contentType.mediaType
169 * @returns {Object} contentType.params
170 */
171 static _parseContentType(contentTypeHeader, defaultContentType = Enum.ContentType.ApplicationOctetStream) {
172 const [ mediaType, ...params ] = (contentTypeHeader || '').split(/ *; */);
173 return {
174 mediaType: mediaType.toLowerCase() || defaultContentType,
175 params: params.reduce((obj, param) => {
176 const [field, value] = param.split('=');
177 const isQuoted = value && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"';
178 obj[field.toLowerCase()] = isQuoted ? value.slice(1, value.length - 1) : value;
179 return obj;
180 }, {}),
181 };
182 }
183
184
185 /**
186 * Parse and add any header link relations to mf data.
187 * @param {Object} microformat
188 * @param {Object} response
189 */
190 _mergeLinkHeader(microformat, response) {
191 const _scope = _fileScope('_mergeLinkHeader');
192
193 // Establish that microformat has expected structure
194 ['rels', 'rel-urls'].forEach((p) => {
195 if (!(p in microformat)) {
196 microformat[p] = {}; // eslint-disable-line security/detect-object-injection
197 }
198 });
199 if (!('items' in microformat)) {
200 microformat.items = [];
201 }
202
203 const linkHeader = response.headers[Enum.Header.Link.toLowerCase()];
204 const links = [];
205 if (linkHeader) {
206 try {
207 links.push(...parseLinkHeader(linkHeader));
208 } catch (e) {
209 this.logger.error(_scope, 'failed to parse link header', { error: e, linkHeader });
210 return;
211 }
212 }
213
214 // Push header link rels into microformat form.
215 // Inserted at front of lists, as headers take precedence.
216 links.forEach((link) => {
217 link.attributes.forEach((attr) => {
218 if (attr.name === 'rel') {
219 if (!(attr.value in microformat.rels)) {
220 microformat.rels[attr.value] = [];
221 }
222 microformat.rels[attr.value].unshift(link.target);
223
224 if (!(link.target in microformat['rel-urls'])) {
225 microformat['rel-urls'][link.target] = {
226 text: '',
227 rels: [],
228 };
229 }
230 microformat['rel-urls'][link.target].rels.unshift(attr.value);
231 }
232 });
233 });
234 }
235
236
237 /**
238 * Retrieve and parse microformat data from url.
239 * N.B. this absorbs any errors!
240 * @param {URL} urlObj
241 * @returns {Object}
242 */
243 async fetchMicroformat(urlObj) {
244 const _scope = _fileScope('fetchMicroformat');
245 const logInfoData = {
246 url: urlObj.href,
247 microformat: undefined,
248 response: undefined,
249 };
250 let response;
251 try {
252 const fetchMicroformatConfig = Communication._axiosConfig('GET', urlObj);
253 response = await this.axios(fetchMicroformatConfig);
254 } catch (e) {
255 this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData });
256 return;
257 }
258 logInfoData.response = common.axiosResponseLogData(response);
259
260 // Normalize to utf8.
261 let body = response.data;
262 const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]);
263 const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset;
264 if (nonUTF8Charset) {
265 try {
266 const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore');
267 body = iconv.convert(body).toString('utf8');
268 } catch (e) {
269 // istanbul ignore next
270 this.logger.error(_scope, 'iconv conversion error', { error: e, ...logInfoData });
271 // Try to carry on, maybe the encoding will work anyhow...
272 }
273 }
274
275 let microformat = {};
276 try {
277 microformat = mf2(body, {
278 baseUrl: Communication._baseUrlString(urlObj),
279 });
280 } catch (e) {
281 this.logger.error(_scope, 'failed to parse microformat data', { error: e, ...logInfoData });
282 // Try to carry on, maybe there are link headers...
283 }
284
285 this._mergeLinkHeader(microformat, response);
286
287 logInfoData.microformat = microformat;
288
289 this.logger.debug(_scope, 'parsed microformat data', logInfoData);
290 return microformat;
291 }
292
293
294 /**
295 * Retrieve and parse JSON.
296 * N.B. this absorbs any errors!
297 * @param {URL} urlObj
298 * @returns {Object}
299 */
300 async fetchJSON(urlObj) {
301 const _scope = _fileScope('fetchJSON');
302 const logInfoData = {
303 url: urlObj.href,
304 response: undefined,
305 };
306 let response;
307 try {
308 const fetchJSONConfig = Communication._axiosConfig('GET', urlObj, undefined, undefined, {
309 [Enum.Header.Accept]: [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', '),
310 });
311 response = await this.axios(fetchJSONConfig);
312 } catch (e) {
313 this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
314 return;
315 }
316 logInfoData.response = common.axiosResponseLogData(response);
317
318 let data;
319 try {
320 data = JSON.parse(response.data);
321 } catch (e) {
322 this.logger.error(_scope, 'json parsing failed', { error: e, ...logInfoData });
323 }
324
325 return data;
326 }
327
328
329 /**
330 * @typedef {Object} ClientIdentifierData
331 * @property {Object} rels - keyed by relation to array of uris
332 * @property {HAppData[]} items
333 */
334 /**
335 * Retrieve and parse client identifier endpoint data.
336 * @param {URL} urlObj
337 * @returns {ClientIdentifierData|undefined} mf2 data filtered for h-app items, or undefined if url could not be fetched
338 */
339 async fetchClientIdentifier(urlObj) {
340 const mfData = await this.fetchMicroformat(urlObj);
341 if (!mfData) {
342 return undefined;
343 }
344
345 // Only return h-app items with matching url field.
346 return {
347 rels: mfData.rels || {},
348 items: (mfData.items || []).filter((item) => {
349 let urlMatched = false;
350 const itemType = item.type || [];
351 if ((itemType.includes('h-app') || itemType.includes('h-x-app'))
352 && (item.properties && item.properties.url)) {
353 item.properties.url.forEach((url) => {
354 try {
355 const hUrl = new URL(url);
356 if (hUrl.href === urlObj.href) {
357 urlMatched = true;
358 }
359 } catch (e) { /**/ }
360 });
361 }
362 return urlMatched;
363 }),
364 };
365 }
366
367
368 /**
369 * @typedef ProfileData
370 * @property {String} name
371 * @property {String} photo
372 * @property {String} url
373 * @property {String} email
374 * @property {String} authorizationEndpoint - deprecated, backwards compatibility for 20201126 spec
375 * @property {String} tokenEndpoint - deprecated, backwards compatibility for 20201126 spec
376 * @property {String} indieauthMetadata authorization server metadata endpoint
377 * @property {Object} metadata - authorization server metadata for profile
378 * @property {String} metadata.issuer
379 * @property {String} metadata.authorizationEndpoint
380 * @property {String} metadata.tokenEndpoint
381 * @property {String} metadata.introspectionEndpoint
382 * @property {String} metadata.introspectionEndpointAuthMethodsSupported
383 * @property {String} metadata.revocationEndpoint
384 * @property {String} metadata.revocationEndpointAuthMethodsSupported
385 * @property {String} metadata.scopesSupported
386 * @property {String} metadata.responseTypesSupported
387 * @property {String} metadata.grantTypesSupported
388 * @property {String} metadata.serviceDocumentation
389 * @property {String} metadata.codeChallengeMethodsSupported
390 * @property {String} metadata.authorizationResponseIssParameterSupported
391 * @property {String} metadata.userinfoEndpoint
392 */
393 /**
394 * Fetch the relevant microformat data from profile url h-card information,
395 * and authorization server metadata.
396 * @param {URL} urlObj
397 * @returns {ProfileData} mf2 data filtered for select fields from h-card
398 */
399 async fetchProfile(urlObj) {
400 const _scope = _fileScope('fetchProfile');
401
402 const mfData = await this.fetchMicroformat(urlObj);
403 const profile = {
404 name: undefined,
405 photo: undefined,
406 url: undefined,
407 email: undefined,
408 metadata: {},
409 };
410
411 // Locate h-card mf2 items with url field matching profile url,
412 // and populate profile fields with first-encountered card values.
413 if (mfData && 'items' in mfData) {
414 const hCards = mfData.items.filter((item) =>
415 item.type && item.type.includes('h-card') &&
416 item.properties && item.properties.url && item.properties.url.includes(urlObj.href));
417 hCards.forEach((hCard) => {
418 Object.keys(profile).forEach((key) => {
419 if (!profile[key] && key in hCard.properties) { // eslint-disable-line security/detect-object-injection
420 profile[key] = hCard.properties[key][0]; // eslint-disable-line security/detect-object-injection
421 }
422 });
423 });
424 }
425
426 // Populate legacy mf2 fields from relation links.
427 // These will be overwritten if they also exist in server metadata.
428 Object.entries({
429 authorizationEndpoint: 'authorization_endpoint', // backwards compatibility
430 tokenEndpoint: 'token_endpoint', // backwards compatibility
431 }).forEach(([p, r]) => {
432 if (mfData && r in mfData.rels) {
433 profile.metadata[p] = profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection
434 }
435 });
436
437 // Set metadata field.
438 if (mfData && 'indieauth-metadata' in mfData.rels) {
439 profile.indieauthMetadata = mfData.rels['indieauth-metadata'][0];
440 }
441
442 // Attempt to populate metadata from authorization server.
443 if (profile.indieauthMetadata) {
444 let mdURL;
445 try {
446 mdURL = new URL(profile.indieauthMetadata);
447 } catch (e) /* istanbul ignore next */ {
448 this.logger.error(_scope, 'invalid authorization server metadata url', { profile });
449 }
450 /* istanbul ignore else */
451 if (mdURL) {
452 const metadataResponse = await this.fetchJSON(mdURL);
453 if (metadataResponse) {
454 // Map snake_case fields to camelCase.
455 Object.entries({
456 issuer: 'issuer',
457 authorizationEndpoint: 'authorization_endpoint',
458 tokenEndpoint: 'token_endpoint',
459 introspectionEndpoint: 'introspection_endpoint',
460 introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
461 revocationEndpoint: 'revocation_endpoint',
462 revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
463 scopesSupported: 'scopes_supported',
464 responseTypesSupported: 'response_types_supported',
465 grantTypesSupported: 'grant_types_supported',
466 serviceDocumentation: 'service_documentation',
467 codeChallengeMethodsSupported: 'code_challenge_methods_supported',
468 authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
469 userinfoEndpoint: 'userinfo_endpoint',
470 }).forEach(([c, s]) => {
471 if (s in metadataResponse) {
472 profile.metadata[c] = metadataResponse[s]; // eslint-disable-line security/detect-object-injection
473 }
474 });
475
476 // Populate legacy profile fields.
477 ['authorizationEndpoint', 'tokenEndpoint'].forEach((f) => {
478 if (f in profile.metadata) {
479 profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection
480 }
481 });
482 }
483 }
484 }
485
486 return profile;
487 }
488
489
490 /**
491 * POST to the auth endpoint, to redeem a code for a profile object.
492 * @param {URL} urlObj
493 * @param {String} code
494 * @param {String} codeVerifier
495 * @param {String} clientId
496 * @param {String} redirectURI
497 * @returns {Object}
498 */
499 async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
500 const _scope = _fileScope('redeemProfileCode');
501
502 const data = new URLSearchParams();
503 Object.entries({
504 'grant_type': 'authorization_code',
505 code,
506 'client_id': clientId,
507 'redirect_uri': redirectURI,
508 'code_verifier': codeVerifier,
509 }).forEach(([name, value]) => data.set(name, value));
510
511 const postRedeemProfileCodeConfig = Communication._axiosConfig('POST', urlObj, data.toString(), {}, {
512 [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm,
513 [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`,
514 });
515
516 try {
517 const response = await this.axios(postRedeemProfileCodeConfig);
518 try {
519 return JSON.parse(response.data);
520 } catch (e) {
521 this.logger.error(_scope, 'failed to parse json', { error: e, response });
522 throw e;
523 }
524 } catch (e) {
525 this.logger.error(_scope, 'redeem profile code request failed', { error: e, url: urlObj.href });
526 return;
527 }
528 }
529
530 }
531
532 module.exports = Communication;