use separate base64url module, update dependencies and devDependencies
[squeep-indieauth-helper] / lib / communication.js
1 'use strict';
2
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');
15
16 const _fileScope = common.fileScope(__filename);
17
18 class Communication {
19 /**
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
26 */
27 constructor(logger, options = {}) {
28 this.logger = logger;
29 this.options = options;
30 this.axios = axios.create({
31 headers: {
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',
34 },
35 });
36 this.axios.interceptors.request.use((request) => {
37 request.startTimestampMs = performance.now();
38 return request;
39 });
40 this.axios.interceptors.response.use((response) => {
41 response.elapsedTimeMs = performance.now() - response.config.startTimestampMs;
42 return response;
43 });
44 }
45
46
47 static _challengeFromVerifier(verifier) {
48 const hash = createHash('sha256');
49 hash.update(verifier);
50 return base64ToBase64URL(hash.digest('base64'));
51 }
52
53 /**
54 * Create a code verifier and its challenge.
55 * @param {Number} length
56 * @returns {Object}
57 */
58 static async generatePKCE(length = 128) {
59 if (length < 43 || length > 128) {
60 throw new RangeError('InvalidLength');
61 }
62
63 const bufferLength = Math.floor(length * 3 / 4);
64 const randomBuffer = await randomBytesAsync(bufferLength);
65 const verifier = base64ToBase64URL(randomBuffer.toString('base64'));
66
67 const challenge = Communication._challengeFromVerifier(verifier);
68
69 return {
70 codeChallengeMethod: 'S256',
71 codeVerifier: verifier,
72 codeChallenge: challenge,
73 };
74 }
75
76
77 /**
78 * Check a challenge with a verifier.
79 * @param {String} codeChallenge
80 * @param {String} codeVerifier
81 * @param {String} codeChallengeMethod
82 * @returns {Boolean}
83 */
84 static verifyChallenge(codeChallenge, codeVerifier, codeChallengeMethod) {
85 switch (codeChallengeMethod) {
86 case 'SHA256':
87 case 'S256': {
88 const challenge = Communication._challengeFromVerifier(codeVerifier);
89 return challenge === codeChallenge;
90 }
91
92 default:
93 throw new Error('unsupported challenge method');
94 }
95 }
96
97
98 /**
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
104 * @returns {String}
105 */
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})`;
114 }
115 return `${product}/${version}${implementation}`;
116 }
117
118
119 /**
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
126 * @returns {Object}
127 */
128 static _axiosConfig(method, urlObj, body, params = {}, headers = {}) {
129 const config = {
130 method,
131 url: `${urlObj.origin}${urlObj.pathname}`,
132 params: urlObj.searchParams,
133 headers,
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 ],
139 };
140 Object.entries(params).map(([k, v]) => config.params.set(k, v));
141 return config;
142 }
143
144
145 /**
146 * Isolate the base of a url.
147 * mf2 parser needs this so that relative links can be made absolute.
148 * @param {URL} urlObj
149 * @returns {String}
150 */
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);
156 }
157 return baseUrl.href;
158 }
159
160
161 /**
162 * Convert a Content-Type string to normalized components.
163 * RFC7231 ยง3.1.1
164 * N.B. this ill-named non-parsing implementation will not work
165 * if a parameter value for some reason includes a ; or = within
166 * a quoted-string.
167 * @param {String} contentTypeHeader
168 * @returns {Object} contentType
169 * @returns {String} contentType.mediaType
170 * @returns {Object} contentType.params
171 */
172 static _parseContentType(contentTypeHeader, defaultContentType = Enum.ContentType.ApplicationOctetStream) {
173 const [ mediaType, ...params ] = (contentTypeHeader || '').split(/ *; */);
174 return {
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;
180 return obj;
181 }, {}),
182 };
183 }
184
185
186 /**
187 * Parse and add any header link relations to mf data.
188 * @param {Object} microformat
189 * @param {Object} response
190 */
191 _mergeLinkHeader(microformat, response) {
192 const _scope = _fileScope('_mergeLinkHeader');
193
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
198 }
199 });
200 if (!('items' in microformat)) {
201 microformat.items = [];
202 }
203
204 const linkHeader = response.headers[Enum.Header.Link.toLowerCase()];
205 const links = [];
206 if (linkHeader) {
207 try {
208 links.push(...parseLinkHeader(linkHeader));
209 } catch (e) {
210 this.logger.error(_scope, 'failed to parse link header', { error: e, linkHeader });
211 return;
212 }
213 }
214
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] = [];
222 }
223 microformat.rels[attr.value].unshift(link.target);
224
225 if (!(link.target in microformat['rel-urls'])) {
226 microformat['rel-urls'][link.target] = {
227 text: '',
228 rels: [],
229 };
230 }
231 microformat['rel-urls'][link.target].rels.unshift(attr.value);
232 }
233 });
234 });
235 }
236
237
238 /**
239 * Retrieve and parse microformat data from url.
240 * N.B. this absorbs any errors!
241 * @param {URL} urlObj
242 * @returns {Object}
243 */
244 async fetchMicroformat(urlObj) {
245 const _scope = _fileScope('fetchMicroformat');
246 const logInfoData = {
247 url: urlObj.href,
248 microformat: undefined,
249 response: undefined,
250 };
251 let response;
252 try {
253 const fetchMicroformatConfig = Communication._axiosConfig('GET', urlObj);
254 response = await this.axios(fetchMicroformatConfig);
255 } catch (e) {
256 this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData });
257 return;
258 }
259 logInfoData.response = common.axiosResponseLogData(response);
260
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) {
266 try {
267 const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore');
268 body = iconv.convert(body).toString('utf8');
269 } catch (e) {
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...
273 }
274 }
275
276 let microformat = {};
277 try {
278 microformat = mf2(body, {
279 baseUrl: Communication._baseUrlString(urlObj),
280 });
281 } catch (e) {
282 this.logger.error(_scope, 'failed to parse microformat data', { error: e, ...logInfoData });
283 // Try to carry on, maybe there are link headers...
284 }
285
286 this._mergeLinkHeader(microformat, response);
287
288 logInfoData.microformat = microformat;
289
290 this.logger.debug(_scope, 'parsed microformat data', logInfoData);
291 return microformat;
292 }
293
294
295 /**
296 * Retrieve and parse JSON.
297 * N.B. this absorbs any errors!
298 * @param {URL} urlObj
299 * @returns {Object}
300 */
301 async fetchJSON(urlObj) {
302 const _scope = _fileScope('fetchJSON');
303 const logInfoData = {
304 url: urlObj.href,
305 response: undefined,
306 };
307 let response;
308 try {
309 const fetchJSONConfig = Communication._axiosConfig('GET', urlObj, undefined, undefined, {
310 [Enum.Header.Accept]: [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', '),
311 });
312 response = await this.axios(fetchJSONConfig);
313 } catch (e) {
314 this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
315 return;
316 }
317 logInfoData.response = common.axiosResponseLogData(response);
318
319 let data;
320 try {
321 data = JSON.parse(response.data);
322 } catch (e) {
323 this.logger.error(_scope, 'json parsing failed', { error: e, ...logInfoData });
324 }
325
326 return data;
327 }
328
329
330 /**
331 * @typedef {Object} ClientIdentifierData
332 * @property {Object} rels - keyed by relation to array of uris
333 * @property {HAppData[]} items
334 */
335 /**
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
339 */
340 async fetchClientIdentifier(urlObj) {
341 const mfData = await this.fetchMicroformat(urlObj);
342 if (!mfData) {
343 return undefined;
344 }
345
346 // Only return h-app items with matching url field.
347 return {
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) => {
355 try {
356 const hUrl = new URL(url);
357 if (hUrl.href === urlObj.href) {
358 urlMatched = true;
359 }
360 } catch (e) { /**/ }
361 });
362 }
363 return urlMatched;
364 }),
365 };
366 }
367
368
369 /**
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
393 */
394 /**
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
399 */
400 async fetchProfile(urlObj) {
401 const _scope = _fileScope('fetchProfile');
402
403 const mfData = await this.fetchMicroformat(urlObj);
404 const profile = {
405 name: undefined,
406 photo: undefined,
407 url: undefined,
408 email: undefined,
409 metadata: {},
410 };
411
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
422 }
423 });
424 });
425 }
426
427 // Populate legacy mf2 fields from relation links.
428 // These will be overwritten if they also exist in server metadata.
429 Object.entries({
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
435 }
436 });
437
438 // Set metadata field.
439 if (mfData && 'indieauth-metadata' in mfData.rels) {
440 profile.indieauthMetadata = mfData.rels['indieauth-metadata'][0];
441 }
442
443 // Attempt to populate metadata from authorization server.
444 if (profile.indieauthMetadata) {
445 let mdURL;
446 try {
447 mdURL = new URL(profile.indieauthMetadata);
448 } catch (e) /* istanbul ignore next */ {
449 this.logger.error(_scope, 'invalid authorization server metadata url', { profile });
450 }
451 /* istanbul ignore else */
452 if (mdURL) {
453 const metadataResponse = await this.fetchJSON(mdURL);
454 if (metadataResponse) {
455 // Map snake_case fields to camelCase.
456 Object.entries({
457 issuer: 'issuer',
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
474 }
475 });
476
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
481 }
482 });
483 }
484 }
485 }
486
487 return profile;
488 }
489
490
491 /**
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
498 * @returns {Object}
499 */
500 async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
501 const _scope = _fileScope('redeemProfileCode');
502
503 const data = new URLSearchParams();
504 Object.entries({
505 'grant_type': 'authorization_code',
506 code,
507 'client_id': clientId,
508 'redirect_uri': redirectURI,
509 'code_verifier': codeVerifier,
510 }).forEach(([name, value]) => data.set(name, value));
511
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`,
515 });
516
517 try {
518 const response = await this.axios(postRedeemProfileCodeConfig);
519 try {
520 return JSON.parse(response.data);
521 } catch (e) {
522 this.logger.error(_scope, 'failed to parse json', { error: e, response });
523 throw e;
524 }
525 } catch (e) {
526 this.logger.error(_scope, 'redeem profile code request failed', { error: e, url: urlObj.href });
527 return;
528 }
529 }
530
531 }
532
533 module.exports = Communication;