c1c11285ad668ec864aadc69a678ffe3664811b7
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('node:crypto');
8 const { promisify
} = require('node:util');
9 const randomBytesAsync
= promisify(randomBytes
);
10 const { Address4
, Address6
} = require('ip-address');
11 const dns
= require('node: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;
27 * @typedef {object} ConsoleLike
28 * @property {Function } debug debug log function
29 * @property {Function } error error log function
34 * @param {ConsoleLike} logger logger
35 * @param {object} options options
36 * @param {number=} options.timeout request timeout
37 * @param {object=} options.userAgent user agent object
38 * @param {string=} options.userAgent.product user agent product
39 * @param {string=} options.userAgent.version user agent version
40 * @param {string=} options.userAgent.implementation user agent implementation
42 constructor(logger
, options
= {}) {
44 this.options
= options
;
46 this._defaultAccept
= options
?.defaultAccept
|| 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1';
47 this._jsonAccept
= options
?.jsonAccept
|| [Enum
.ContentType
.ApplicationJson
, Enum
.ContentType
.Any
+ ';q=0.1'].join(', ');
50 this.got
= this._init
; // Do the dynamic import on first attempt to use client.
55 * Do a little dance to support this ESM client.
56 * @param {...any} args request args
57 * @returns {Promise<any>} response
59 async
_init(...args
) {
61 // For some reason eslint is confused about import being supported here.
63 this.Got
= await
import('got');
64 this.got
= this.Got
.got
.extend({
66 [Enum
.Header
.UserAgent
]: Communication
._userAgentString(this.options
.userAgent
),
67 [Enum
.Header
.Accept
]: this._defaultAccept
,
70 request: this.options
.timeout
|| 120000,
74 this._onRetry
.bind(this),
80 return this.got(...args
);
86 * Take notes on transient retries.
87 * @param {*} error error
88 * @param {*} retryCount retry count
90 _onRetry(error
, retryCount
) {
91 const _scope
= _fileScope('_onRetry');
92 this.logger
.debug(_scope
, 'retry', { retryCount
, error
});
97 * @typedef {string|Buffer|DataView} BinaryLike
99 * Encode hashed verifier data for PKCE.
100 * @param {BinaryLike} verifier verifier
101 * @returns {string} challenge
103 static _challengeFromVerifier(verifier
) {
104 const hash
= createHash('sha256');
105 hash
.update(verifier
);
106 return hash
.digest('base64url');
112 * @property {string} codeChallengeMethod challenge method
113 * @property {string} codeVerifier code verifier
114 * @property {string} codeChallenge code challenge
117 * Create a code verifier and its challenge.
118 * @param {number} length of verifier string, between 43 and 128
119 * @returns {Promise<PKCEData>} data
121 static async
generatePKCE(length
= 128) {
122 if (length
< 43 || length
> 128) {
123 throw new RangeError('InvalidLength');
126 const bufferLength
= Math
.floor(length
* 3 / 4);
127 const randomBuffer
= await
randomBytesAsync(bufferLength
);
128 const verifier
= randomBuffer
.toString('base64url');
130 const challenge
= Communication
._challengeFromVerifier(verifier
);
133 codeChallengeMethod: 'S256',
134 codeVerifier: verifier
,
135 codeChallenge: challenge
,
141 * Check a challenge with a verifier.
142 * @param {string} codeChallenge challenge
143 * @param {string} codeVerifier verifier
144 * @param {string} codeChallengeMethod method
145 * @returns {boolean} is valid
147 static verifyChallenge(codeChallenge
, codeVerifier
, codeChallengeMethod
) {
148 switch (codeChallengeMethod
) {
151 const challenge
= Communication
._challengeFromVerifier(codeVerifier
);
152 return challenge
=== codeChallenge
;
156 throw new Error('unsupported challenge method');
162 * Assemble a suitable User-Agent value.
163 * @param {object} userAgentConfig user agent object
164 * @param {string=} userAgentConfig.product product
165 * @param {string=} userAgentConfig.version version
166 * @param {string=} userAgentConfig.implementation implementation
167 * @returns {string} UA string
169 static _userAgentString(userAgentConfig
) {
170 // eslint-disable-next-line security/detect-object-injection
171 const _conf
= (field
, def
) => (userAgentConfig
&& field
in userAgentConfig
) ? userAgentConfig
[field
] : def
;
172 const product
= _conf('product', packageName
).split('/').pop();
173 const version
= _conf('version', packageVersion
);
174 let implementation
= _conf('implementation', Enum
.Specification
);
175 if (implementation
) {
176 implementation
= ` (${implementation})`;
178 return `${product}/${version}${implementation}`;
183 * Isolate the base of a url.
184 * mf2 parser needs this so that relative links can be made absolute.
185 * @param {URL} urlObj url
186 * @returns {string} url base
188 static _baseUrlString(urlObj
) {
189 const baseUrl
= new URL(urlObj
);
190 const lastSlashIdx
= baseUrl
.pathname
.lastIndexOf('/');
191 if (lastSlashIdx
> 0) {
192 baseUrl
.pathname
= baseUrl
.pathname
.slice(0, lastSlashIdx
+ 1);
199 * @typedef {object} ParsedContentType
200 * @property {string} mediaType media type
201 * @property {object} params other parameters
204 * Convert a Content-Type string to normalized components.
206 * N.B. this ill-named non-parsing implementation will not work
207 * if a parameter value for some reason includes a ; or = within
209 * @param {string} contentTypeHeader content-type header value
210 * @param {string} defaultContentType default type if none present
211 * @returns {ParsedContentType} contentType
213 static _parseContentType(contentTypeHeader
, defaultContentType
= Enum
.ContentType
.ApplicationOctetStream
) {
214 const [ mediaType
, ...params
] = (contentTypeHeader
|| '').split(/ *; */
);
216 mediaType: mediaType
.toLowerCase() || defaultContentType
,
217 params: params
.reduce((obj
, param
) => {
218 const [field
, value
] = param
.split('=');
219 const isQuoted
= value
?.startsWith('"') && value
?.endsWith('"');
220 obj
[field
.toLowerCase()] = isQuoted
? value
.slice(1, value
.length
- 1) : value
;
228 * Parse and add any header link relations from response to microformat data.
229 * @param {object} microformat microformat
230 * @param {object} response response
231 * @param {object} response.headers response headers
233 _mergeLinkHeader(microformat
, response
) {
234 const _scope
= _fileScope('_mergeLinkHeader');
236 // Establish that microformat has expected structure
237 ['rels', 'rel-urls'].forEach((p
) => {
238 if (!(p
in microformat
)) {
239 microformat
[p
] = {}; // eslint-disable-line security/detect-object-injection
242 if (!('items' in microformat
)) {
243 microformat
.items
= [];
246 const linkHeader
= response
.headers
[Enum
.Header
.Link
.toLowerCase()];
250 links
.push(...parseLinkHeader(linkHeader
));
252 this.logger
.error(_scope
, 'failed to parse link header', { error: e
, linkHeader
});
257 // Push header link rels into microformat form.
258 // Inserted at front of lists, as headers take precedence.
259 links
.forEach((link
) => {
260 link
.attributes
.forEach((attr
) => {
261 if (attr
.name
=== 'rel') {
262 if (!(attr
.value
in microformat
.rels
)) {
263 microformat
.rels
[attr
.value
] = [];
265 microformat
.rels
[attr
.value
].unshift(link
.target
);
267 if (!(link
.target
in microformat
['rel-urls'])) {
268 microformat
['rel-urls'][link
.target
] = {
273 microformat
['rel-urls'][link
.target
].rels
.unshift(attr
.value
);
281 * Retrieve and parse microformat data from url.
282 * N.B. this absorbs any errors!
283 * @param {URL} urlObj url
284 * @returns {Promise<object>} microformat
286 async
fetchMicroformat(urlObj
) {
287 const _scope
= _fileScope('fetchMicroformat');
288 const logInfoData
= {
290 microformat: undefined,
295 const fetchMicroformatConfig
= {
298 responseType: 'buffer',
300 response
= await
this.got(fetchMicroformatConfig
);
302 this.logger
.error(_scope
, 'microformat request failed', { error: e
, ...logInfoData
});
305 logInfoData
.response
= common
.gotResponseLogData(response
);
307 // Normalize to utf8.
309 const contentType
= Communication
._parseContentType(response
.headers
[Enum
.Header
.ContentType
.toLowerCase()]);
310 // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8.
311 const nonUTF8Charset
= !utf8CharsetRE
.test(contentType
.params
.charset
) && contentType
.params
.charset
;
312 if (nonUTF8Charset
) {
314 const iconv
= new Iconv(nonUTF8Charset
, 'utf-8//translit//ignore');
315 body
= iconv
.convert(response
.body
).toString('utf8');
317 // istanbul ignore next
318 this.logger
.error(_scope
, 'iconv conversion error', { error: e
, ...logInfoData
});
319 // Try to carry on, maybe the encoding will work anyhow...
322 body
= response
.body
.toString('utf8');
325 let microformat
= {};
327 microformat
= mf2(body
, {
328 baseUrl: Communication
._baseUrlString(urlObj
),
331 this.logger
.error(_scope
, 'failed to parse microformat data', { error: e
, ...logInfoData
});
332 // Try to carry on, maybe there are link headers...
335 this._mergeLinkHeader(microformat
, response
);
337 logInfoData
.microformat
= microformat
;
339 this.logger
.debug(_scope
, 'parsed microformat data', logInfoData
);
345 * Retrieve and parse JSON.
346 * N.B. this absorbs any errors!
347 * @param {URL} urlObj url
348 * @returns {Promise<object>} parsed json
350 async
fetchJSON(urlObj
) {
351 const _scope
= _fileScope('fetchJSON');
352 const logInfoData
= {
358 const fetchJSONConfig
= {
362 [Enum
.Header
.Accept
]: this._jsonAccept
,
364 responseType: 'json',
366 response
= await
this.got(fetchJSONConfig
);
368 this.logger
.error(_scope
, 'json request failed', { error: e
, ...logInfoData
});
372 return response
.body
;
377 * Validate a url has a specific schema.
378 * @param {URL} urlObj url
379 * @param {string[]} validSchemes url schemes
381 static _urlValidScheme(urlObj
, validSchemes
= ['http:', 'https:']) {
382 if (!validSchemes
.includes(urlObj
.protocol
)) {
383 throw new ValidationError(`unsupported url scheme '${urlObj.protocol}'`);
389 * Validate a url does not include some components.
390 * @param {URL} urlObj url
391 * @param {string[]} disallowed component names
393 static _urlPartsDisallowed(urlObj
, disallowed
) {
394 disallowed
.forEach((part
) => {
395 if (urlObj
[part
]) { // eslint-disable-line security/detect-object-injection
396 throw new ValidationError(`url cannot contain ${common.properURLComponentName(part)}`);
403 * Validate a url does not have relative path.
404 * @param {string} url url
406 static _urlPathNoDots(url
) {
407 if (noDotPathRE
.test(url
)) {
408 throw new ValidationError('relative path segment not valid');
414 * Validate a url does not have a hostname which is an ip address.
415 * N.B. Sets isLoopback on urlObj
416 * @param {URL} urlObj url
417 * @param {boolean} allowLoopback allow loopback ip
418 * @param {boolean} resolveHostname resolve hostname for checks
419 * @returns {Promise<void>}
421 static async
_urlNamedHost(urlObj
, allowLoopback
, resolveHostname
) {
423 if (v6HostRE
.test(urlObj
.hostname
)) {
425 * We do not need to worry about the Address6() failing to parse,
426 * as if it looks like an ipv6 addr but is not valid, the URL()
427 * call would already have failed.
429 address
= new Address6(urlObj
.hostname
.slice(1, urlObj
.hostname
.length
- 1));
430 /* Succeeded parsing as ipv6, reject unless loopback */
431 urlObj
.isLoopback
= address
.isLoopback();
434 address
= new Address4(urlObj
.hostname
);
435 /* Succeeded parsing as ipv4, reject unless loopback */
436 urlObj
.isLoopback
= address
.isInSubnet(loopback4
);
437 } catch (e
) { // eslint-disable-line no-unused-vars
438 /* Did not parse as ip, carry on */
442 if (resolveHostname
&& !urlObj
.isLoopback
) {
444 * Resolve hostname to check for localhost.
445 * This is more complicated due to SSRF mitigation:
446 * If the hostname does not end with a ., we also resolve that,
447 * and complain if the two resolutions do not match, assuming
448 * malicious intent for the server to resolve a local record.
450 const hostnames
= [urlObj
.hostname
];
451 if (!urlObj
.hostname
.endsWith('.')) {
452 hostnames
.push(urlObj
.hostname
+ '.');
454 const settledResolutions
= await Promise
.allSettled(hostnames
.map((hostname
) => dns
.promises
.lookup(hostname
, {
458 // If any resolution failed, bail.
459 if (settledResolutions
460 .map((resolution
) => resolution
.status
)
461 .includes('rejected')) {
462 throw new ValidationError('could not resolve hostname');
465 // Extract each resolution value, array of {address,family}
466 const resolutions
= settledResolutions
.map((resolution
) => resolution
.value
);
468 // If there were two resolutions, ensure they returned identical results.
469 if (resolutions
.length
> 1) {
470 // Create set of addresses for each resolution
471 const addressSets
= resolutions
.map((addrs
) => {
472 return new Set((addrs
|| []).map((a
) => a
.address
));
474 const differences
= common
.setSymmetricDifference(...addressSets
);
475 if (differences
.size
) {
476 throw new ValidationError('inconsistent hostname resolution');
479 const resolvedHost
= resolutions
[0] || [];
481 // Persist the loopback state
482 urlObj
.isLoopback
= resolvedHost
.reduce((acc
, resolved
) => {
484 switch (resolved
.family
) {
486 addr
= new Address4(resolved
.address
);
487 return acc
|| addr
.isInSubnet(loopback4
);
489 addr
= new Address6(resolved
.address
);
490 return acc
|| addr
.isLoopback();
498 && (!urlObj
.isLoopback
|| !allowLoopback
)) {
499 throw new ValidationError('hostname cannot be IP');
505 * Ensure a url meets the requirements to be a profile uri.
506 * @param {string} url url
507 * @param {object} validationOptions options
508 * @param {boolean=} validationOptions.allowLoopback default is false, following spec
509 * @param {boolean=} validationOptions.resolveHostname default is false, following spec
510 * @returns {Promise<URL>} validated url
512 async
validateProfile(url
, validationOptions
) {
513 const _scope
= _fileScope('validateProfile');
514 const errorScope
= 'invalid profile url';
517 allowLoopback: false,
518 resolveHostname: false,
519 ...validationOptions
,
524 profile
= new URL(url
);
526 this.logger
.debug(_scope
, 'failed to parse url', { error: e
, url
});
527 throw new ValidationError(`${errorScope}: unparsable`);
529 profile
.isLoopback
= false;
532 Communication
._urlValidScheme(profile
);
533 Communication
._urlPartsDisallowed(profile
, ['hash', 'username', 'password', 'port']);
534 Communication
._urlPathNoDots(url
);
535 await Communication
._urlNamedHost(profile
, options
.allowLoopback
, options
.resolveHostname
);
537 this.logger
.debug(_scope
, 'profile url not valid', { url
, error: e
});
538 throw new ValidationError(`${errorScope}: ${e.message}`);
546 * Ensure a url meets the requirements to be a client identifier.
547 * Sets 'isLoopback' on returned URL object to true if hostname is - or resolves to - a loopback ip.
548 * @param {string} url url
549 * @param {object} validationOptions options
550 * @param {boolean=} validationOptions.allowLoopback default is true, following spec
551 * @param {boolean=} validationOptions.resolveHostname default is true, following spec
552 * @returns {Promise<URL>} validated client url
554 async
validateClientIdentifier(url
, validationOptions
) {
555 const _scope
= _fileScope('validateClientIdentifier');
556 const errorScope
= 'invalid client identifier url';
560 resolveHostname: true,
561 ...validationOptions
,
566 clientId
= new URL(url
);
568 this.logger
.debug(_scope
, 'failed to parse url', { error: e
, url
});
569 throw new ValidationError('invalid client identifier url: unparsable');
571 clientId
.isLoopback
= false;
574 Communication
._urlValidScheme(clientId
);
575 Communication
._urlPartsDisallowed(clientId
, ['hash', 'username', 'password']);
576 Communication
._urlPathNoDots(url
);
577 await Communication
._urlNamedHost(clientId
, options
.allowLoopback
, options
.resolveHostname
);
579 this.logger
.debug(_scope
, 'client identifier url not valid', { url
, error: e
});
580 throw new ValidationError(`${errorScope}: ${e.message}`);
588 * @typedef {object} HAppData
589 * @property {string[]=} type list of item types
590 * @property {object=} properties map of item properties
593 * @typedef {object} ClientIdentifierData
594 * @property {object} rels keyed by relation to array of uris
595 * @property {HAppData[]} items h-app data
598 * Retrieve and parse client identifier endpoint data.
599 * N.B. Assumes urlObj has passed validateClientIdentifier.
600 * @param {URL} urlObj url
601 * @returns {Promise<ClientIdentifierData|undefined>} mf2 data filtered for h-app items, or undefined if url could not be fetched
603 async
fetchClientIdentifier(urlObj
) {
604 // Set by validation method in case of loopback ip hostname
605 if (urlObj
.isLoopback
) {
606 // Loopback address will eschew client fetch, return empty data.
613 const mfData
= await
this.fetchMicroformat(urlObj
);
618 // Only return h-app items with matching url field.
620 rels: mfData
.rels
|| {},
621 items: (mfData
.items
|| []).filter((item
) => {
622 let urlMatched
= false;
623 const itemType
= item
.type
|| [];
624 if ((itemType
.includes('h-app') || itemType
.includes('h-x-app'))
625 && (item
?.properties
?.url
)) {
626 item
.properties
.url
.forEach((url
) => {
628 const hUrl
= new URL(url
);
629 if (hUrl
.href
=== urlObj
.href
) {
632 } catch (e
) { /**/ } // eslint-disable-line no-unused-vars
642 * @typedef {object} Metadata
643 * @property {string} issuer issuer
644 * @property {string} authorizationEndpoint authorization endpoint
645 * @property {string} tokenEndpoint token endpoint
646 * @property {string} ticketEndpoint ticket endpoint
647 * @property {string} introspectionEndpoint introspection endpoint
648 * @property {string} introspectionEndpointAuthMethodsSupported auth methods
649 * @property {string} revocationEndpoint revocation endpoint
650 * @property {string} revocationEndpointAuthMethodsSupported auth methods
651 * @property {string} scopesSupported scopes
652 * @property {string} responseTypesSupported response types
653 * @property {string} grantTypesSupported grant types
654 * @property {string} serviceDocumentation service documentation reference
655 * @property {string} codeChallengeMethodsSupported code challenge methods
656 * @property {string} authorizationResponseIssParameterSupported iss parameter supported
657 * @property {string} userinfoEndpoint endpoint
660 * @typedef ProfileData
661 * @property {string} name name
662 * @property {string} photo photo
663 * @property {string} url url
664 * @property {string} email email
665 * @property {string} authorizationEndpoint deprecated, backwards compatibility for 20201126 spec
666 * @property {string} tokenEndpoint deprecated, backwards compatibility for 20201126 spec
667 * @property {string} indieauthMetadata authorization server metadata endpoint
668 * @property {Metadata} metadata authorization server metadata for profile
671 * Fetch the relevant microformat data from profile url h-card information,
672 * and authorization server metadata.
673 * N.B. Assumes urlObj has passed validateProfile
674 * @param {URL} urlObj valid profile url
675 * @returns {Promise<ProfileData>} mf2 data filtered for select fields from h-card
677 async
fetchProfile(urlObj
) {
678 const _scope
= _fileScope('fetchProfile');
680 const mfData
= await
this.fetchMicroformat(urlObj
);
690 * Locate h-card mf2 items with url field matching profile url,
691 * and populate profile fields with first-encountered card values.
693 if (mfData
&& 'items' in mfData
) {
694 const hCards
= mfData
.items
.filter((item
) =>
695 item
?.type
?.includes('h-card') &&
696 item
?.properties
?.url
?.includes(urlObj
.href
));
697 hCards
.forEach((hCard
) => {
698 Object
.keys(profile
).forEach((key
) => {
699 if (!profile
[key
] && key
in hCard
.properties
) { // eslint-disable-line security/detect-object-injection
700 profile
[key
] = hCard
.properties
[key
][0]; // eslint-disable-line security/detect-object-injection
706 // Populate legacy mf2 fields from relation links.
707 // These will be overwritten if they also exist in server metadata.
709 authorizationEndpoint: MF2Rel
.AuthorizationEndpoint
, // Backwards compatibility
710 tokenEndpoint: MF2Rel
.TokenEndpoint
, // Backwards compatibility
711 ticketEndpoint: MF2Rel
.TicketEndpoint
, // Backwards compatibility
712 }).forEach(([p
, r
]) => {
713 if (mfData
&& r
in mfData
.rels
) {
714 profile
.metadata
[p
] = profile
[p
] = mfData
.rels
[r
][0]; // eslint-disable-line security/detect-object-injection
718 // Set metadata field.
719 if (mfData
&& MF2Rel
.IndieauthMetadata
in mfData
.rels
) {
720 profile
.indieauthMetadata
= mfData
.rels
[MF2Rel
.IndieauthMetadata
][0];
723 // Attempt to populate metadata from authorization server.
724 if (profile
.indieauthMetadata
) {
727 mdURL
= new URL(profile
.indieauthMetadata
);
728 } catch (e
) /* istanbul ignore next */ { // eslint-disable-line no-unused-vars
729 this.logger
.error(_scope
, 'invalid authorization server metadata url', { profile
});
731 /* istanbul ignore else */
733 profile
.metadata
= await
this.fetchMetadata(mdURL
);
735 // Populate legacy profile fields.
736 ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f
) => {
737 if (f
in profile
.metadata
) {
738 profile
[f
] = profile
.metadata
[f
]; // eslint-disable-line security/detect-object-injection
749 * Fetch the server metadata from an authorization server's metadata endpoint.
750 * @param {URL} metadataUrl url
751 * @returns {Promise<Metadata>} metadata
753 async
fetchMetadata(metadataUrl
) {
754 const metadataResponse
= await
this.fetchJSON(metadataUrl
);
756 if (metadataResponse
) {
757 // Map snake_case fields to camelCase.
760 authorizationEndpoint: 'authorization_endpoint',
761 tokenEndpoint: 'token_endpoint',
762 ticketEndpoint: 'ticket_endpoint',
763 introspectionEndpoint: 'introspection_endpoint',
764 introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
765 revocationEndpoint: 'revocation_endpoint',
766 revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
767 scopesSupported: 'scopes_supported',
768 responseTypesSupported: 'response_types_supported',
769 grantTypesSupported: 'grant_types_supported',
770 serviceDocumentation: 'service_documentation',
771 codeChallengeMethodsSupported: 'code_challenge_methods_supported',
772 authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
773 userinfoEndpoint: 'userinfo_endpoint',
774 }).forEach(([c
, s
]) => {
775 if (s
in metadataResponse
) {
776 metadata
[c
] = metadataResponse
[s
]; // eslint-disable-line security/detect-object-injection
786 * POST to the auth endpoint, to redeem a code for a profile or token.
787 * N.B. this absorbs any errors!
788 * @param {URL} urlObj url
789 * @param {string} code code
790 * @param {string} codeVerifier verifier
791 * @param {string} clientId client id
792 * @param {string} redirectURI uri
793 * @returns {Promise<object>} response
795 async
redeemCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
) {
796 const _scope
= _fileScope('redeemCode');
798 const postRedeemCodeConfig
= {
802 [Enum
.Header
.Accept
]: this._jsonAccept
,
805 'grant_type': 'authorization_code',
807 'client_id': clientId
,
808 'redirect_uri': redirectURI
,
809 'code_verifier': codeVerifier
,
811 responseType: 'json',
815 const response
= await
this.got(postRedeemCodeConfig
);
816 return response
.body
;
818 this.logger
.error(_scope
, 'redeem code request failed', { error: e
, url: urlObj
.href
});
825 * Deprecated method name alias.
827 * @param {URL} urlObj url
828 * @param {string} code code
829 * @param {string} codeVerifier verifier
830 * @param {string} clientId client id
831 * @param {string} redirectURI uri
832 * @returns {Promise<object>} response
834 async
redeemProfileCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
) {
835 return this.redeemCode(urlObj
, code
, codeVerifier
, clientId
, redirectURI
);
840 * Verify a token with an IdP endpoint, using the Authorization header supplied.
841 * @param {URL} introspectionUrlObj url
842 * @param {string} authorizationHeader authorization
843 * @param {string} token token
844 * @returns {Promise<object>} response
846 async
introspectToken(introspectionUrlObj
, authorizationHeader
, token
) {
847 const _scope
= _fileScope('introspectToken');
849 const postIntrospectConfig
= {
850 url: introspectionUrlObj
,
853 [Enum
.Header
.Authorization
]: authorizationHeader
,
854 [Enum
.Header
.Accept
]: this._jsonAccept
,
859 responseType: 'json',
863 const response
= await
this.got(postIntrospectConfig
);
875 if (![true, false].includes(active
)) {
876 throw new RangeError('missing required response field "active"');
882 ...(clientId
&& { clientId
}),
883 ...(scope
&& { scope: scope
.split(scopeSplitRE
) }),
884 ...(exp
&& { exp: Number(exp
) }),
885 ...(iat
&& { iat: Number(iat
) }),
888 this.logger
.error(_scope
, 'failed to parse json', { error: e
, response: common
.gotResponseLogData(response
) });
892 this.logger
.error(_scope
, 'introspect token request failed', { error: e
, url: introspectionUrlObj
.href
});
899 * Attempt to deliver a ticket to an endpoint.
900 * N.B. does not absorb errors
901 * @param {URL} ticketEndpointUrlObj url ticket endpoint url
902 * @param {URL} resourceUrlObj url resource url
903 * @param {URL} subjectUrlObj url subject url
904 * @param {string} ticket ticket
905 * @param {URL=} issuerUrlObj issuer url
906 * @returns {Promise<Response>} response
908 async
deliverTicket(ticketEndpointUrlObj
, resourceUrlObj
, subjectUrlObj
, ticket
, issuerUrlObj
) {
909 const _scope
= _fileScope('deliverTicket');
912 const ticketConfig
= {
914 url: ticketEndpointUrlObj
,
917 resource: resourceUrlObj
.href
,
918 subject: subjectUrlObj
.href
,
919 ...( issuerUrlObj
&& { iss: issuerUrlObj
.href
}),
922 const result
= await
this.got(ticketConfig
);
923 this.logger
.debug(_scope
, 'success', { ...common
.gotResponseLogData(result
) });
926 this.logger
.error(_scope
, 'ticket delivery request failed', { error: e
, url: ticketEndpointUrlObj
.href
});
933 * Attempt to fetch some link relations from a url.
934 * @param {URL} urlObj url
935 * @returns {Promise<object>} data
937 async
_fetchMetadataOrTokenEndpoint(urlObj
) {
938 const _scope
= _fileScope('_fetchMetadataOrTokenEndpoint');
940 let metadataUrl
, tokenUrl
;
942 const mfData
= await
this.fetchMicroformat(urlObj
);
943 const metadataRel
= mfData
?.rels
?.[MF2Rel
.IndieauthMetadata
]?.[0];
946 metadataUrl
= new URL(metadataRel
);
947 } catch (e
) { // eslint-disable-line no-unused-vars
948 this.logger
.debug(_scope
, 'invalid metadata rel url', { url: urlObj
.href
, metadataRel
});
952 // No metadata rel, try old-style token endpoint
953 const tokenRel
= mfData
?.rels
?.[MF2Rel
.TokenEndpoint
]?.[0];
956 tokenUrl
= new URL(tokenRel
);
957 } catch (e
) { // eslint-disable-line no-unused-vars
958 this.logger
.debug(_scope
, 'invalid token rel url', { url: urlObj
.href
, tokenRel
});
963 return { metadataUrl
, tokenUrl
};
968 * Attempt to redeem a ticket for a token.
969 * N.B. does not absorb errors
970 * @param {string} ticket ticket
971 * @param {URL} resourceUrlObj resource url
972 * @param {URL=} issuerUrlObj issuer url
973 * @returns {Promise<object>} response body
975 async
redeemTicket(ticket
, resourceUrlObj
, issuerUrlObj
) {
976 const _scope
= _fileScope('redeemTicket');
978 let metadataUrl
, tokenUrl
;
979 // Attempt to determine metadata or token endpoint from issuer MF data
981 ({ metadataUrl
, tokenUrl
} = await
this._fetchMetadataOrTokenEndpoint(issuerUrlObj
));
984 // Fallback to resource MF data
985 if (!metadataUrl
&& !tokenUrl
) {
986 ({ metadataUrl
, tokenUrl
} = await
this._fetchMetadataOrTokenEndpoint(resourceUrlObj
));
990 const metadata
= await
this.fetchMetadata(metadataUrl
);
992 tokenUrl
= new URL(metadata
?.tokenEndpoint
);
993 } catch (e
) { // eslint-disable-line no-unused-vars
994 this.logger
.debug(_scope
, 'invalid token endpoint url from metadata', { resourceUrl: resourceUrlObj
.href
, issuerUrl: issuerUrlObj
.href
, tokenEndpoint: metadata
?.tokenEndpoint
});
999 throw new ValidationError('could not determine endpoint for ticket redemption');
1002 const postRedeemTicketConfig
= {
1006 [Enum
.Header
.Accept
]: this._jsonAccept
,
1009 'grant_type': 'ticket',
1012 responseType: 'json',
1016 const response
= await
this.got(postRedeemTicketConfig
);
1017 return response
.body
;
1019 this.logger
.error(_scope
, 'ticket redemption failed', { error: e
, resource: resourceUrlObj
.href
, issuer: issuerUrlObj
?.href
});
1025 module
.exports
= Communication
;