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