3 const { mf2
} = require('microformats-parser');
4 const { parse: parseLinkHeader
} = require('@squeep/web-linking');
5 const { Iconv
} = require('iconv');
6 const { version: packageVersion
, name: packageName
} = require('../package.json');
7 const { randomBytes
, createHash
} = require('crypto');
8 const { promisify
} = require('util');
9 const randomBytesAsync
= promisify(randomBytes
);
10 const { Address4
, Address6
} = require('ip-address');
11 const dns
= require('dns');
12 const common
= require('./common');
13 const Enum
= require('./enum');
14 const { Microformat2: { Relation: MF2Rel
} } = Enum
;
15 const { ValidationError
} = require('./errors');
16 const { fileScope
} = require('@squeep/log-helper');
18 const _fileScope
= fileScope(__filename
);
20 const noDotPathRE
= /(\/\.\/|\/\.\.\/)/;
21 const v6HostRE
= /\[[0-9a-f:]+\]/;
22 const loopback4
= new Address4('127.0.0.0/8');
23 const scopeSplitRE
= / +/;
24 const utf8CharsetRE
= /utf-*8/i;
28 * @param {Console} logger
29 * @param {Object} options
30 * @param {Number=} options.timeout
31 * @param {Object=} options.userAgent
32 * @param {String=} options.userAgent.product
33 * @param {String=} options.userAgent.version
34 * @param {String=} options.userAgent.implementation
36 constructor(logger
, options
= {}) {
38 this.options
= options
;
40 this._defaultAccept
= options
?.defaultAccept
|| 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1';
41 this._jsonAccept
= options
?.jsonAccept
|| [Enum
.ContentType
.ApplicationJson
, Enum
.ContentType
.Any
+ ';q=0.1'].join(', ');
44 this.got
= this._init
; // Do the dynamic import on first attempt to use client.
49 * Do a little dance to support this ESM client.
51 async
_init(...args
) {
53 // For some reason eslint is confused about import being supported here.
54 // eslint-disable-next-line
55 this.Got
= await
import('got');
56 this.got
= this.Got
.got
.extend({
58 [Enum
.Header
.UserAgent
]: Communication
._userAgentString(this.options
.userAgent
),
59 [Enum
.Header
.Accept
]: this._defaultAccept
,
62 request: this.options
.timeout
|| 120000,
66 this._onRetry
.bind(this),
72 return this.got(...args
);
78 * Take notes on transient retries.
80 * @param {*} retryCount
82 _onRetry(error
, retryCount
) {
83 const _scope
= _fileScope('_onRetry');
84 this.logger
.debug(_scope
, 'retry', { retryCount
, error
});
89 * Encode hashed verifier data for PKCE.
90 * @param {BinaryLike} verifier
93 static _challengeFromVerifier(verifier
) {
94 const hash
= createHash('sha256');
95 hash
.update(verifier
);
96 return hash
.digest('base64url');
102 * @property {String} codeChallengeMethod
103 * @property {String} codeVerifier
104 * @property {String} codeChallenge
107 * Create a code verifier and its challenge.
108 * @param {Number} length of verifier string, between 43 and 128
109 * @returns {Promise<PKCEData>}
111 static async
generatePKCE(length
= 128) {
112 if (length
< 43 || length
> 128) {
113 throw new RangeError('InvalidLength');
116 const bufferLength
= Math
.floor(length
* 3 / 4);
117 const randomBuffer
= await
randomBytesAsync(bufferLength
);
118 const verifier
= randomBuffer
.toString('base64url');
120 const challenge
= Communication
._challengeFromVerifier(verifier
);
123 codeChallengeMethod: 'S256',
124 codeVerifier: verifier
,
125 codeChallenge: challenge
,
131 * Check a challenge with a verifier.
132 * @param {String} codeChallenge
133 * @param {String} codeVerifier
134 * @param {String} codeChallengeMethod
137 static verifyChallenge(codeChallenge
, codeVerifier
, codeChallengeMethod
) {
138 switch (codeChallengeMethod
) {
141 const challenge
= Communication
._challengeFromVerifier(codeVerifier
);
142 return challenge
=== codeChallenge
;
146 throw new Error('unsupported challenge method');
152 * Assemble a suitable User-Agent value.
153 * @param {Object} userAgentConfig
154 * @param {String=} userAgentConfig.product
155 * @param {String=} userAgentConfig.version
156 * @param {String=} userAgentConfig.implementation
159 static _userAgentString(userAgentConfig
) {
160 // eslint-disable-next-line security/detect-object-injection
161 const _conf
= (field
, def
) => (userAgentConfig
&& field
in userAgentConfig
) ? userAgentConfig
[field
] : def
;
162 const product
= _conf('product', packageName
).split('/').pop();
163 const version
= _conf('version', packageVersion
);
164 let implementation
= _conf('implementation', Enum
.Specification
);
165 if (implementation
) {
166 implementation
= ` (${implementation})`;
168 return `${product}/${version}${implementation}`;
173 * Isolate the base of a url.
174 * mf2 parser needs this so that relative links can be made absolute.
175 * @param {URL} urlObj
178 static _baseUrlString(urlObj
) {
179 const baseUrl
= new URL(urlObj
);
180 const lastSlashIdx
= baseUrl
.pathname
.lastIndexOf('/');
181 if (lastSlashIdx
> 0) {
182 baseUrl
.pathname
= baseUrl
.pathname
.slice(0, lastSlashIdx
+ 1);
189 * Convert a Content-Type string to normalized components.
191 * N.B. this ill-named non-parsing implementation will not work
192 * if a parameter value for some reason includes a ; or = within
194 * @param {String} contentTypeHeader
195 * @returns {Object} contentType
196 * @returns {String} contentType.mediaType
197 * @returns {Object} contentType.params
199 static _parseContentType(contentTypeHeader
, defaultContentType
= Enum
.ContentType
.ApplicationOctetStream
) {
200 const [ mediaType
, ...params
] = (contentTypeHeader
|| '').split(/ *; */
);
202 mediaType: mediaType
.toLowerCase() || defaultContentType
,
203 params: params
.reduce((obj
, param
) => {
204 const [field
, value
] = param
.split('=');
205 const isQuoted
= value
?.startsWith('"') && value
?.endsWith('"');
206 obj
[field
.toLowerCase()] = isQuoted
? value
.slice(1, value
.length
- 1) : value
;
214 * Parse and add any header link relations from response to microformat data.
215 * @param {Object} microformat
216 * @param {Object} response
217 * @param {Object} response.headers
219 _mergeLinkHeader(microformat
, response
) {
220 const _scope
= _fileScope('_mergeLinkHeader');
222 // Establish that microformat has expected structure
223 ['rels', 'rel-urls'].forEach((p
) => {
224 if (!(p
in microformat
)) {
225 microformat
[p
] = {}; // eslint-disable-line security/detect-object-injection
228 if (!('items' in microformat
)) {
229 microformat
.items
= [];
232 const linkHeader
= response
.headers
[Enum
.Header
.Link
.toLowerCase()];
236 links
.push(...parseLinkHeader(linkHeader
));
238 this.logger
.error(_scope
, 'failed to parse link header', { error: e
, linkHeader
});
243 // Push header link rels into microformat form.
244 // Inserted at front of lists, as headers take precedence.
245 links
.forEach((link
) => {
246 link
.attributes
.forEach((attr
) => {
247 if (attr
.name
=== 'rel') {
248 if (!(attr
.value
in microformat
.rels
)) {
249 microformat
.rels
[attr
.value
] = [];
251 microformat
.rels
[attr
.value
].unshift(link
.target
);
253 if (!(link
.target
in microformat
['rel-urls'])) {
254 microformat
['rel-urls'][link
.target
] = {
259 microformat
['rel-urls'][link
.target
].rels
.unshift(attr
.value
);
267 * Retrieve and parse microformat data from url.
268 * N.B. this absorbs any errors!
269 * @param {URL} urlObj
270 * @returns {Promise<Object>}
272 async
fetchMicroformat(urlObj
) {
273 const _scope
= _fileScope('fetchMicroformat');
274 const logInfoData
= {
276 microformat: undefined,
281 const fetchMicroformatConfig
= {
284 responseType: 'buffer',
286 response
= await
this.got(fetchMicroformatConfig
);
288 this.logger
.error(_scope
, 'microformat request failed', { error: e
, ...logInfoData
});
291 logInfoData
.response
= common
.gotResponseLogData(response
);
293 // Normalize to utf8.
295 const contentType
= Communication
._parseContentType(response
.headers
[Enum
.Header
.ContentType
.toLowerCase()]);
296 // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8.
297 const nonUTF8Charset
= !utf8CharsetRE
.test(contentType
.params
.charset
) && contentType
.params
.charset
;
298 if (nonUTF8Charset
) {
300 const iconv
= new Iconv(nonUTF8Charset
, 'utf-8//translit//ignore');
301 body
= iconv
.convert(response
.body
).toString('utf8');
303 // istanbul ignore next
304 this.logger
.error(_scope
, 'iconv conversion error', { error: e
, ...logInfoData
});
305 // Try to carry on, maybe the encoding will work anyhow...
308 body
= response
.body
.toString('utf8');
311 let microformat
= {};
313 microformat
= mf2(body
, {
314 baseUrl: Communication
._baseUrlString(urlObj
),
317 this.logger
.error(_scope
, 'failed to parse microformat data', { error: e
, ...logInfoData
});
318 // Try to carry on, maybe there are link headers...
321 this._mergeLinkHeader(microformat
, response
);
323 logInfoData
.microformat
= microformat
;
325 this.logger
.debug(_scope
, 'parsed microformat data', logInfoData
);
331 * Retrieve and parse JSON.
332 * N.B. this absorbs any errors!
333 * @param {URL} urlObj
334 * @returns {Promise<Object>}
336 async
fetchJSON(urlObj
) {
337 const _scope
= _fileScope('fetchJSON');
338 const logInfoData
= {
344 const fetchJSONConfig
= {
348 [Enum
.Header
.Accept
]: this._jsonAccept
,
350 responseType: 'json',
352 response
= await
this.got(fetchJSONConfig
);
354 this.logger
.error(_scope
, 'json request failed', { error: e
, ...logInfoData
});
358 return response
.body
;
363 * Validate a url has a specific schema.
364 * @param {URL} urlObj
365 * @param {String[]} validSchemes
367 static _urlValidScheme(urlObj
, validSchemes
= ['http:', 'https:']) {
368 if (!validSchemes
.includes(urlObj
.protocol
)) {
369 throw new ValidationError(`unsupported url scheme '${urlObj.protocol}'`);
375 * Validate a url does not include some components.
376 * @param {URL} urlObj
377 * @param {String[]} disallowed
379 static _urlPartsDisallowed(urlObj
, disallowed
) {
380 disallowed
.forEach((part
) => {
381 if (urlObj
[part
]) { // eslint-disable-line security/detect-object-injection
382 throw new ValidationError(`url cannot contain ${common.properURLComponentName(part)}`);
389 * Validate a url does not have relative path.
390 * @param {String} url
392 static _urlPathNoDots(url
) {
393 if (noDotPathRE
.test(url
)) {
394 throw new ValidationError('relative path segment not valid');
400 * Validate a url does not have a hostname which is an ip address.
401 * N.B. Sets isLoopback on urlObj
402 * @param {URL} urlObj
403 * @param {Boolean} allowLoopback
404 * @param {Boolean} resolveHostname
405 * @returns {Promise<void>}
407 static async
_urlNamedHost(urlObj
, allowLoopback
, resolveHostname
) {
409 if (v6HostRE
.test(urlObj
.hostname
)) {
411 * We do not need to worry about the Address6() failing to parse,
412 * as if it looks like an ipv6 addr but is not valid, the URL()
413 * call would already have failed.
415 address
= new Address6(urlObj
.hostname
.slice(1, urlObj
.hostname
.length
- 1));
416 /* Succeeded parsing as ipv6, reject unless loopback */
417 urlObj
.isLoopback
= address
.isLoopback();
420 address
= new Address4(urlObj
.hostname
);
421 /* Succeeded parsing as ipv4, reject unless loopback */
422 urlObj
.isLoopback
= address
.isInSubnet(loopback4
);
424 /* Did not parse as ip, carry on */
428 if (resolveHostname
&& !urlObj
.isLoopback
) {
430 * Resolve hostname to check for localhost.
431 * This is more complicated due to SSRF mitigation:
432 * If the hostname does not end with a ., we also resolve that,
433 * and complain if the two resolutions do not match, assuming
434 * malicious intent for the server to resolve a local record.
436 const hostnames
= [urlObj
.hostname
];
437 if (!urlObj
.hostname
.endsWith('.')) {
438 hostnames
.push(urlObj
.hostname
+ '.');
440 const settledResolutions
= await Promise
.allSettled(hostnames
.map((hostname
) => dns
.promises
.lookup(hostname
, {
444 // If any resolution failed, bail.
445 if (settledResolutions
446 .map((resolution
) => resolution
.status
)
447 .includes('rejected')) {
448 throw new ValidationError('could not resolve hostname');
451 // Extract each resolution value, array of {address,family}
452 const resolutions
= settledResolutions
.map((resolution
) => resolution
.value
);
454 // If there were two resolutions, ensure they returned identical results.
455 if (resolutions
.length
> 1) {
456 // Create set of addresses for each resolution
457 const addressSets
= resolutions
.map((addrs
) => {
458 return new Set((addrs
|| []).map((a
) => a
.address
));
460 const differences
= common
.setSymmetricDifference(...addressSets
);
461 if (differences
.size
) {
462 throw new ValidationError('inconsistent hostname resolution');
465 const resolvedHost
= resolutions
[0] || [];
467 // Persist the loopback state
468 urlObj
.isLoopback
= resolvedHost
.reduce((acc
, resolved
) => {
470 switch (resolved
.family
) {
472 addr
= new Address4(resolved
.address
);
473 return acc
|| addr
.isInSubnet(loopback4
);
475 addr
= new Address6(resolved
.address
);
476 return acc
|| addr
.isLoopback();
484 && (!urlObj
.isLoopback
|| !allowLoopback
)) {
485 throw new ValidationError('hostname cannot be IP');
491 * Ensure a url meets the requirements to be a profile uri.
492 * @param {String} url
493 * @param {Object} validationOptions
494 * @param {Boolean=} validationOptions.allowLoopback default is false, following spec
495 * @param {Boolean=} validationOptions.resolveHostname default is false, following spec
496 * @returns {Promise<URL>}
498 async
validateProfile(url
, validationOptions
) {
499 const _scope
= _fileScope('validateProfile');
500 const errorScope
= 'invalid profile url';
503 allowLoopback: false,
504 resolveHostname: false,
505 ...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 default is true, following spec
537 * @param {Boolean=} validationOptions.resolveHostname default is true, following spec
538 * @returns {Promise<URL>}
540 async
validateClientIdentifier(url
, validationOptions
) {
541 const _scope
= _fileScope('validateClientIdentifier');
542 const errorScope
= 'invalid client identifier url';
546 resolveHostname: true,
547 ...validationOptions
,
552 clientId
= new URL(url
);
554 this.logger
.debug(_scope
, 'failed to parse url', { error: e
, url
});
555 throw new ValidationError('invalid client identifier url: unparsable');
557 clientId
.isLoopback
= false;
560 Communication
._urlValidScheme(clientId
);
561 Communication
._urlPartsDisallowed(clientId
, ['hash', 'username', 'password']);
562 Communication
._urlPathNoDots(url
);
563 await Communication
._urlNamedHost(clientId
, options
.allowLoopback
, options
.resolveHostname
);
565 this.logger
.debug(_scope
, 'client identifier url not valid', { url
, error: e
});
566 throw new ValidationError(`${errorScope}: ${e.message}`);
574 * @typedef {Object} ClientIdentifierData
575 * @property {Object} rels - keyed by relation to array of uris
576 * @property {HAppData[]} items
579 * Retrieve and parse client identifier endpoint data.
580 * N.B. Assumes urlObj has passed validateClientIdentifier.
581 * @param {URL} urlObj
582 * @returns {Promise<ClientIdentifierData|undefined>} mf2 data filtered for h-app items, or undefined if url could not be fetched
584 async
fetchClientIdentifier(urlObj
) {
585 // Set by validation method in case of loopback ip hostname
586 if (urlObj
.isLoopback
) {
587 // Loopback address will eschew client fetch, return empty data.
594 const mfData
= await
this.fetchMicroformat(urlObj
);
599 // Only return h-app items with matching url field.
601 rels: mfData
.rels
|| {},
602 items: (mfData
.items
|| []).filter((item
) => {
603 let urlMatched
= false;
604 const itemType
= item
.type
|| [];
605 if ((itemType
.includes('h-app') || itemType
.includes('h-x-app'))
606 && (item
?.properties
?.url
)) {
607 item
.properties
.url
.forEach((url
) => {
609 const hUrl
= new URL(url
);
610 if (hUrl
.href
=== urlObj
.href
) {
623 * @typedef {Object} Metadata
624 * @property {String} issuer
625 * @property {String} authorizationEndpoint
626 * @property {String} tokenEndpoint
627 * @property {String} ticketEndpoint
628 * @property {String} introspectionEndpoint
629 * @property {String} introspectionEndpointAuthMethodsSupported
630 * @property {String} revocationEndpoint
631 * @property {String} revocationEndpointAuthMethodsSupported
632 * @property {String} scopesSupported
633 * @property {String} responseTypesSupported
634 * @property {String} grantTypesSupported
635 * @property {String} serviceDocumentation
636 * @property {String} codeChallengeMethodsSupported
637 * @property {String} authorizationResponseIssParameterSupported
638 * @property {String} userinfoEndpoint
641 * @typedef ProfileData
642 * @property {String} name
643 * @property {String} photo
644 * @property {String} url
645 * @property {String} email
646 * @property {String} authorizationEndpoint - deprecated, backwards compatibility for 20201126 spec
647 * @property {String} tokenEndpoint - deprecated, backwards compatibility for 20201126 spec
648 * @property {String} indieauthMetadata authorization server metadata endpoint
649 * @property {Metadata} metadata - authorization server metadata for profile
652 * Fetch the relevant microformat data from profile url h-card information,
653 * and authorization server metadata.
654 * N.B. Assumes urlObj has passed validateProfile
655 * @param {URL} urlObj
656 * @returns {Promise<ProfileData>} mf2 data filtered for select fields from h-card
658 async
fetchProfile(urlObj
) {
659 const _scope
= _fileScope('fetchProfile');
661 const mfData
= await
this.fetchMicroformat(urlObj
);
671 * Locate h-card mf2 items with url field matching profile url,
672 * and populate profile fields with first-encountered card values.
674 if (mfData
&& 'items' in mfData
) {
675 const hCards
= mfData
.items
.filter((item
) =>
676 item
?.type
?.includes('h-card') &&
677 item
?.properties
?.url
?.includes(urlObj
.href
));
678 hCards
.forEach((hCard
) => {
679 Object
.keys(profile
).forEach((key
) => {
680 if (!profile
[key
] && key
in hCard
.properties
) { // eslint-disable-line security/detect-object-injection
681 profile
[key
] = hCard
.properties
[key
][0]; // eslint-disable-line security/detect-object-injection
687 // Populate legacy mf2 fields from relation links.
688 // These will be overwritten if they also exist in server metadata.
690 authorizationEndpoint: MF2Rel
.AuthorizationEndpoint
, // Backwards compatibility
691 tokenEndpoint: MF2Rel
.TokenEndpoint
, // Backwards compatibility
692 ticketEndpoint: MF2Rel
.TicketEndpoint
, // Backwards compatibility
693 }).forEach(([p
, r
]) => {
694 if (mfData
&& r
in mfData
.rels
) {
695 profile
.metadata
[p
] = profile
[p
] = mfData
.rels
[r
][0]; // eslint-disable-line security/detect-object-injection
699 // Set metadata field.
700 if (mfData
&& MF2Rel
.IndieauthMetadata
in mfData
.rels
) {
701 profile
.indieauthMetadata
= mfData
.rels
[MF2Rel
.IndieauthMetadata
][0];
704 // Attempt to populate metadata from authorization server.
705 if (profile
.indieauthMetadata
) {
708 mdURL
= new URL(profile
.indieauthMetadata
);
709 } catch (e
) /* istanbul ignore next */ {
710 this.logger
.error(_scope
, 'invalid authorization server metadata url', { profile
});
712 /* istanbul ignore else */
714 profile
.metadata
= await
this.fetchMetadata(mdURL
);
716 // Populate legacy profile fields.
717 ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f
) => {
718 if (f
in profile
.metadata
) {
719 profile
[f
] = profile
.metadata
[f
]; // eslint-disable-line security/detect-object-injection
730 * Fetch the server metadata from an authorization server's metadata endpoint.
731 * @param {URL} metadataUrl
732 * @returns {Promise<Metadata>}
734 async
fetchMetadata(metadataUrl
) {
735 const metadataResponse
= await
this.fetchJSON(metadataUrl
);
737 if (metadataResponse
) {
738 // Map snake_case fields to camelCase.
741 authorizationEndpoint: 'authorization_endpoint',
742 tokenEndpoint: 'token_endpoint',
743 ticketEndpoint: 'ticket_endpoint',
744 introspectionEndpoint: 'introspection_endpoint',
745 introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
746 revocationEndpoint: 'revocation_endpoint',
747 revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
748 scopesSupported: 'scopes_supported',
749 responseTypesSupported: 'response_types_supported',
750 grantTypesSupported: 'grant_types_supported',
751 serviceDocumentation: 'service_documentation',
752 codeChallengeMethodsSupported: 'code_challenge_methods_supported',
753 authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
754 userinfoEndpoint: 'userinfo_endpoint',
755 }).forEach(([c
, s
]) => {
756 if (s
in metadataResponse
) {
757 metadata
[c
] = metadataResponse
[s
]; // eslint-disable-line security/detect-object-injection
767 * POST to the auth endpoint, to redeem a code for a profile or token.
768 * N.B. this absorbs any errors!
769 * @param {URL} urlObj
770 * @param {String} code
771 * @param {String} codeVerifier
772 * @param {String} clientId
773 * @param {String} redirectURI
774 * @returns {Promise<Object>}
776 async
redeemCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
) {
777 const _scope
= _fileScope('redeemCode');
779 const postRedeemCodeConfig
= {
783 [Enum
.Header
.Accept
]: this._jsonAccept
,
786 'grant_type': 'authorization_code',
788 'client_id': clientId
,
789 'redirect_uri': redirectURI
,
790 'code_verifier': codeVerifier
,
792 responseType: 'json',
796 const response
= await
this.got(postRedeemCodeConfig
);
797 return response
.body
;
799 this.logger
.error(_scope
, 'redeem code request failed', { error: e
, url: urlObj
.href
});
806 * Deprecated method name alias.
808 * @param {URL} urlObj
809 * @param {String} code
810 * @param {Strin} codeVerifier
811 * @param {String} clientId
812 * @param {String} redirectURI
813 * @returns {Promise<Object>}
815 async
redeemProfileCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
) {
816 return this.redeemCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
);
821 * Verify a token with an IdP endpoint, using the Authorization header supplied.
822 * @param {URL} introspectionUrlObj
823 * @param {String} authorizationHeader
824 * @param {String} token
825 * @returns {Promise<Object>}
827 async
introspectToken(introspectionUrlObj
, authorizationHeader
, token
) {
828 const _scope
= _fileScope('introspectToken');
830 const postIntrospectConfig
= {
831 url: introspectionUrlObj
,
834 [Enum
.Header
.Authorization
]: authorizationHeader
,
835 [Enum
.Header
.Accept
]: this._jsonAccept
,
840 responseType: 'json',
844 const response
= await
this.got(postIntrospectConfig
);
856 if (![true, false].includes(active
)) {
857 throw new RangeError('missing required response field "active"');
863 ...(clientId
&& { clientId
}),
864 ...(scope
&& { scope: scope
.split(scopeSplitRE
) }),
865 ...(exp
&& { exp: Number(exp
) }),
866 ...(iat
&& { iat: Number(iat
) }),
869 this.logger
.error(_scope
, 'failed to parse json', { error: e
, response: common
.gotResponseLogData(response
) });
873 this.logger
.error(_scope
, 'introspect token request failed', { error: e
, url: introspectionUrlObj
.href
});
880 * Attempt to deliver a ticket to an endpoint.
881 * N.B. does not absorb errors
882 * @param {URL} ticketEndpointUrlObj
883 * @param {URL} resourceUrlObj
884 * @param {URL} subjectUrlObj
885 * @param {URL=} issuerUrlObj
886 * @param {String} ticket
887 * @returns {Promise<Response>}
889 async
deliverTicket(ticketEndpointUrlObj
, resourceUrlObj
, subjectUrlObj
, ticket
, issuerUrlObj
) {
890 const _scope
= _fileScope('deliverTicket');
893 const ticketConfig
= {
895 url: ticketEndpointUrlObj
,
898 resource: resourceUrlObj
.href
,
899 subject: subjectUrlObj
.href
,
900 ...( issuerUrlObj
&& { iss: issuerUrlObj
.href
}),
903 const result
= await
this.got(ticketConfig
);
904 this.logger
.debug(_scope
, 'success', { ...common
.gotResponseLogData(result
) });
907 this.logger
.error(_scope
, 'ticket delivery request failed', { error: e
, url: ticketEndpointUrlObj
.href
});
914 * Attempt to fetch some link relations from a url.
915 * @param {URL} urlObj
916 * @returns {Promise<Object>}
918 async
_fetchMetadataOrTokenEndpoint(urlObj
) {
919 const _scope
= _fileScope('_fetchMetadataOrTokenEndpoint');
921 let metadataUrl
, tokenUrl
;
923 const mfData
= await
this.fetchMicroformat(urlObj
);
924 const metadataRel
= mfData
?.rels
?.[MF2Rel
.IndieauthMetadata
]?.[0];
927 metadataUrl
= new URL(metadataRel
);
929 this.logger
.debug(_scope
, 'invalid metadata rel url', { url: urlObj
.href
, metadataRel
});
933 // No metadata rel, try old-style token endpoint
934 const tokenRel
= mfData
?.rels
?.[MF2Rel
.TokenEndpoint
]?.[0];
937 tokenUrl
= new URL(tokenRel
);
939 this.logger
.debug(_scope
, 'invalid token rel url', { url: urlObj
.href
, tokenRel
});
944 return { metadataUrl
, tokenUrl
};
949 * Attempt to redeem a ticket for a token.
950 * N.B. does not absorb errors
951 * @property {String} ticket
952 * @property {URL} resourceUrlObj
953 * @property {URL=} issuerUrlObj
954 * @returns {Promise<Object>} response body
956 async
redeemTicket(ticket
, resourceUrlObj
, issuerUrlObj
) {
957 const _scope
= _fileScope('redeemTicket');
959 let metadataUrl
, tokenUrl
;
960 // Attempt to determine metadata or token endpoint from issuer MF data
962 ({ metadataUrl
, tokenUrl
} = await
this._fetchMetadataOrTokenEndpoint(issuerUrlObj
));
965 // Fallback to resource MF data
966 if (!metadataUrl
&& !tokenUrl
) {
967 ({ metadataUrl
, tokenUrl
} = await
this._fetchMetadataOrTokenEndpoint(resourceUrlObj
));
971 const metadata
= await
this.fetchMetadata(metadataUrl
);
973 tokenUrl
= new URL(metadata
?.tokenEndpoint
);
975 this.logger
.debug(_scope
, 'invalid token endpoint url from metadata', { resourceUrl: resourceUrlObj
.href
, issuerUrl: issuerUrlObj
.href
, tokenEndpoint: metadata
?.tokenEndpoint
});
980 throw new ValidationError('could not determine endpoint for ticket redemption');
983 const postRedeemTicketConfig
= {
987 [Enum
.Header
.Accept
]: this._jsonAccept
,
990 'grant_type': 'ticket',
993 responseType: 'json',
997 const response
= await
this.got(postRedeemTicketConfig
);
998 return response
.body
;
1000 this.logger
.error(_scope
, 'ticket redemption failed', { error: e
, resource: resourceUrlObj
.href
, issuer: issuerUrlObj
?.href
});
1006 module
.exports
= Communication
;