From 45d151c0587750c9a8ed3e3180ca79139a6ccb5e Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Fri, 10 Nov 2023 17:51:18 -0800 Subject: [PATCH] add support for redeeming ticket, and sending ticket with issuer --- README.md | 5 +- lib/communication.js | 211 +++++++++++++++++++++++++++++--------- test/lib/communication.js | 169 +++++++++++++++++++++++++++++- 3 files changed, 332 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index b3f85bf..9a67a6d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # IndieAuth Helper -Just some abstractions for interacting with IndieAuth sites, basically wrapping `got` and `microformats-parser` mf2 parsing and some other fiddly bits. +Just some abstractions for interacting with IndieAuth sites, basically wrapping `got` and `microformats-parser` mf2 parsing, performing various validations according to the specification, and some other fiddly bits. This is currently quite opinionated, and likely is only really useful in the context of Squeep Framework Applications. @@ -35,3 +35,6 @@ Notable methods on the Communication class: - `async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket)` Submit a ticket offer. + +- `async redeemTicket(ticket, resourceUrlObj, issuerUrlObj)` + Exchange a ticket for an access token to the given resource. diff --git a/lib/communication.js b/lib/communication.js index d160707..6d2334a 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -19,6 +19,7 @@ const noDotPathRE = /(\/\.\/|\/\.\.\/)/; const v6HostRE = /\[[0-9a-f:]+\]/; const loopback4 = new Address4('127.0.0.0/8'); const scopeSplitRE = / +/; +const utf8CharsetRE = /utf-*8/i; class Communication { /** @@ -291,7 +292,7 @@ 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'); @@ -351,7 +352,6 @@ class Communication { this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData }); return; } - logInfoData.response = common.gotResponseLogData(response); return response.body; } @@ -619,6 +619,24 @@ class Communication { } + /** + * @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 @@ -628,22 +646,7 @@ class Communication { * @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, @@ -706,38 +709,14 @@ class Communication { } /* 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 + } + }); } } @@ -745,6 +724,43 @@ class Communication { } + /** + * Fetch the server metadata from an authorization server's metadata endpoint. + * @param {URL} metadataUrl + * @returns {Promise} + */ + 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! @@ -824,7 +840,6 @@ class Communication { try { const response = await this.got(postIntrospectConfig); - this.logger.debug(_scope, 'response', { response }); // check status try { const { @@ -865,10 +880,11 @@ class Communication { * @param {URL} ticketEndpointUrlObj * @param {URL} resourceUrlObj * @param {URL} subjectUrlObj + * @param {URL=} issuerUrlObj * @param {String} ticket * @returns {Promise} */ - async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) { + async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket, issuerUrlObj) { const _scope = _fileScope('deliverTicket'); try { @@ -879,6 +895,7 @@ class Communication { ticket, resource: resourceUrlObj.href, subject: subjectUrlObj.href, + ...( issuerUrlObj && { iss: issuerUrlObj.href }), }, }; return await this.got(ticketConfig); @@ -888,6 +905,98 @@ class Communication { } } + + /** + * Attempt to fetch some link relations from a url. + * @param {URL} urlObj + * @returns {Promise} + */ + 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} 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; diff --git a/test/lib/communication.js b/test/lib/communication.js index 332aa64..42b5116 100644 --- a/test/lib/communication.js +++ b/test/lib/communication.js @@ -785,6 +785,52 @@ describe('Communication', function () { }); }); // 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 () { @@ -878,14 +924,21 @@ describe('Communication', 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); @@ -898,4 +951,118 @@ describe('Communication', function () { }); }); // 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 -- 2.43.2