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