ce2bc4e25b8bc05b281b86f970cd1e152b30c35c
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');
18 const _fileScope
= common
.fileScope(__filename
);
20 const noDotPathRE
= /(\/\.\/|\/\.\.\/)/;
21 const v6HostRE
= /\[[0-9a-f:]+\]/;
22 const loopback4
= new Address4('127.0.0.0/8');
23 const scopeSplitRE
= / +/;
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
34 constructor(logger
, options
= {}) {
36 this.options
= options
;
37 this.axios
= axios
.create({
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',
43 this.axios
.interceptors
.request
.use((request
) => {
44 request
.startTimestampMs
= performance
.now();
47 this.axios
.interceptors
.response
.use((response
) => {
48 response
.elapsedTimeMs
= performance
.now() - response
.config
.startTimestampMs
;
55 * Encode hashed verifier data for PKCE.
56 * @param {BinaryLike} verifier
59 static _challengeFromVerifier(verifier
) {
60 const hash
= createHash('sha256');
61 hash
.update(verifier
);
62 return hash
.digest('base64url');
68 * @property {String} codeChallengeMethod
69 * @property {String} codeVerifier
70 * @property {String} codeChallenge
73 * Create a code verifier and its challenge.
74 * @param {Number} length of verifier string, between 43 and 128
75 * @returns {Promise<PKCEData>}
77 static async
generatePKCE(length
= 128) {
78 if (length
< 43 || length
> 128) {
79 throw new RangeError('InvalidLength');
82 const bufferLength
= Math
.floor(length
* 3 / 4);
83 const randomBuffer
= await
randomBytesAsync(bufferLength
);
84 const verifier
= randomBuffer
.toString('base64url');
86 const challenge
= Communication
._challengeFromVerifier(verifier
);
89 codeChallengeMethod: 'S256',
90 codeVerifier: verifier
,
91 codeChallenge: challenge
,
97 * Check a challenge with a verifier.
98 * @param {String} codeChallenge
99 * @param {String} codeVerifier
100 * @param {String} codeChallengeMethod
103 static verifyChallenge(codeChallenge
, codeVerifier
, codeChallengeMethod
) {
104 switch (codeChallengeMethod
) {
107 const challenge
= Communication
._challengeFromVerifier(codeVerifier
);
108 return challenge
=== codeChallenge
;
112 throw new Error('unsupported challenge method');
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
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})`;
134 return `${product}/${version}${implementation}`;
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
145 static _validateStatus(status
) {
146 return (status
>= 200 && status
< 300) || status
== 401;
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
159 static _axiosConfig(method
, urlObj
, body
, params
= {}, headers
= {}) {
162 url: `${urlObj.origin}${urlObj.pathname}`,
163 params: urlObj
.searchParams
,
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
],
171 validateStatus: Communication
._validateStatus
,
173 Object
.entries(params
).map(([k
, v
]) => config
.params
.set(k
, v
));
179 * Isolate the base of a url.
180 * mf2 parser needs this so that relative links can be made absolute.
181 * @param {URL} urlObj
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);
195 * Convert a Content-Type string to normalized components.
197 * N.B. this ill-named non-parsing implementation will not work
198 * if a parameter value for some reason includes a ; or = within
200 * @param {String} contentTypeHeader
201 * @returns {Object} contentType
202 * @returns {String} contentType.mediaType
203 * @returns {Object} contentType.params
205 static _parseContentType(contentTypeHeader
, defaultContentType
= Enum
.ContentType
.ApplicationOctetStream
) {
206 const [ mediaType
, ...params
] = (contentTypeHeader
|| '').split(/ *; */
);
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
;
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
225 _mergeLinkHeader(microformat
, response
) {
226 const _scope
= _fileScope('_mergeLinkHeader');
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
234 if (!('items' in microformat
)) {
235 microformat
.items
= [];
238 const linkHeader
= response
.headers
[Enum
.Header
.Link
.toLowerCase()];
242 links
.push(...parseLinkHeader(linkHeader
));
244 this.logger
.error(_scope
, 'failed to parse link header', { error: e
, linkHeader
});
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
] = [];
257 microformat
.rels
[attr
.value
].unshift(link
.target
);
259 if (!(link
.target
in microformat
['rel-urls'])) {
260 microformat
['rel-urls'][link
.target
] = {
265 microformat
['rel-urls'][link
.target
].rels
.unshift(attr
.value
);
273 * Retrieve and parse microformat data from url.
274 * N.B. this absorbs any errors!
275 * @param {URL} urlObj
276 * @returns {Promise<Object>}
278 async
fetchMicroformat(urlObj
) {
279 const _scope
= _fileScope('fetchMicroformat');
280 const logInfoData
= {
282 microformat: undefined,
287 const fetchMicroformatConfig
= Communication
._axiosConfig('GET', urlObj
);
288 response
= await
this.axios(fetchMicroformatConfig
);
290 this.logger
.error(_scope
, 'microformat request failed', { error: e
, ...logInfoData
});
293 logInfoData
.response
= common
.axiosResponseLogData(response
);
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
) {
301 const iconv
= new Iconv(nonUTF8Charset
, 'utf-8//translit//ignore');
302 body
= iconv
.convert(body
).toString('utf8');
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...
310 let microformat
= {};
312 microformat
= mf2(body
, {
313 baseUrl: Communication
._baseUrlString(urlObj
),
316 this.logger
.error(_scope
, 'failed to parse microformat data', { error: e
, ...logInfoData
});
317 // Try to carry on, maybe there are link headers...
320 this._mergeLinkHeader(microformat
, response
);
322 logInfoData
.microformat
= microformat
;
324 this.logger
.debug(_scope
, 'parsed microformat data', logInfoData
);
330 * Retrieve and parse JSON.
331 * N.B. this absorbs any errors!
332 * @param {URL} urlObj
333 * @returns {Promise<Object>}
335 async
fetchJSON(urlObj
) {
336 const _scope
= _fileScope('fetchJSON');
337 const logInfoData
= {
343 const fetchJSONConfig
= Communication
._axiosConfig('GET', urlObj
, undefined, undefined, {
344 [Enum
.Header
.Accept
]: [Enum
.ContentType
.ApplicationJson
, Enum
.ContentType
.Any
+ ';q=0.1'].join(', '),
346 response
= await
this.axios(fetchJSONConfig
);
348 this.logger
.error(_scope
, 'json request failed', { error: e
, ...logInfoData
});
351 logInfoData
.response
= common
.axiosResponseLogData(response
);
355 data
= JSON
.parse(response
.data
);
357 this.logger
.error(_scope
, 'json parsing failed', { error: e
, ...logInfoData
});
365 * Validate a url has a specific schema.
366 * @param {URL} urlObj
367 * @param {String[]} validSchemes
369 static _urlValidScheme(urlObj
, validSchemes
= ['http:', 'https:']) {
370 if (!validSchemes
.includes(urlObj
.protocol
)) {
371 throw new ValidationError(`unsupported url scheme '${urlObj.protocol}'`);
377 * Validate a url does not include some components.
378 * @param {URL} urlObj
379 * @param {String[]} disallowed
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)}`);
391 * Validate a url does not have relative path.
392 * @param {String} url
394 static _urlPathNoDots(url
) {
395 if (noDotPathRE
.test(url
)) {
396 throw new ValidationError('relative path segment not valid');
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>}
408 static async
_urlNamedHost(urlObj
, allowLoopback
, resolveHostname
) {
410 if (v6HostRE
.test(urlObj
.hostname
)) {
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.
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();
421 address
= new Address4(urlObj
.hostname
);
422 /* succeeded parsing as ipv4, reject unless loopback */
423 urlObj
.isLoopback
= address
.isInSubnet(loopback4
);
425 /* did not parse as ip, carry on */
429 if (resolveHostname
&& !urlObj
.isLoopback
) {
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.
437 const hostnames
= [urlObj
.hostname
];
438 if (!urlObj
.hostname
.endsWith('.')) {
439 hostnames
.push(urlObj
.hostname
+ '.');
441 const settledResolutions
= await Promise
.allSettled(hostnames
.map((hostname
) => dns
.promises
.lookup(hostname
, {
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');
452 // extract each resolution value, array of {address,family}
453 const resolutions
= settledResolutions
.map((resolution
) => resolution
.value
);
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
));
461 const differences
= common
.setSymmetricDifference(...addressSets
);
462 if (differences
.size
) {
463 throw new ValidationError('inconsistent hostname resolution');
466 const resolvedHost
= resolutions
[0] || [];
468 // Persist the loopback state
469 urlObj
.isLoopback
= resolvedHost
.reduce((acc
, resolved
) => {
471 switch (resolved
.family
) {
473 addr
= new Address4(resolved
.address
);
474 return acc
|| addr
.isInSubnet(loopback4
);
476 addr
= new Address6(resolved
.address
);
477 return acc
|| addr
.isLoopback();
485 && (!urlObj
.isLoopback
|| !allowLoopback
)) {
486 throw new ValidationError('hostname cannot be IP');
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>}
499 async
validateProfile(url
, validationOptions
) {
500 const _scope
= _fileScope('validateProfile');
501 const errorScope
= 'invalid profile url';
503 const options
= Object
.assign({
504 allowLoopback: false,
505 resolveHostname: false,
506 }, validationOptions
);
510 profile
= new URL(url
);
512 this.logger
.debug(_scope
, 'failed to parse url', { error: e
, url
});
513 throw new ValidationError(`${errorScope}: unparsable`);
515 profile
.isLoopback
= false;
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
);
523 this.logger
.debug(_scope
, 'profile url not valid', { url
, error: e
});
524 throw new ValidationError(`${errorScope}: ${e.message}`);
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>}
540 async
validateClientIdentifier(url
, validationOptions
) {
541 const _scope
= _fileScope('validateClientIdentifier');
542 const errorScope
= 'invalid client identifier url';
544 const options
= Object
.assign({
546 resolveHostname: true,
547 }, validationOptions
);
551 clientId
= new URL(url
);
553 this.logger
.debug(_scope
, 'failed to parse url', { error: e
, url
});
554 throw new ValidationError('invalid client identifier url: unparsable');
556 clientId
.isLoopback
= false;
559 Communication
._urlValidScheme(clientId
);
560 Communication
._urlPartsDisallowed(clientId
, ['hash', 'username', 'password']);
561 Communication
._urlPathNoDots(url
);
562 await Communication
._urlNamedHost(clientId
, options
.allowLoopback
, options
.resolveHostname
);
564 this.logger
.debug(_scope
, 'client identifier url not valid', { url
, error: e
});
565 throw new ValidationError(`${errorScope}: ${e.message}`);
573 * @typedef {Object} ClientIdentifierData
574 * @property {Object} rels - keyed by relation to array of uris
575 * @property {HAppData[]} items
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
583 async
fetchClientIdentifier(urlObj
) {
584 const _scope
= _fileScope('fetchClientIdentifier');
586 // Loopback address will eschew client fetch, return empty data.
587 const isLoopbackResult
= {
592 // Set by validation method in case of loopback ip hostname
593 if (urlObj
.isLoopback
) {
594 return isLoopbackResult
;
597 const mfData
= await
this.fetchMicroformat(urlObj
);
602 // Only return h-app items with matching url field.
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
) => {
612 const hUrl
= new URL(url
);
613 if (hUrl
.href
=== urlObj
.href
) {
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
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
657 async
fetchProfile(urlObj
) {
658 const _scope
= _fileScope('fetchProfile');
660 const mfData
= await
this.fetchMicroformat(urlObj
);
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
684 // Populate legacy mf2 fields from relation links.
685 // These will be overwritten if they also exist in server metadata.
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
696 // Set metadata field.
697 if (mfData
&& 'indieauth-metadata' in mfData
.rels
) {
698 profile
.indieauthMetadata
= mfData
.rels
['indieauth-metadata'][0];
701 // Attempt to populate metadata from authorization server.
702 if (profile
.indieauthMetadata
) {
705 mdURL
= new URL(profile
.indieauthMetadata
);
706 } catch (e
) /* istanbul ignore next */ {
707 this.logger
.error(_scope
, 'invalid authorization server metadata url', { profile
});
709 /* istanbul ignore else */
711 const metadataResponse
= await
this.fetchJSON(mdURL
);
712 if (metadataResponse
) {
713 // Map snake_case fields to camelCase.
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
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
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
760 async
redeemCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
) {
761 const _scope
= _fileScope('redeemCode');
763 const formData
= common
.formData({
764 'grant_type': 'authorization_code',
766 'client_id': clientId
,
767 'redirect_uri': redirectURI
,
768 'code_verifier': codeVerifier
,
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`,
777 const response
= await
this.axios(postRedeemCodeConfig
);
779 return JSON
.parse(response
.data
);
781 this.logger
.error(_scope
, 'failed to parse json', { error: e
, response
});
785 this.logger
.error(_scope
, 'redeem code request failed', { error: e
, url: urlObj
.href
});
792 * Deprecated method name.
794 * @param {URL} urlObj
795 * @param {String} code
796 * @param {Strin} codeVerifier
797 * @param {String} clientId
798 * @param {String} redirectURI
801 async
redeemProfileCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
) {
802 return this.redeemCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
);
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
812 async
introspectToken(introspectionUrlObj
, authorizationHeader
, token
) {
813 const _scope
= _fileScope('introspectToken');
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`,
821 delete postIntrospectConfig
.validateStatus
; // only accept success
825 const response
= await
this.axios(postIntrospectConfig
);
826 this.logger
.debug(_scope
, 'response', { response
});
829 tokenInfo
= JSON
.parse(response
.data
);
842 ...(clientId
&& { clientId
}),
843 ...(scope
&& { scope: scope
.split(scopeSplitRE
) }),
844 ...(exp
&& { exp: Number(exp
) }),
845 ...(iat
&& { iat: Number(iat
) }),
848 this.logger
.error(_scope
, 'failed to parse json', { error: e
, response
});
852 this.logger
.error(_scope
, 'introspect token request failed', { error: e
, url: introspectionUrlObj
.href
});
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>}
867 async
deliverTicket(ticketEndpointUrlObj
, resourceUrlObj
, subjectUrlObj
, ticket
) {
868 const _scope
= _fileScope('deliverTicket');
871 const ticketPayload
= {
873 resource: resourceUrlObj
.href
,
874 subject: subjectUrlObj
.href
,
876 const ticketConfig
= Communication
._axiosConfig('POST', ticketEndpointUrlObj
, ticketPayload
, {}, {
877 [Enum
.Header
.ContentType
]: Enum
.ContentType
.ApplicationForm
,
879 return await
this.axios(ticketConfig
);
881 this.logger
.error(_scope
, 'ticket delivery request failed', { error: e
, url: ticketEndpointUrlObj
.href
});
888 module
.exports
= Communication
;