minor doc fixes
[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
23 class Communication {
24 /**
25 * @param {Console} logger
26 * @param {Object} options
27 * @param {Number=} options.timeout
28 * @param {Object=} options.userAgent
29 * @param {String=} options.userAgent.product
30 * @param {String=} options.userAgent.version
31 * @param {String=} options.userAgent.implementation
32 */
33 constructor(logger, options = {}) {
34 this.logger = logger;
35 this.options = options;
36
37 this._defaultAccept = options?.defaultAccept || 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1';
38 this._jsonAccept = options?.jsonAccept || [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', ');
39
40 this.Got = undefined;
41 this.got = this._init; // Do the dynamic import on first attempt to use client.
42 }
43
44
45 /**
46 * Do a little dance to support this ESM client.
47 */
48 async _init(...args) {
49 if (!this.Got) {
50 // For some reason eslint is confused about import being supported here.
51 // eslint-disable-next-line
52 this.Got = await import('got');
53 this.got = this.Got.got.extend({
54 headers: {
55 [Enum.Header.UserAgent]: Communication._userAgentString(this.options.userAgent),
56 [Enum.Header.Accept]: this._defaultAccept,
57 },
58 timeout: {
59 request: this.options.timeout || 120000,
60 },
61 hooks: {
62 beforeRetry: [
63 this._onRetry,
64 ],
65 },
66 });
67 }
68 if (args.length) {
69 return this.got(...args);
70 }
71 }
72
73
74 /**
75 * Take notes on transient retries.
76 * @param {*} error
77 * @param {*} retryCount
78 */
79 _onRetry(error, retryCount) {
80 const _scope = _fileScope('_onRetry');
81 this.logger.debug(_scope, 'retry', { retryCount, error });
82 }
83
84
85 /**
86 * Encode hashed verifier data for PKCE.
87 * @param {BinaryLike} verifier
88 * @returns {String}
89 */
90 static _challengeFromVerifier(verifier) {
91 const hash = createHash('sha256');
92 hash.update(verifier);
93 return hash.digest('base64url');
94 }
95
96
97 /**
98 * @typedef PKCEData
99 * @property {String} codeChallengeMethod
100 * @property {String} codeVerifier
101 * @property {String} codeChallenge
102 */
103 /**
104 * Create a code verifier and its challenge.
105 * @param {Number} length of verifier string, between 43 and 128
106 * @returns {Promise<PKCEData>}
107 */
108 static async generatePKCE(length = 128) {
109 if (length < 43 || length > 128) {
110 throw new RangeError('InvalidLength');
111 }
112
113 const bufferLength = Math.floor(length * 3 / 4);
114 const randomBuffer = await randomBytesAsync(bufferLength);
115 const verifier = randomBuffer.toString('base64url');
116
117 const challenge = Communication._challengeFromVerifier(verifier);
118
119 return {
120 codeChallengeMethod: 'S256',
121 codeVerifier: verifier,
122 codeChallenge: challenge,
123 };
124 }
125
126
127 /**
128 * Check a challenge with a verifier.
129 * @param {String} codeChallenge
130 * @param {String} codeVerifier
131 * @param {String} codeChallengeMethod
132 * @returns {Boolean}
133 */
134 static verifyChallenge(codeChallenge, codeVerifier, codeChallengeMethod) {
135 switch (codeChallengeMethod) {
136 case 'SHA256':
137 case 'S256': {
138 const challenge = Communication._challengeFromVerifier(codeVerifier);
139 return challenge === codeChallenge;
140 }
141
142 default:
143 throw new Error('unsupported challenge method');
144 }
145 }
146
147
148 /**
149 * Assemble a suitable User-Agent value.
150 * @param {Object} userAgentConfig
151 * @param {String=} userAgentConfig.product
152 * @param {String=} userAgentConfig.version
153 * @param {String=} userAgentConfig.implementation
154 * @returns {String}
155 */
156 static _userAgentString(userAgentConfig) {
157 // eslint-disable-next-line security/detect-object-injection
158 const _conf = (field, def) => (userAgentConfig && field in userAgentConfig) ? userAgentConfig[field] : def;
159 const product = _conf('product', packageName).split('/').pop();
160 const version = _conf('version', packageVersion);
161 let implementation = _conf('implementation', Enum.Specification);
162 if (implementation) {
163 implementation = ` (${implementation})`;
164 }
165 return `${product}/${version}${implementation}`;
166 }
167
168
169 /**
170 * Isolate the base of a url.
171 * mf2 parser needs this so that relative links can be made absolute.
172 * @param {URL} urlObj
173 * @returns {String}
174 */
175 static _baseUrlString(urlObj) {
176 const baseUrl = new URL(urlObj);
177 const lastSlashIdx = baseUrl.pathname.lastIndexOf('/');
178 if (lastSlashIdx > 0) {
179 baseUrl.pathname = baseUrl.pathname.slice(0, lastSlashIdx + 1);
180 }
181 return baseUrl.href;
182 }
183
184
185 /**
186 * Convert a Content-Type string to normalized components.
187 * RFC7231 §3.1.1
188 * N.B. this ill-named non-parsing implementation will not work
189 * if a parameter value for some reason includes a ; or = within
190 * a quoted-string.
191 * @param {String} contentTypeHeader
192 * @returns {Object} contentType
193 * @returns {String} contentType.mediaType
194 * @returns {Object} contentType.params
195 */
196 static _parseContentType(contentTypeHeader, defaultContentType = Enum.ContentType.ApplicationOctetStream) {
197 const [ mediaType, ...params ] = (contentTypeHeader || '').split(/ *; */);
198 return {
199 mediaType: mediaType.toLowerCase() || defaultContentType,
200 params: params.reduce((obj, param) => {
201 const [field, value] = param.split('=');
202 const isQuoted = value?.startsWith('"') && value?.endsWith('"');
203 obj[field.toLowerCase()] = isQuoted ? value.slice(1, value.length - 1) : value;
204 return obj;
205 }, {}),
206 };
207 }
208
209
210 /**
211 * Parse and add any header link relations from response to microformat data.
212 * @param {Object} microformat
213 * @param {Object} response
214 * @param {Object} response.headers
215 */
216 _mergeLinkHeader(microformat, response) {
217 const _scope = _fileScope('_mergeLinkHeader');
218
219 // Establish that microformat has expected structure
220 ['rels', 'rel-urls'].forEach((p) => {
221 if (!(p in microformat)) {
222 microformat[p] = {}; // eslint-disable-line security/detect-object-injection
223 }
224 });
225 if (!('items' in microformat)) {
226 microformat.items = [];
227 }
228
229 const linkHeader = response.headers[Enum.Header.Link.toLowerCase()];
230 const links = [];
231 if (linkHeader) {
232 try {
233 links.push(...parseLinkHeader(linkHeader));
234 } catch (e) {
235 this.logger.error(_scope, 'failed to parse link header', { error: e, linkHeader });
236 return;
237 }
238 }
239
240 // Push header link rels into microformat form.
241 // Inserted at front of lists, as headers take precedence.
242 links.forEach((link) => {
243 link.attributes.forEach((attr) => {
244 if (attr.name === 'rel') {
245 if (!(attr.value in microformat.rels)) {
246 microformat.rels[attr.value] = [];
247 }
248 microformat.rels[attr.value].unshift(link.target);
249
250 if (!(link.target in microformat['rel-urls'])) {
251 microformat['rel-urls'][link.target] = {
252 text: '',
253 rels: [],
254 };
255 }
256 microformat['rel-urls'][link.target].rels.unshift(attr.value);
257 }
258 });
259 });
260 }
261
262
263 /**
264 * Retrieve and parse microformat data from url.
265 * N.B. this absorbs any errors!
266 * @param {URL} urlObj
267 * @returns {Promise<Object>}
268 */
269 async fetchMicroformat(urlObj) {
270 const _scope = _fileScope('fetchMicroformat');
271 const logInfoData = {
272 url: urlObj.href,
273 microformat: undefined,
274 response: undefined,
275 };
276 let response;
277 try {
278 const fetchMicroformatConfig = {
279 method: 'GET',
280 url: urlObj,
281 responseType: 'buffer',
282 };
283 response = await this.got(fetchMicroformatConfig);
284 } catch (e) {
285 this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData });
286 return;
287 }
288 logInfoData.response = common.gotResponseLogData(response);
289
290 // Normalize to utf8.
291 let body;
292 const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]);
293 // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8.
294 const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset;
295 if (nonUTF8Charset) {
296 try {
297 const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore');
298 body = iconv.convert(response.body).toString('utf8');
299 } catch (e) {
300 // istanbul ignore next
301 this.logger.error(_scope, 'iconv conversion error', { error: e, ...logInfoData });
302 // Try to carry on, maybe the encoding will work anyhow...
303 }
304 } else {
305 body = response.body.toString('utf8');
306 }
307
308 let microformat = {};
309 try {
310 microformat = mf2(body, {
311 baseUrl: Communication._baseUrlString(urlObj),
312 });
313 } catch (e) {
314 this.logger.error(_scope, 'failed to parse microformat data', { error: e, ...logInfoData });
315 // Try to carry on, maybe there are link headers...
316 }
317
318 this._mergeLinkHeader(microformat, response);
319
320 logInfoData.microformat = microformat;
321
322 this.logger.debug(_scope, 'parsed microformat data', logInfoData);
323 return microformat;
324 }
325
326
327 /**
328 * Retrieve and parse JSON.
329 * N.B. this absorbs any errors!
330 * @param {URL} urlObj
331 * @returns {Promise<Object>}
332 */
333 async fetchJSON(urlObj) {
334 const _scope = _fileScope('fetchJSON');
335 const logInfoData = {
336 url: urlObj.href,
337 response: undefined,
338 };
339 let response;
340 try {
341 const fetchJSONConfig = {
342 method: 'GET',
343 url: urlObj,
344 headers: {
345 [Enum.Header.Accept]: this._jsonAccept,
346 },
347 responseType: 'json',
348 };
349 response = await this.got(fetchJSONConfig);
350 } catch (e) {
351 this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
352 return;
353 }
354 logInfoData.response = common.gotResponseLogData(response);
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 ProfileData
624 * @property {String} name
625 * @property {String} photo
626 * @property {String} url
627 * @property {String} email
628 * @property {String} authorizationEndpoint - deprecated, backwards compatibility for 20201126 spec
629 * @property {String} tokenEndpoint - deprecated, backwards compatibility for 20201126 spec
630 * @property {String} indieauthMetadata authorization server metadata endpoint
631 * @property {Object} metadata - authorization server metadata for profile
632 * @property {String} metadata.issuer
633 * @property {String} metadata.authorizationEndpoint
634 * @property {String} metadata.tokenEndpoint
635 * @property {String} metadata.ticketEndpoint
636 * @property {String} metadata.introspectionEndpoint
637 * @property {String} metadata.introspectionEndpointAuthMethodsSupported
638 * @property {String} metadata.revocationEndpoint
639 * @property {String} metadata.revocationEndpointAuthMethodsSupported
640 * @property {String} metadata.scopesSupported
641 * @property {String} metadata.responseTypesSupported
642 * @property {String} metadata.grantTypesSupported
643 * @property {String} metadata.serviceDocumentation
644 * @property {String} metadata.codeChallengeMethodsSupported
645 * @property {String} metadata.authorizationResponseIssParameterSupported
646 * @property {String} metadata.userinfoEndpoint
647 */
648 /**
649 * Fetch the relevant microformat data from profile url h-card information,
650 * and authorization server metadata.
651 * N.B. Assumes urlObj has passed validateProfile
652 * @param {URL} urlObj
653 * @returns {Promise<ProfileData>} mf2 data filtered for select fields from h-card
654 */
655 async fetchProfile(urlObj) {
656 const _scope = _fileScope('fetchProfile');
657
658 const mfData = await this.fetchMicroformat(urlObj);
659 const profile = {
660 name: undefined,
661 photo: undefined,
662 url: undefined,
663 email: undefined,
664 metadata: {},
665 };
666
667 // Locate h-card mf2 items with url field matching profile url,
668 // and populate profile fields with first-encountered card values.
669 if (mfData && 'items' in mfData) {
670 const hCards = mfData.items.filter((item) =>
671 item?.type?.includes('h-card') &&
672 item.properties && item.properties.url && item.properties.url.includes(urlObj.href));
673 hCards.forEach((hCard) => {
674 Object.keys(profile).forEach((key) => {
675 if (!profile[key] && key in hCard.properties) { // eslint-disable-line security/detect-object-injection
676 profile[key] = hCard.properties[key][0]; // eslint-disable-line security/detect-object-injection
677 }
678 });
679 });
680 }
681
682 // Populate legacy mf2 fields from relation links.
683 // These will be overwritten if they also exist in server metadata.
684 Object.entries({
685 authorizationEndpoint: 'authorization_endpoint', // backwards compatibility
686 tokenEndpoint: 'token_endpoint', // backwards compatibility
687 ticketEndpoint: 'ticket_endpoint', // backwards compatibility
688 }).forEach(([p, r]) => {
689 if (mfData && r in mfData.rels) {
690 profile.metadata[p] = profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection
691 }
692 });
693
694 // Set metadata field.
695 if (mfData && 'indieauth-metadata' in mfData.rels) {
696 profile.indieauthMetadata = mfData.rels['indieauth-metadata'][0];
697 }
698
699 // Attempt to populate metadata from authorization server.
700 if (profile.indieauthMetadata) {
701 let mdURL;
702 try {
703 mdURL = new URL(profile.indieauthMetadata);
704 } catch (e) /* istanbul ignore next */ {
705 this.logger.error(_scope, 'invalid authorization server metadata url', { profile });
706 }
707 /* istanbul ignore else */
708 if (mdURL) {
709 const metadataResponse = await this.fetchJSON(mdURL);
710 if (metadataResponse) {
711 // Map snake_case fields to camelCase.
712 Object.entries({
713 issuer: 'issuer',
714 authorizationEndpoint: 'authorization_endpoint',
715 tokenEndpoint: 'token_endpoint',
716 ticketEndpoint: 'ticket_endpoint',
717 introspectionEndpoint: 'introspection_endpoint',
718 introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
719 revocationEndpoint: 'revocation_endpoint',
720 revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
721 scopesSupported: 'scopes_supported',
722 responseTypesSupported: 'response_types_supported',
723 grantTypesSupported: 'grant_types_supported',
724 serviceDocumentation: 'service_documentation',
725 codeChallengeMethodsSupported: 'code_challenge_methods_supported',
726 authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
727 userinfoEndpoint: 'userinfo_endpoint',
728 }).forEach(([c, s]) => {
729 if (s in metadataResponse) {
730 profile.metadata[c] = metadataResponse[s]; // eslint-disable-line security/detect-object-injection
731 }
732 });
733
734 // Populate legacy profile fields.
735 ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f) => {
736 if (f in profile.metadata) {
737 profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection
738 }
739 });
740 }
741 }
742 }
743
744 return profile;
745 }
746
747
748 /**
749 * POST to the auth endpoint, to redeem a code for a profile or token.
750 * N.B. this absorbs any errors!
751 * @param {URL} urlObj
752 * @param {String} code
753 * @param {String} codeVerifier
754 * @param {String} clientId
755 * @param {String} redirectURI
756 * @returns {Promise<Object>}
757 */
758 async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) {
759 const _scope = _fileScope('redeemCode');
760
761 const postRedeemCodeConfig = {
762 url: urlObj,
763 method: 'POST',
764 headers: {
765 [Enum.Header.Accept]: this._jsonAccept,
766 },
767 form: {
768 'grant_type': 'authorization_code',
769 code,
770 'client_id': clientId,
771 'redirect_uri': redirectURI,
772 'code_verifier': codeVerifier,
773 },
774 responseType: 'json',
775 };
776
777 try {
778 const response = await this.got(postRedeemCodeConfig);
779 return response.body;
780 } catch (e) {
781 this.logger.error(_scope, 'redeem code request failed', { error: e, url: urlObj.href });
782 return;
783 }
784 }
785
786
787 /**
788 * Deprecated method name alias.
789 * @see redeemCode
790 * @param {URL} urlObj
791 * @param {String} code
792 * @param {Strin} codeVerifier
793 * @param {String} clientId
794 * @param {String} redirectURI
795 * @returns {Promise<Object>}
796 */
797 async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) {
798 return this.redeemCode(urlObj, code, codeVerifier, clientId, redirectURI);
799 }
800
801
802 /**
803 * Verify a token with an IdP endpoint, using the Authorization header supplied.
804 * @param {URL} introspectionUrlObj
805 * @param {String} authorizationHeader
806 * @param {String} token
807 * @returns {Promise<Object>}
808 */
809 async introspectToken(introspectionUrlObj, authorizationHeader, token) {
810 const _scope = _fileScope('introspectToken');
811
812 const postIntrospectConfig = {
813 url: introspectionUrlObj,
814 method: 'POST',
815 headers: {
816 [Enum.Header.Authorization]: authorizationHeader,
817 [Enum.Header.Accept]: this._jsonAccept,
818 },
819 form: {
820 token,
821 },
822 responseType: 'json',
823 };
824
825 try {
826 const response = await this.got(postIntrospectConfig);
827 this.logger.debug(_scope, 'response', { response });
828 // check status
829 try {
830 const {
831 active,
832 me,
833 client_id: clientId,
834 scope,
835 exp,
836 iat,
837 } = response.body;
838
839 if (![true, false].includes(active)) {
840 throw new RangeError('missing required response field "active"');
841 }
842
843 return {
844 active,
845 ...(me && { me }),
846 ...(clientId && { clientId }),
847 ...(scope && { scope: scope.split(scopeSplitRE) }),
848 ...(exp && { exp: Number(exp) }),
849 ...(iat && { iat: Number(iat) }),
850 };
851 } catch (e) {
852 this.logger.error(_scope, 'failed to parse json', { error: e, response: common.gotResponseLogData(response) });
853 throw e;
854 }
855 } catch (e) {
856 this.logger.error(_scope, 'introspect token request failed', { error: e, url: introspectionUrlObj.href });
857 throw e;
858 }
859 }
860
861
862 /**
863 * Attempt to deliver a ticket to an endpoint.
864 * N.B. does not absorb errors
865 * @param {URL} ticketEndpointUrlObj
866 * @param {URL} resourceUrlObj
867 * @param {URL} subjectUrlObj
868 * @param {String} ticket
869 * @returns {Promise<Response>}
870 */
871 async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) {
872 const _scope = _fileScope('deliverTicket');
873
874 try {
875 const ticketConfig = {
876 method: 'POST',
877 url: ticketEndpointUrlObj,
878 form: {
879 ticket,
880 resource: resourceUrlObj.href,
881 subject: subjectUrlObj.href,
882 },
883 };
884 return await this.got(ticketConfig);
885 } catch (e) {
886 this.logger.error(_scope, 'ticket delivery request failed', { error: e, url: ticketEndpointUrlObj.href });
887 throw e;
888 }
889 }
890
891 }
892
893 module.exports = Communication;