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