const v6HostRE = /\[[0-9a-f:]+\]/;
const loopback4 = new Address4('127.0.0.0/8');
const scopeSplitRE = / +/;
+const utf8CharsetRE = /utf-*8/i;
class Communication {
/**
let body;
const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]);
// If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8.
- const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset;
+ const nonUTF8Charset = !utf8CharsetRE.test(contentType.params.charset) && contentType.params.charset;
if (nonUTF8Charset) {
try {
const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore');
this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData });
return;
}
- logInfoData.response = common.gotResponseLogData(response);
return response.body;
}
}
+ /**
+ * @typedef {Object} Metadata
+ * @property {String} issuer
+ * @property {String} authorizationEndpoint
+ * @property {String} tokenEndpoint
+ * @property {String} ticketEndpoint
+ * @property {String} introspectionEndpoint
+ * @property {String} introspectionEndpointAuthMethodsSupported
+ * @property {String} revocationEndpoint
+ * @property {String} revocationEndpointAuthMethodsSupported
+ * @property {String} scopesSupported
+ * @property {String} responseTypesSupported
+ * @property {String} grantTypesSupported
+ * @property {String} serviceDocumentation
+ * @property {String} codeChallengeMethodsSupported
+ * @property {String} authorizationResponseIssParameterSupported
+ * @property {String} userinfoEndpoint
+ */
/**
* @typedef ProfileData
* @property {String} name
* @property {String} authorizationEndpoint - deprecated, backwards compatibility for 20201126 spec
* @property {String} tokenEndpoint - deprecated, backwards compatibility for 20201126 spec
* @property {String} indieauthMetadata authorization server metadata endpoint
- * @property {Object} metadata - authorization server metadata for profile
- * @property {String} metadata.issuer
- * @property {String} metadata.authorizationEndpoint
- * @property {String} metadata.tokenEndpoint
- * @property {String} metadata.ticketEndpoint
- * @property {String} metadata.introspectionEndpoint
- * @property {String} metadata.introspectionEndpointAuthMethodsSupported
- * @property {String} metadata.revocationEndpoint
- * @property {String} metadata.revocationEndpointAuthMethodsSupported
- * @property {String} metadata.scopesSupported
- * @property {String} metadata.responseTypesSupported
- * @property {String} metadata.grantTypesSupported
- * @property {String} metadata.serviceDocumentation
- * @property {String} metadata.codeChallengeMethodsSupported
- * @property {String} metadata.authorizationResponseIssParameterSupported
- * @property {String} metadata.userinfoEndpoint
+ * @property {Metadata} metadata - authorization server metadata for profile
*/
/**
* Fetch the relevant microformat data from profile url h-card information,
}
/* istanbul ignore else */
if (mdURL) {
- const metadataResponse = await this.fetchJSON(mdURL);
- if (metadataResponse) {
- // Map snake_case fields to camelCase.
- Object.entries({
- issuer: 'issuer',
- authorizationEndpoint: 'authorization_endpoint',
- tokenEndpoint: 'token_endpoint',
- ticketEndpoint: 'ticket_endpoint',
- introspectionEndpoint: 'introspection_endpoint',
- introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
- revocationEndpoint: 'revocation_endpoint',
- revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
- scopesSupported: 'scopes_supported',
- responseTypesSupported: 'response_types_supported',
- grantTypesSupported: 'grant_types_supported',
- serviceDocumentation: 'service_documentation',
- codeChallengeMethodsSupported: 'code_challenge_methods_supported',
- authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
- userinfoEndpoint: 'userinfo_endpoint',
- }).forEach(([c, s]) => {
- if (s in metadataResponse) {
- profile.metadata[c] = metadataResponse[s]; // eslint-disable-line security/detect-object-injection
- }
- });
+ profile.metadata = await this.fetchMetadata(mdURL);
- // Populate legacy profile fields.
- ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f) => {
- if (f in profile.metadata) {
- profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection
- }
- });
- }
+ // Populate legacy profile fields.
+ ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f) => {
+ if (f in profile.metadata) {
+ profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection
+ }
+ });
}
}
}
+ /**
+ * Fetch the server metadata from an authorization server's metadata endpoint.
+ * @param {URL} metadataUrl
+ * @returns {Promise<Metadata>}
+ */
+ async fetchMetadata(metadataUrl) {
+ const metadataResponse = await this.fetchJSON(metadataUrl);
+ const metadata = {};
+ if (metadataResponse) {
+ // Map snake_case fields to camelCase.
+ Object.entries({
+ issuer: 'issuer',
+ authorizationEndpoint: 'authorization_endpoint',
+ tokenEndpoint: 'token_endpoint',
+ ticketEndpoint: 'ticket_endpoint',
+ introspectionEndpoint: 'introspection_endpoint',
+ introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
+ revocationEndpoint: 'revocation_endpoint',
+ revocationEndpointAuthMethodsSupported: 'revocation_endpoint_auth_methods_supported',
+ scopesSupported: 'scopes_supported',
+ responseTypesSupported: 'response_types_supported',
+ grantTypesSupported: 'grant_types_supported',
+ serviceDocumentation: 'service_documentation',
+ codeChallengeMethodsSupported: 'code_challenge_methods_supported',
+ authorizationResponseIssParameterSupported: 'authorization_response_iss_parameter_supported',
+ userinfoEndpoint: 'userinfo_endpoint',
+ }).forEach(([c, s]) => {
+ if (s in metadataResponse) {
+ metadata[c] = metadataResponse[s]; // eslint-disable-line security/detect-object-injection
+ }
+ });
+ }
+
+ return metadata;
+ }
+
+
/**
* POST to the auth endpoint, to redeem a code for a profile or token.
* N.B. this absorbs any errors!
try {
const response = await this.got(postIntrospectConfig);
- this.logger.debug(_scope, 'response', { response });
// check status
try {
const {
* @param {URL} ticketEndpointUrlObj
* @param {URL} resourceUrlObj
* @param {URL} subjectUrlObj
+ * @param {URL=} issuerUrlObj
* @param {String} ticket
* @returns {Promise<Response>}
*/
- async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) {
+ async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj) {
const _scope = _fileScope('deliverTicket');
try {
ticket,
resource: resourceUrlObj.href,
subject: subjectUrlObj.href,
+ ...( issuerUrlObj && { iss: issuerUrlObj.href }),
},
};
return await this.got(ticketConfig);
}
}
+
+ /**
+ * Attempt to fetch some link relations from a url.
+ * @param {URL} urlObj
+ * @returns {Promise<Object>}
+ */
+ async _fetchMetadataOrTokenEndpoint(urlObj) {
+ const _scope = _fileScope('_fetchMetadataOrTokenEndpoint');
+
+ let metadataUrl, tokenUrl;
+ if (urlObj) {
+ const mfData = await this.fetchMicroformat(urlObj);
+ const metadataRel = mfData?.rels?.['indieauth-metadata']?.[0];
+ if (metadataRel) {
+ try {
+ metadataUrl = new URL(metadataRel);
+ } catch (e) {
+ this.logger.debug(_scope, 'invalid metadata rel url', { url: urlObj.href, metadataRel });
+ }
+ }
+ if (!metadataUrl) {
+ // no metadata rel, try old-style token endpoint
+ const tokenRel = mfData?.rels?.['token_endpoint']?.[0];
+ if (tokenRel) {
+ try {
+ tokenUrl = new URL(tokenRel);
+ } catch (e) {
+ this.logger.debug(_scope, 'invalid token rel url', { url: urlObj.href, tokenRel });
+ }
+ }
+ }
+ }
+ return { metadataUrl, tokenUrl };
+ }
+
+
+ /**
+ * Attempt to redeem a ticket for a token.
+ * N.B. does not absorb errors
+ * @property {String} ticket
+ * @property {URL} resourceUrlObj
+ * @property {URL=} issuerUrlObj
+ * @returns {Promise<Object>} response body
+ */
+ async redeemTicket(ticket, resourceUrlObj, issuerUrlObj) {
+ const _scope = _fileScope('redeemTicket');
+
+ let metadataUrl, tokenUrl;
+ // Attempt to determine metadata or token endpoint from issuer MF data
+ if (issuerUrlObj) {
+ ({ metadataUrl, tokenUrl } = await this._fetchMetadataOrTokenEndpoint(issuerUrlObj));
+ }
+
+ // Fallback to resource MF data
+ if (!metadataUrl && !tokenUrl) {
+ ({ metadataUrl, tokenUrl } = await this._fetchMetadataOrTokenEndpoint(resourceUrlObj));
+ }
+
+ if (metadataUrl) {
+ const metadata = await this.fetchMetadata(metadataUrl);
+ try {
+ tokenUrl = new URL(metadata?.tokenEndpoint);
+ } catch (e) {
+ this.logger.debug(_scope, 'invalid token endpoint url from metadata', { resourceUrl: resourceUrlObj.href, issuerUrl: issuerUrlObj.href, tokenEndpoint: metadata?.tokenEndpoint });
+ }
+ }
+
+ if (!tokenUrl) {
+ throw new ValidationError('could not determine endpoint for ticket redemption');
+ }
+
+ const postRedeemTicketConfig = {
+ url: tokenUrl,
+ method: 'POST',
+ headers: {
+ [Enum.Header.Accept]: this._jsonAccept,
+ },
+ form: {
+ 'grant_type': 'ticket',
+ ticket,
+ },
+ responseType: 'json',
+ };
+
+ try {
+ const response = await this.got(postRedeemTicketConfig);
+ return response.body;
+ } catch (e) {
+ this.logger.error(_scope, 'ticket redemption failed', { error: e, resource: resourceUrlObj.href, issuer: issuerUrlObj?.href });
+ throw e;
+ }
+ }
}
module.exports = Communication;
});
}); // fetchProfile
+ describe('fetchMetadata', function () {
+ let metadataUrl;
+ beforeEach(function () {
+ metadataUrl = new URL('https://thuza.ratfeathers.com/');
+ sinon.stub(communication, 'fetchJSON');
+ });
+ it('covers success', async function () {
+ communication.fetchJSON.resolves({
+ 'issuer': 'https://ia.squeep.com/',
+ 'authorization_endpoint': 'https://ia.squeep.com/auth',
+ 'token_endpoint': 'https://ia.squeep.com/token',
+ 'introspection_endpoint': 'https://ia.squeep.com/introspect',
+ 'introspection_endpoint_auth_methods_supported': [ '' ],
+ 'revocation_endpoint': 'https://ia.squeep.com/revoke',
+ 'revocation_endpoint_auth_methods_supported': [ 'none' ],
+ 'scopes_supported': [ 'profile', 'email' ],
+ 'service_documentation': 'https://indieauth.spec.indieweb.org/',
+ 'code_challenge_methods_supported': [ 'S256', 'SHA256' ],
+ 'authorization_response_iss_parameter_supported': true,
+ 'userinfo_endpoint': 'https://ia.squeep.com/userinfo',
+ });
+ const expected = {
+ authorizationEndpoint: 'https://ia.squeep.com/auth',
+ tokenEndpoint: 'https://ia.squeep.com/token',
+ issuer: 'https://ia.squeep.com/',
+ introspectionEndpoint: 'https://ia.squeep.com/introspect',
+ introspectionEndpointAuthMethodsSupported: [ '' ],
+ revocationEndpoint: 'https://ia.squeep.com/revoke',
+ revocationEndpointAuthMethodsSupported: [ 'none' ],
+ scopesSupported: [ 'profile', 'email' ],
+ serviceDocumentation: 'https://indieauth.spec.indieweb.org/',
+ codeChallengeMethodsSupported: [ 'S256', 'SHA256' ],
+ authorizationResponseIssParameterSupported: true,
+ userinfoEndpoint: 'https://ia.squeep.com/userinfo',
+ };
+ const result = await communication.fetchMetadata(metadataUrl);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('covers failure', async function () {
+ communication.fetchJSON.resolves(undefined);
+ const expected = {};
+ const result = await communication.fetchMetadata(metadataUrl);
+ assert.deepStrictEqual(result, expected);
+ });
+ }); // fetchMetadata
+
describe('redeemCode', function () {
let expected, urlObj, code, codeVerifier, clientId, redirectURI;
beforeEach(function () {
}); // introspectToken
describe('deliverTicket', function () {
- let ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket;
+ let ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj;
beforeEach(function () {
ticketEndpointUrlObj = new URL('https://ticket.example.com/');
resourceUrlObj = new URL('https://resource.example.com/');
subjectUrlObj = new URL('https://subject.example.com/');
+ issuerUrlObj = new URL('https://idp.example.com/');
ticket = 'XXXThisIsATicketXXX';
});
it('covers success', async function () {
+ const expected = { body: 'blah', statusCode: 200 };
+ communication.got.resolves(expected);
+ const result = await communication.deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('covers success, no issuer', async function () {
const expected = { body: 'blah', statusCode: 200 };
communication.got.resolves(expected);
const result = await communication.deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket);
});
}); // deliverTicket
+ describe('_fetchMetadataOrTokenEndpoint', function () {
+ let urlObj, metadataUrl, tokenUrl;
+ beforeEach(function () {
+ urlObj = new URL('https://idp.example.com/');
+ metadataUrl = new URL('https://idp.example.com/meta');
+ tokenUrl = new URL('https://idp.example.com/token');
+ sinon.stub(communication, 'fetchMicroformat').resolves({
+ rels: {
+ 'indieauth-metadata': [ metadataUrl.href ],
+ 'token_endpoint': [ tokenUrl.href ],
+ },
+ });
+ });
+ it('covers success', async function () {
+ const expected = {
+ metadataUrl,
+ tokenUrl: undefined,
+ };
+ const result = await communication._fetchMetadataOrTokenEndpoint(urlObj);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('covers bad metadata url', async function () {
+ communication.fetchMicroformat.resolves({
+ rels: {
+ 'indieauth-metadata': [ 'not a url' ],
+ 'token_endpoint': [ tokenUrl.href ],
+ },
+ });
+ const expected = {
+ metadataUrl: undefined,
+ tokenUrl,
+ };
+ const result = await communication._fetchMetadataOrTokenEndpoint(urlObj);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('covers bad token url', async function () {
+ communication.fetchMicroformat.resolves({
+ rels: {
+ 'indieauth-metadata': [],
+ 'token_endpoint': [ 'not a url' ],
+ },
+ });
+ const expected = {
+ metadataUrl: undefined,
+ tokenUrl: undefined,
+ };
+ const result = await communication._fetchMetadataOrTokenEndpoint(urlObj);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('covers no rels', async function () {
+ communication.fetchMicroformat.resolves({
+ rels: {
+ 'indieauth-metadata': [],
+ 'token_endpoint': [],
+ },
+ });
+ const expected = {
+ metadataUrl: undefined,
+ tokenUrl: undefined,
+ };
+ const result = await communication._fetchMetadataOrTokenEndpoint(urlObj);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('covers no url', async function () {
+ const expected = {
+ metadataUrl: undefined,
+ tokenUrl: undefined,
+ };
+ const result = await communication._fetchMetadataOrTokenEndpoint();
+ assert.deepStrictEqual(result, expected);
+ });
+ }); // _fetchMetadataOrTokenEndpoint
+
+ describe('redeemTicket', function () {
+ let ticket, resourceUrlObj, issuerUrlObj;
+ beforeEach(function () {
+ resourceUrlObj = new URL('https://resource.example.com/');
+ issuerUrlObj = new URL('https://idp.example.com/');
+ ticket = 'XXXThisIsATicketXXX';
+ sinon.stub(communication, '_fetchMetadataOrTokenEndpoint').resolves({
+ metadataUrl: new URL('https://example.com'),
+ tokenUrl: undefined,
+ });
+ sinon.stub(communication, 'fetchMetadata').resolves({ tokenEndpoint: 'https://idp.example.com/' });
+ });
+ it('covers success', async function () {
+ const expected = { 'access_token': 'XXXThisIsAnAccessTokenXXX' };
+ const response = { body: expected, headers: {}, statusCode: 200 };
+ communication.got.resolves(response);
+ const result = await communication.redeemTicket(ticket, resourceUrlObj, issuerUrlObj);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('covers success without issuer', async function () {
+ const expected = { 'access_token': 'XXXThisIsAnAccessTokenXXX' };
+ const response = { body: expected, headers: {}, statusCode: 200 };
+ communication.got.resolves(response);
+ const result = await communication.redeemTicket(ticket, resourceUrlObj);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('covers got failure', async function () {
+ const expectedException = new Error('oh no');
+ communication.got.rejects(expectedException);
+ await assert.rejects(() => communication.redeemTicket(ticket, resourceUrlObj, issuerUrlObj), expectedException);
+ });
+ it('covers no metadata url', async function () {
+ communication._fetchMetadataOrTokenEndpoint.resolves({ metadataUrl: undefined, tokenUrl: undefined });
+ await assert.rejects(() => communication.redeemTicket(ticket, resourceUrlObj, issuerUrlObj), ValidationError);
+ });
+ it('covers bad token url', async function () {
+ communication.fetchMetadata.resolves({ tokenEndpoint: 'not a url' });
+ await assert.rejects(() => communication.redeemTicket(ticket, resourceUrlObj, issuerUrlObj), ValidationError);
+ });
+ }); // redeemTicket
+
}); // Communication