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