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