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