update devDependencies, eslint config, address lint issues
[squeep-indieauth-helper] / lib / communication.js
1 'use strict';
2
3 const { mf2 } = require('microformats-parser');
4 const { parse: parseLinkHeader } = require('@squeep/web-linking');
5 const { Iconv } = require('iconv');
6 const { version: packageVersion, name: packageName } = require('../package.json');
7 const { randomBytes, createHash } = require('crypto');
8 const { promisify } = require('util');
9 const randomBytesAsync = promisify(randomBytes);
10 const { Address4, Address6 } = require('ip-address');
11 const dns = require('dns');
12 const common = require('./common');
13 const Enum = require('./enum');
14 const { Microformat2: { Relation: MF2Rel } } = Enum;
15 const { ValidationError } = require('./errors');
16 const { fileScope } = require('@squeep/log-helper');
17
18 const _fileScope = fileScope(__filename);
19
20 const noDotPathRE = /(\/\.\/|\/\.\.\/)/;
21 const v6HostRE = /\[[0-9a-f:]+\]/;
22 const loopback4 = new Address4('127.0.0.0/8');
23 const scopeSplitRE = / +/;
24 const utf8CharsetRE = /utf-*8/i;
25
26 class Communication {
27 /**
28 * @param {Console} logger
29 * @param {Object} options
30 * @param {Number=} options.timeout
31 * @param {Object=} options.userAgent
32 * @param {String=} options.userAgent.product
33 * @param {String=} options.userAgent.version
34 * @param {String=} options.userAgent.implementation
35 */
36 constructor(logger, options = {}) {
37 this.logger = logger;
38 this.options = options;
39
40 this._defaultAccept = options?.defaultAccept || 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1';
41 this._jsonAccept = options?.jsonAccept || [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', ');
42
43 this.Got = undefined;
44 this.got = this._init; // Do the dynamic import on first attempt to use client.
45 }
46
47
48 /**
49 * Do a little dance to support this ESM client.
50 */
51 async _init(...args) {
52 if (!this.Got) {
53 // For some reason eslint is confused about import being supported here.
54 // eslint-disable-next-line
55 this.Got = await import('got');
56 this.got = this.Got.got.extend({
57 headers: {
58 [Enum.Header.UserAgent]: Communication._userAgentString(this.options.userAgent),
59 [Enum.Header.Accept]: this._defaultAccept,
60 },
61 timeout: {
62 request: this.options.timeout || 120000,
63 },
64 hooks: {
65 beforeRetry: [
66 this._onRetry.bind(this),
67 ],
68 },
69 });
70 }
71 if (args.length) {
72 return this.got(...args);
73 }
74 }
75
76
77 /**
78 * Take notes on transient retries.
79 * @param {*} error
80 * @param {*} retryCount
81 */
82 _onRetry(error, retryCount) {
83 const _scope = _fileScope('_onRetry');
84 this.logger.debug(_scope, 'retry', { retryCount, error });
85 }
86
87
88 /**
89 * Encode hashed verifier data for PKCE.
90 * @param {BinaryLike} verifier
91 * @returns {String}
92 */
93 static _challengeFromVerifier(verifier) {
94 const hash = createHash('sha256');
95 hash.update(verifier);
96 return hash.digest('base64url');
97 }
98
99
100 /**
101 * @typedef PKCEData
102 * @property {String} codeChallengeMethod
103 * @property {String} codeVerifier
104 * @property {String} codeChallenge
105 */
106 /**
107 * Create a code verifier and its challenge.
108 * @param {Number} length of verifier string, between 43 and 128
109 * @returns {Promise<PKCEData>}
110 */
111 static async generatePKCE(length = 128) {
112 if (length < 43 || length > 128) {
113 throw new RangeError('InvalidLength');
114 }
115
116 const bufferLength = Math.floor(length * 3 / 4);
117 const randomBuffer = await randomBytesAsync(bufferLength);
118 const verifier = randomBuffer.toString('base64url');
119
120 const challenge = Communication._challengeFromVerifier(verifier);
121
122 return {
123 codeChallengeMethod: 'S256',
124 codeVerifier: verifier,
125 codeChallenge: challenge,
126 };
127 }
128
129
130 /**
131 * Check a challenge with a verifier.
132 * @param {String} codeChallenge
133 * @param {String} codeVerifier
134 * @param {String} codeChallengeMethod
135 * @returns {Boolean}
136 */
137 static verifyChallenge(codeChallenge, codeVerifier, codeChallengeMethod) {
138 switch (codeChallengeMethod) {
139 case 'SHA256':
140 case 'S256': {
141 const challenge = Communication._challengeFromVerifier(codeVerifier);
142 return challenge === codeChallenge;
143 }
144
145 default:
146 throw new Error('unsupported challenge method');
147 }
148 }
149
150
151 /**
152 * Assemble a suitable User-Agent value.
153 * @param {Object} userAgentConfig
154 * @param {String=} userAgentConfig.product
155 * @param {String=} userAgentConfig.version
156 * @param {String=} userAgentConfig.implementation
157 * @returns {String}
158 */
159 static _userAgentString(userAgentConfig) {
160 // eslint-disable-next-line security/detect-object-injection
161 const _conf = (field, def) => (userAgentConfig && field in userAgentConfig) ? userAgentConfig[field] : def;
162 const product = _conf('product', packageName).split('/').pop();
163 const version = _conf('version', packageVersion);
164 let implementation = _conf('implementation', Enum.Specification);
165 if (implementation) {
166 implementation = ` (${implementation})`;
167 }
168 return `${product}/${version}${implementation}`;
169 }
170
171
172 /**
173 * Isolate the base of a url.
174 * mf2 parser needs this so that relative links can be made absolute.
175 * @param {URL} urlObj
176 * @returns {String}
177 */
178 static _baseUrlString(urlObj) {
179 const baseUrl = new URL(urlObj);
180 const lastSlashIdx = baseUrl.pathname.lastIndexOf('/');
181 if (lastSlashIdx > 0) {
182 baseUrl.pathname = baseUrl.pathname.slice(0, lastSlashIdx + 1);
183 }
184 return baseUrl.href;
185 }
186
187
188 /**
189 * Convert a Content-Type string to normalized components.
190 * RFC7231 ยง3.1.1
191 * N.B. this ill-named non-parsing implementation will not work
192 * if a parameter value for some reason includes a ; or = within
193 * a quoted-string.
194 * @param {String} contentTypeHeader
195 * @returns {Object} contentType
196 * @returns {String} contentType.mediaType
197 * @returns {Object} contentType.params
198 */
199 static _parseContentType(contentTypeHeader, defaultContentType = Enum.ContentType.ApplicationOctetStream) {
200 const [ mediaType, ...params ] = (contentTypeHeader || '').split(/ *; */);
201 return {
202 mediaType: mediaType.toLowerCase() || defaultContentType,
203 params: params.reduce((obj, param) => {
204 const [field, value] = param.split('=');
205 const isQuoted = value?.startsWith('"') && value?.endsWith('"');
206 obj[field.toLowerCase()] = isQuoted ? value.slice(1, value.length - 1) : value;
207 return obj;
208 }, {}),
209 };
210 }
211
212
213 /**
214 * Parse and add any header link relations from response to microformat data.
215 * @param {Object} microformat
216 * @param {Object} response
217 * @param {Object} response.headers
218 */
219 _mergeLinkHeader(microformat, response) {
220 const _scope = _fileScope('_mergeLinkHeader');
221
222 // Establish that microformat has expected structure
223 ['rels', 'rel-urls'].forEach((p) => {
224 if (!(p in microformat)) {
225 microformat[p] = {}; // eslint-disable-line security/detect-object-injection
226 }
227 });
228 if (!('items' in microformat)) {
229 microformat.items = [];
230 }
231
232 const linkHeader = response.headers[Enum.Header.Link.toLowerCase()];
233 const links = [];
234 if (linkHeader) {
235 try {
236 links.push(...parseLinkHeader(linkHeader));
237 } catch (e) {
238 this.logger.error(_scope, 'failed to parse link header', { error: e, linkHeader });
239 return;
240 }
241 }
242
243 // Push header link rels into microformat form.
244 // Inserted at front of lists, as headers take precedence.
245 links.forEach((link) => {
246 link.attributes.forEach((attr) => {
247 if (attr.name === 'rel') {
248 if (!(attr.value in microformat.rels)) {
249 microformat.rels[attr.value] = [];
250 }
251 microformat.rels[attr.value].unshift(link.target);
252
253 if (!(link.target in microformat['rel-urls'])) {
254 microformat['rel-urls'][link.target] = {
255 text: '',
256 rels: [],
257 };
258 }
259 microformat['rel-urls'][link.target].rels.unshift(attr.value);
260 }
261 });
262 });
263 }
264
265
266 /**
267 * Retrieve and parse microformat data from url.
268 * N.B. this absorbs any errors!
269 * @param {URL} urlObj
270 * @returns {Promise<Object>}
271 */
272 async fetchMicroformat(urlObj) {
273 const _scope = _fileScope('fetchMicroformat');
274 const logInfoData = {
275 url: urlObj.href,
276 microformat: undefined,
277 response: undefined,
278 };
279 let response;
280 try {
281 const fetchMicroformatConfig = {
282 method: 'GET',
283 url: urlObj,
284 responseType: 'buffer',
285 };
286 response = await this.got(fetchMicroformatConfig);
287 } catch (e) {
288 this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData });
289 return;
290 }
291 logInfoData.response = common.gotResponseLogData(response);
292
293 // Normalize to utf8.
294 let body;
295 const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]);
296 // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8.
297 const nonUTF8Charset = !utf8CharsetRE.test(contentType.params.charset) && contentType.params.charset;
298 if (nonUTF8Charset) {
299 try {
300 const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore');
301 body = iconv.convert(response.body).toString('utf8');
302 } catch (e) {
303 // istanbul ignore next
304 this.logger.error(_scope, 'iconv conversion error', { error: e, ...logInfoData });
305 // Try to carry on, maybe the encoding will work anyhow...
306 }
307 } else {
308 body = response.body.toString('utf8');
309 }
310
311 let microformat = {};
312 try {
313 microformat = mf2(body, {
314 baseUrl: Communication._baseUrlString(urlObj),
315 });
316 } catch (e) {
317 this.logger.error(_scope, 'failed to parse microformat data', { error: e, ...logInfoData });
318 // Try to carry on, maybe there are link headers...
319 }
320
321 this._mergeLinkHeader(microformat, response);
322
323 logInfoData.microformat = microformat;
324
325 this.logger.debug(_scope, 'parsed microformat data', logInfoData);
326 return microformat;
327 }
328
329
330 /**
331 * Retrieve and parse JSON.
332 * N.B. this absorbs any errors!
333 * @param {URL} urlObj
334 * @returns {Promise<Object>}
335 */
336 async fetchJSON(urlObj) {
337 const _scope = _fileScope('fetchJSON');
338 const logInfoData = {
339 url: urlObj.href,
340 response: undefined,
341 };
342 let response;
343 try {
344 const fetchJSONConfig = {
345 method: 'GET',
346 url: urlObj,
347 headers: {
348 [Enum.Header.Accept]: this._jsonAccept,
349 },
350 responseType: 'json',
351 };
352 response = await this.got(fetchJSONConfig);
353 } catch (e) {
354 this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
355 return;
356 }
357
358 return response.body;
359 }
360
361
362 /**
363 * Validate a url has a specific schema.
364 * @param {URL} urlObj
365 * @param {String[]} validSchemes
366 */
367 static _urlValidScheme(urlObj, validSchemes = ['http:', 'https:']) {
368 if (!validSchemes.includes(urlObj.protocol)) {
369 throw new ValidationError(`unsupported url scheme '${urlObj.protocol}'`);
370 }
371 }
372
373
374 /**
375 * Validate a url does not include some components.
376 * @param {URL} urlObj
377 * @param {String[]} disallowed
378 */
379 static _urlPartsDisallowed(urlObj, disallowed) {
380 disallowed.forEach((part) => {
381 if (urlObj[part]) { // eslint-disable-line security/detect-object-injection
382 throw new ValidationError(`url cannot contain ${common.properURLComponentName(part)}`);
383 }
384 });
385 }
386
387
388 /**
389 * Validate a url does not have relative path.
390 * @param {String} url
391 */
392 static _urlPathNoDots(url) {
393 if (noDotPathRE.test(url)) {
394 throw new ValidationError('relative path segment not valid');
395 }
396 }
397
398
399 /**
400 * Validate a url does not have a hostname which is an ip address.
401 * N.B. Sets isLoopback on urlObj
402 * @param {URL} urlObj
403 * @param {Boolean} allowLoopback
404 * @param {Boolean} resolveHostname
405 * @returns {Promise<void>}
406 */
407 static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) {
408 let address;
409 if (v6HostRE.test(urlObj.hostname)) {
410 /**
411 * We do not need to worry about the Address6() failing to parse,
412 * as if it looks like an ipv6 addr but is not valid, the URL()
413 * call would already have failed.
414 */
415 address = new Address6(urlObj.hostname.slice(1, urlObj.hostname.length - 1));
416 /* Succeeded parsing as ipv6, reject unless loopback */
417 urlObj.isLoopback = address.isLoopback();
418 } else {
419 try {
420 address = new Address4(urlObj.hostname);
421 /* Succeeded parsing as ipv4, reject unless loopback */
422 urlObj.isLoopback = address.isInSubnet(loopback4);
423 } catch (e) {
424 /* Did not parse as ip, carry on */
425 }
426 }
427
428 if (resolveHostname && !urlObj.isLoopback) {
429 /**
430 * Resolve hostname to check for localhost.
431 * This is more complicated due to SSRF mitigation:
432 * If the hostname does not end with a ., we also resolve that,
433 * and complain if the two resolutions do not match, assuming
434 * malicious intent for the server to resolve a local record.
435 */
436 const hostnames = [urlObj.hostname];
437 if (!urlObj.hostname.endsWith('.')) {
438 hostnames.push(urlObj.hostname + '.');
439 }
440 const settledResolutions = await Promise.allSettled(hostnames.map((hostname) => dns.promises.lookup(hostname, {
441 all: true,
442 verbatim: true,
443 })));
444 // If any resolution failed, bail.
445 if (settledResolutions
446 .map((resolution) => resolution.status)
447 .includes('rejected')) {
448 throw new ValidationError('could not resolve hostname');
449 }
450
451 // Extract each resolution value, array of {address,family}
452 const resolutions = settledResolutions.map((resolution) => resolution.value);
453
454 // If there were two resolutions, ensure they returned identical results.
455 if (resolutions.length > 1) {
456 // Create set of addresses for each resolution
457 const addressSets = resolutions.map((addrs) => {
458 return new Set((addrs || []).map((a) => a.address));
459 });
460 const differences = common.setSymmetricDifference(...addressSets);
461 if (differences.size) {
462 throw new ValidationError('inconsistent hostname resolution');
463 }
464 }
465 const resolvedHost = resolutions[0] || [];
466
467 // Persist the loopback state
468 urlObj.isLoopback = resolvedHost.reduce((acc, resolved) => {
469 let addr;
470 switch (resolved.family) {
471 case 4:
472 addr = new Address4(resolved.address);
473 return acc || addr.isInSubnet(loopback4);
474 case 6:
475 addr = new Address6(resolved.address);
476 return acc || addr.isLoopback();
477 default:
478 return acc;
479 }
480 }, false);
481 }
482
483 if (address
484 && (!urlObj.isLoopback || !allowLoopback)) {
485 throw new ValidationError('hostname cannot be IP');
486 }
487 }
488
489
490 /**
491 * Ensure a url meets the requirements to be a profile uri.
492 * @param {String} url
493 * @param {Object} validationOptions
494 * @param {Boolean=} validationOptions.allowLoopback default is false, following spec
495 * @param {Boolean=} validationOptions.resolveHostname default is false, following spec
496 * @returns {Promise<URL>}
497 */
498 async validateProfile(url, validationOptions) {
499 const _scope = _fileScope('validateProfile');
500 const errorScope = 'invalid profile url';
501
502 const options = {
503 allowLoopback: false,
504 resolveHostname: false,
505 ...validationOptions,
506 };
507
508 let profile;
509 try {
510 profile = new URL(url);
511 } catch (e) {
512 this.logger.debug(_scope, 'failed to parse url', { error: e, url });
513 throw new ValidationError(`${errorScope}: unparsable`);
514 }
515 profile.isLoopback = false;
516
517 try {
518 Communication._urlValidScheme(profile);
519 Communication._urlPartsDisallowed(profile, ['hash', 'username', 'password', 'port']);
520 Communication._urlPathNoDots(url);
521 await Communication._urlNamedHost(profile, options.allowLoopback, options.resolveHostname);
522 } catch (e) {
523 this.logger.debug(_scope, 'profile url not valid', { url, error: e });
524 throw new ValidationError(`${errorScope}: ${e.message}`);
525 }
526
527 return profile;
528 }
529
530
531 /**
532 * Ensure a url meets the requirements to be a client identifier.
533 * Sets 'isLoopback' on returned URL object to true if hostname is - or resolves to - a loopback ip.
534 * @param {String} url
535 * @param {Object} validationOptions
536 * @param {Boolean=} validationOptions.allowLoopback default is true, following spec
537 * @param {Boolean=} validationOptions.resolveHostname default is true, following spec
538 * @returns {Promise<URL>}
539 */
540 async validateClientIdentifier(url, validationOptions) {
541 const _scope = _fileScope('validateClientIdentifier');
542 const errorScope = 'invalid client identifier url';
543
544 const options = {
545 allowLoopback: true,
546 resolveHostname: true,
547 ...validationOptions,
548 };
549
550 let clientId;
551 try {
552 clientId = new URL(url);
553 } catch (e) {
554 this.logger.debug(_scope, 'failed to parse url', { error: e, url });
555 throw new ValidationError('invalid client identifier url: unparsable');
556 }
557 clientId.isLoopback = false;
558
559 try {
560 Communication._urlValidScheme(clientId);
561 Communication._urlPartsDisallowed(clientId, ['hash', 'username', 'password']);
562 Communication._urlPathNoDots(url);
563 await Communication._urlNamedHost(clientId, options.allowLoopback, options.resolveHostname);
564 } catch (e) {
565 this.logger.debug(_scope, 'client identifier url not valid', { url, error: e });
566 throw new ValidationError(`${errorScope}: ${e.message}`);
567 }
568
569 return clientId;
570 }
571
572
573 /**
574 * @typedef {Object} ClientIdentifierData
575 * @property {Object} rels - keyed by relation to array of uris
576 * @property {HAppData[]} items
577 */
578 /**
579 * Retrieve and parse client identifier endpoint data.
580 * N.B. Assumes urlObj has passed validateClientIdentifier.
581 * @param {URL} urlObj
582 * @returns {Promise<ClientIdentifierData|undefined>} mf2 data filtered for h-app items, or undefined if url could not be fetched
583 */
584 async fetchClientIdentifier(urlObj) {
585 // Set by validation method in case of loopback ip hostname
586 if (urlObj.isLoopback) {
587 // Loopback address will eschew client fetch, return empty data.
588 return {
589 rels: {},
590 items: [],
591 };
592 }
593
594 const mfData = await this.fetchMicroformat(urlObj);
595 if (!mfData) {
596 return undefined;
597 }
598
599 // Only return h-app items with matching url field.
600 return {
601 rels: mfData.rels || {},
602 items: (mfData.items || []).filter((item) => {
603 let urlMatched = false;
604 const itemType = item.type || [];
605 if ((itemType.includes('h-app') || itemType.includes('h-x-app'))
606 && (item?.properties?.url)) {
607 item.properties.url.forEach((url) => {
608 try {
609 const hUrl = new URL(url);
610 if (hUrl.href === urlObj.href) {
611 urlMatched = true;
612 }
613 } catch (e) { /**/ }
614 });
615 }
616 return urlMatched;
617 }),
618 };
619 }
620
621
622 /**
623 * @typedef {Object} Metadata
624 * @property {String} issuer
625 * @property {String} authorizationEndpoint
626 * @property {String} tokenEndpoint
627 * @property {String} ticketEndpoint
628 * @property {String} introspectionEndpoint
629 * @property {String} introspectionEndpointAuthMethodsSupported
630 * @property {String} revocationEndpoint
631 * @property {String} revocationEndpointAuthMethodsSupported
632 * @property {String} scopesSupported
633 * @property {String} responseTypesSupported
634 * @property {String} grantTypesSupported
635 * @property {String} serviceDocumentation
636 * @property {String} codeChallengeMethodsSupported
637 * @property {String} authorizationResponseIssParameterSupported
638 * @property {String} userinfoEndpoint
639 */
640 /**
641 * @typedef ProfileData
642 * @property {String} name
643 * @property {String} photo
644 * @property {String} url
645 * @property {String} email
646 * @property {String} authorizationEndpoint - deprecated, backwards compatibility for 20201126 spec
647 * @property {String} tokenEndpoint - deprecated, backwards compatibility for 20201126 spec
648 * @property {String} indieauthMetadata authorization server metadata endpoint
649 * @property {Metadata} metadata - authorization server metadata for profile
650 */
651 /**
652 * Fetch the relevant microformat data from profile url h-card information,
653 * and authorization server metadata.
654 * N.B. Assumes urlObj has passed validateProfile
655 * @param {URL} urlObj
656 * @returns {Promise<ProfileData>} mf2 data filtered for select fields from h-card
657 */
658 async fetchProfile(urlObj) {
659 const _scope = _fileScope('fetchProfile');
660
661 const mfData = await this.fetchMicroformat(urlObj);
662 const profile = {
663 name: undefined,
664 photo: undefined,
665 url: undefined,
666 email: undefined,
667 metadata: {},
668 };
669
670 /**
671 * Locate h-card mf2 items with url field matching profile url,
672 * and populate profile fields with first-encountered card values.
673 */
674 if (mfData && 'items' in mfData) {
675 const hCards = mfData.items.filter((item) =>
676 item?.type?.includes('h-card') &&
677 item?.properties?.url?.includes(urlObj.href));
678 hCards.forEach((hCard) => {
679 Object.keys(profile).forEach((key) => {
680 if (!profile[key] && key in hCard.properties) { // eslint-disable-line security/detect-object-injection
681 profile[key] = hCard.properties[key][0]; // eslint-disable-line security/detect-object-injection
682 }
683 });
684 });
685 }
686
687 // Populate legacy mf2 fields from relation links.
688 // These will be overwritten if they also exist in server metadata.
689 Object.entries({
690 authorizationEndpoint: MF2Rel.AuthorizationEndpoint, // Backwards compatibility
691 tokenEndpoint: MF2Rel.TokenEndpoint, // Backwards compatibility
692 ticketEndpoint: MF2Rel.TicketEndpoint, // Backwards compatibility
693 }).forEach(([p, r]) => {
694 if (mfData && r in mfData.rels) {
695 profile.metadata[p] = profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection
696 }
697 });
698
699 // Set metadata field.
700 if (mfData && MF2Rel.IndieauthMetadata in mfData.rels) {
701 profile.indieauthMetadata = mfData.rels[MF2Rel.IndieauthMetadata][0];
702 }
703
704 // Attempt to populate metadata from authorization server.
705 if (profile.indieauthMetadata) {
706 let mdURL;
707 try {
708 mdURL = new URL(profile.indieauthMetadata);
709 } catch (e) /* istanbul ignore next */ {
710 this.logger.error(_scope, 'invalid authorization server metadata url', { profile });
711 }
712 /* istanbul ignore else */
713 if (mdURL) {
714 profile.metadata = await this.fetchMetadata(mdURL);
715
716 // Populate legacy profile fields.
717 ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f) => {
718 if (f in profile.metadata) {
719 profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection
720 }
721 });
722 }
723 }
724
725 return profile;
726 }
727
728
729 /**
730 * Fetch the server metadata from an authorization server's metadata endpoint.
731 * @param {URL} metadataUrl
732 * @returns {Promise<Metadata>}
733 */
734 async fetchMetadata(metadataUrl) {
735 const metadataResponse = await this.fetchJSON(metadataUrl);
736 const metadata = {};
737 if (metadataResponse) {
738 // Map snake_case fields to camelCase.
739 Object.entries({
740 issuer: 'issuer',
741 authorizationEndpoint: 'authorization_endpoint',
742 tokenEndpoint: 'token_endpoint',
743 ticketEndpoint: 'ticket_endpoint',
744 introspectionEndpoint: 'introspection_endpoint',
745 introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
746 revocationEndpoint: 'revocation_endpoint',
747 revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
748 scopesSupported: 'scopes_supported',
749 responseTypesSupported: 'response_types_supported',
750 grantTypesSupported: 'grant_types_supported',
751 serviceDocumentation: 'service_documentation',
752 codeChallengeMethodsSupported: 'code_challenge_methods_supported',
753 authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
754 userinfoEndpoint: 'userinfo_endpoint',
755 }).forEach(([c, s]) => {
756 if (s in metadataResponse) {
757 metadata[c] = metadataResponse[s]; // eslint-disable-line security/detect-object-injection
758 }
759 });
760 }
761
762 return metadata;
763 }
764
765
766 /**
767 * POST to the auth endpoint, to redeem a code for a profile or token.
768 * N.B. this absorbs any errors!
769 * @param {URL} urlObj
770 * @param {String} code
771 * @param {String} codeVerifier
772 * @param {String} clientId
773 * @param {String} redirectURI
774 * @returns {Promise<Object>}
775 */
776 async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) {
777 const _scope = _fileScope('redeemCode');
778
779 const postRedeemCodeConfig = {
780 url: urlObj,
781 method: 'POST',
782 headers: {
783 [Enum.Header.Accept]: this._jsonAccept,
784 },
785 form: {
786 'grant_type': 'authorization_code',
787 code,
788 'client_id': clientId,
789 'redirect_uri': redirectURI,
790 'code_verifier': codeVerifier,
791 },
792 responseType: 'json',
793 };
794
795 try {
796 const response = await this.got(postRedeemCodeConfig);
797 return response.body;
798 } catch (e) {
799 this.logger.error(_scope, 'redeem code request failed', { error: e, url: urlObj.href });
800 return;
801 }
802 }
803
804
805 /**
806 * Deprecated method name alias.
807 * @see redeemCode
808 * @param {URL} urlObj
809 * @param {String} code
810 * @param {Strin} codeVerifier
811 * @param {String} clientId
812 * @param {String} redirectURI
813 * @returns {Promise<Object>}
814 */
815 async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
816 return this.redeemCode(urlObj, code, codeVerifier, clientId, redirectURI);
817 }
818
819
820 /**
821 * Verify a token with an IdP endpoint, using the Authorization header supplied.
822 * @param {URL} introspectionUrlObj
823 * @param {String} authorizationHeader
824 * @param {String} token
825 * @returns {Promise<Object>}
826 */
827 async introspectToken(introspectionUrlObj, authorizationHeader, token) {
828 const _scope = _fileScope('introspectToken');
829
830 const postIntrospectConfig = {
831 url: introspectionUrlObj,
832 method: 'POST',
833 headers: {
834 [Enum.Header.Authorization]: authorizationHeader,
835 [Enum.Header.Accept]: this._jsonAccept,
836 },
837 form: {
838 token,
839 },
840 responseType: 'json',
841 };
842
843 try {
844 const response = await this.got(postIntrospectConfig);
845 // Check status
846 try {
847 const {
848 active,
849 me,
850 client_id: clientId,
851 scope,
852 exp,
853 iat,
854 } = response.body;
855
856 if (![true, false].includes(active)) {
857 throw new RangeError('missing required response field "active"');
858 }
859
860 return {
861 active,
862 ...(me && { me }),
863 ...(clientId && { clientId }),
864 ...(scope && { scope: scope.split(scopeSplitRE) }),
865 ...(exp && { exp: Number(exp) }),
866 ...(iat && { iat: Number(iat) }),
867 };
868 } catch (e) {
869 this.logger.error(_scope, 'failed to parse json', { error: e, response: common.gotResponseLogData(response) });
870 throw e;
871 }
872 } catch (e) {
873 this.logger.error(_scope, 'introspect token request failed', { error: e, url: introspectionUrlObj.href });
874 throw e;
875 }
876 }
877
878
879 /**
880 * Attempt to deliver a ticket to an endpoint.
881 * N.B. does not absorb errors
882 * @param {URL} ticketEndpointUrlObj
883 * @param {URL} resourceUrlObj
884 * @param {URL} subjectUrlObj
885 * @param {URL=} issuerUrlObj
886 * @param {String} ticket
887 * @returns {Promise<Response>}
888 */
889 async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj) {
890 const _scope = _fileScope('deliverTicket');
891
892 try {
893 const ticketConfig = {
894 method: 'POST',
895 url: ticketEndpointUrlObj,
896 form: {
897 ticket,
898 resource: resourceUrlObj.href,
899 subject: subjectUrlObj.href,
900 ...( issuerUrlObj && { iss: issuerUrlObj.href }),
901 },
902 };
903 const result = await this.got(ticketConfig);
904 this.logger.debug(_scope, 'success', { ...common.gotResponseLogData(result) });
905 return result;
906 } catch (e) {
907 this.logger.error(_scope, 'ticket delivery request failed', { error: e, url: ticketEndpointUrlObj.href });
908 throw e;
909 }
910 }
911
912
913 /**
914 * Attempt to fetch some link relations from a url.
915 * @param {URL} urlObj
916 * @returns {Promise<Object>}
917 */
918 async _fetchMetadataOrTokenEndpoint(urlObj) {
919 const _scope = _fileScope('_fetchMetadataOrTokenEndpoint');
920
921 let metadataUrl, tokenUrl;
922 if (urlObj) {
923 const mfData = await this.fetchMicroformat(urlObj);
924 const metadataRel = mfData?.rels?.[MF2Rel.IndieauthMetadata]?.[0];
925 if (metadataRel) {
926 try {
927 metadataUrl = new URL(metadataRel);
928 } catch (e) {
929 this.logger.debug(_scope, 'invalid metadata rel url', { url: urlObj.href, metadataRel });
930 }
931 }
932 if (!metadataUrl) {
933 // No metadata rel, try old-style token endpoint
934 const tokenRel = mfData?.rels?.[MF2Rel.TokenEndpoint]?.[0];
935 if (tokenRel) {
936 try {
937 tokenUrl = new URL(tokenRel);
938 } catch (e) {
939 this.logger.debug(_scope, 'invalid token rel url', { url: urlObj.href, tokenRel });
940 }
941 }
942 }
943 }
944 return { metadataUrl, tokenUrl };
945 }
946
947
948 /**
949 * Attempt to redeem a ticket for a token.
950 * N.B. does not absorb errors
951 * @property {String} ticket
952 * @property {URL} resourceUrlObj
953 * @property {URL=} issuerUrlObj
954 * @returns {Promise<Object>} response body
955 */
956 async redeemTicket(ticket, resourceUrlObj, issuerUrlObj) {
957 const _scope = _fileScope('redeemTicket');
958
959 let metadataUrl, tokenUrl;
960 // Attempt to determine metadata or token endpoint from issuer MF data
961 if (issuerUrlObj) {
962 ({ metadataUrl, tokenUrl } = await this._fetchMetadataOrTokenEndpoint(issuerUrlObj));
963 }
964
965 // Fallback to resource MF data
966 if (!metadataUrl && !tokenUrl) {
967 ({ metadataUrl, tokenUrl } = await this._fetchMetadataOrTokenEndpoint(resourceUrlObj));
968 }
969
970 if (metadataUrl) {
971 const metadata = await this.fetchMetadata(metadataUrl);
972 try {
973 tokenUrl = new URL(metadata?.tokenEndpoint);
974 } catch (e) {
975 this.logger.debug(_scope, 'invalid token endpoint url from metadata', { resourceUrl: resourceUrlObj.href, issuerUrl: issuerUrlObj.href, tokenEndpoint: metadata?.tokenEndpoint });
976 }
977 }
978
979 if (!tokenUrl) {
980 throw new ValidationError('could not determine endpoint for ticket redemption');
981 }
982
983 const postRedeemTicketConfig = {
984 url: tokenUrl,
985 method: 'POST',
986 headers: {
987 [Enum.Header.Accept]: this._jsonAccept,
988 },
989 form: {
990 'grant_type': 'ticket',
991 ticket,
992 },
993 responseType: 'json',
994 };
995
996 try {
997 const response = await this.got(postRedeemTicketConfig);
998 return response.body;
999 } catch (e) {
1000 this.logger.error(_scope, 'ticket redemption failed', { error: e, resource: resourceUrlObj.href, issuer: issuerUrlObj?.href });
1001 throw e;
1002 }
1003 }
1004 }
1005
1006 module.exports = Communication;