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