From 30851a8cb9f8823b1b395ace8f53d62c5c53abd8 Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Sat, 12 Nov 2022 11:35:47 -0800 Subject: [PATCH] add some support for tickets, introspection method, minor fixes --- .nycrc.json | 6 ++ README.md | 12 +++ lib/common.js | 16 +++- lib/communication.js | 125 ++++++++++++++++++++++++++++--- test/lib/common.js | 10 +++ test/lib/communication.js | 153 +++++++++++++++++++------------------- 6 files changed, 230 insertions(+), 92 deletions(-) create mode 100644 .nycrc.json diff --git a/.nycrc.json b/.nycrc.json new file mode 100644 index 0000000..497d8af --- /dev/null +++ b/.nycrc.json @@ -0,0 +1,6 @@ +{ + "reporter": [ + "lcov", + "text" + ] +} diff --git a/README.md b/README.md index e2df28d..fe3e362 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Notable methods on the Communication class: - `static async generatePKCE(length)` Create a code and verifier for use in an IndieAuth transaction. +- `validateProfile(url)` + Check that a urls meets specification requirements to be a profile. + - `async fetchProfile(urlObject)` Retrieve profile information from an endpoint. @@ -23,3 +26,12 @@ Notable methods on the Communication class: - `async fetchJSON(urlObject)` Retrieve json from an endpoint. + +- `async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI)` + Submit a code to get a profile response. + +- `async introspectToken(introspectionUrlObj, authenticationHeader, token)` + Submit a token for introspection. + +- `async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket)` + Submit a ticket offer. diff --git a/lib/common.js b/lib/common.js index dc05854..f199b49 100644 --- a/lib/common.js +++ b/lib/common.js @@ -17,8 +17,8 @@ const fileScope = (filename) => { /** * Pick out useful axios response fields. - * @param {*} res - * @returns + * @param {AxiosResponse} res + * @returns {Object} */ const axiosResponseLogData = (res) => { const data = pick(res, [ @@ -97,6 +97,17 @@ const properURLComponentName = (component) => { } +/** + * Encodes single-level object as form data string. + * @param {Object} data + */ +const formData = (data) => { + const formData = new URLSearchParams(); + Object.entries(data).forEach(([name, value]) => formData.set(name, value)); + return formData.toString(); +}; + + module.exports = { fileScope, axiosResponseLogData, @@ -104,4 +115,5 @@ module.exports = { pick, setSymmetricDifference, properURLComponentName, + formData, }; \ No newline at end of file diff --git a/lib/communication.js b/lib/communication.js index 8f6110f..d52d784 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -22,6 +22,7 @@ const _fileScope = common.fileScope(__filename); const noDotPathRE = /(\/\.\/|\/\.\.\/)/; const v6HostRE = /\[[0-9a-f:]+\]/; const loopback4 = new Address4('127.0.0.0/8'); +const scopeSplitRE = / +/; class Communication { /** @@ -52,16 +53,28 @@ class Communication { } + /** + * Encode hashed verifier data for PKCE. + * @param {BinaryLike} verifier + * @returns {String} + */ static _challengeFromVerifier(verifier) { const hash = createHash('sha256'); hash.update(verifier); return base64ToBase64URL(hash.digest('base64')); } + + /** + * @typedef PKCEData + * @property {String} codeChallengeMethod + * @property {String} codeVerifier + * @property {String} codeChallenge + */ /** * Create a code verifier and its challenge. * @param {Number} length - * @returns {Object} + * @returns {Promise} */ static async generatePKCE(length = 128) { if (length < 43 || length > 128) { @@ -135,6 +148,7 @@ class Communication { return (status >= 200 && status < 300) || status == 401; } + /** * A request config skeleton. * @param {String} method @@ -260,7 +274,7 @@ class Communication { * Retrieve and parse microformat data from url. * N.B. this absorbs any errors! * @param {URL} urlObj - * @returns {Object} + * @returns {Promise} */ async fetchMicroformat(urlObj) { const _scope = _fileScope('fetchMicroformat'); @@ -317,7 +331,7 @@ class Communication { * Retrieve and parse JSON. * N.B. this absorbs any errors! * @param {URL} urlObj - * @returns {Object} + * @returns {Promise} */ async fetchJSON(urlObj) { const _scope = _fileScope('fetchJSON'); @@ -390,6 +404,7 @@ class Communication { * N.B. Sets isLoopback on urlObj * @param {URL} urlObj * @param {Boolean} allowLoopback + * @returns {Promise} */ static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) { let address; @@ -479,12 +494,14 @@ class Communication { * @param {String} url * @param {Object} validationOptions * @param {Boolean} validationOptions.allowLoopback + * @param {Boolean} validationOptions.resolveHostname + * @returns {Promise} */ async validateProfile(url, validationOptions) { const _scope = _fileScope('validateProfile'); const errorScope = 'invalid profile url'; - const options = Object.assign({}, { + const options = Object.assign({ allowLoopback: false, resolveHostname: false, }, validationOptions); @@ -519,13 +536,13 @@ class Communication { * @param {Object} validationOptions * @param {Boolean} validationOptions.allowLoopback * @param {Boolean} validationOptions.resolveHostname - * @returns {URL} + * @returns {Promise} */ async validateClientIdentifier(url, validationOptions) { const _scope = _fileScope('validateClientIdentifier'); const errorScope = 'invalid client identifier url'; - const options = Object.assign({}, { + const options = Object.assign({ allowLoopback: true, resolveHostname: true, }, validationOptions); @@ -619,6 +636,7 @@ class Communication { * @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 @@ -669,6 +687,7 @@ class Communication { Object.entries({ authorizationEndpoint: 'authorization_endpoint', // backwards compatibility tokenEndpoint: 'token_endpoint', // backwards compatibility + ticketEndpoint: 'ticket_endpoint', // backwards compatibility }).forEach(([p, r]) => { if (mfData && r in mfData.rels) { profile.metadata[p] = profile[p] = mfData.rels[r][0]; // eslint-disable-line security/detect-object-injection @@ -697,6 +716,7 @@ class Communication { issuer: 'issuer', authorizationEndpoint: 'authorization_endpoint', tokenEndpoint: 'token_endpoint', + ticketEndpoint: 'ticket_endpoint', introspectionEndpoint: 'introspection_endpoint', introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported', revocationEndpoint: 'revocation_endpoint', @@ -715,7 +735,7 @@ class Communication { }); // Populate legacy profile fields. - ['authorizationEndpoint', 'tokenEndpoint'].forEach((f) => { + ['authorizationEndpoint', 'tokenEndpoint', 'ticketEndpoint'].forEach((f) => { if (f in profile.metadata) { profile[f] = profile.metadata[f]; // eslint-disable-line security/detect-object-injection } @@ -730,6 +750,7 @@ class Communication { /** * POST to the auth endpoint, to redeem a code for a profile object. + * FIXME: [name] this isn't specific to profile redemption, it works for tokens too * @param {URL} urlObj * @param {String} code * @param {String} codeVerifier @@ -740,16 +761,15 @@ class Communication { async redeemProfileCode(urlObj, code, codeVerifier, clientId, redirectURI) { const _scope = _fileScope('redeemProfileCode'); - const data = new URLSearchParams(); - Object.entries({ + const formData = common.formData({ 'grant_type': 'authorization_code', code, 'client_id': clientId, 'redirect_uri': redirectURI, 'code_verifier': codeVerifier, - }).forEach(([name, value]) => data.set(name, value)); + }); - const postRedeemProfileCodeConfig = Communication._axiosConfig('POST', urlObj, data.toString(), {}, { + const postRedeemProfileCodeConfig = Communication._axiosConfig('POST', urlObj, formData, {}, { [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`, }); @@ -768,6 +788,87 @@ class Communication { } } + + /** + * Verify a token with an IdP endpoint, using the Authentication header supplied. + * @param {URL} introspectionUrlObj + * @param {String} authenticationHeader + * @param {String} token + */ + async introspectToken(introspectionUrlObj, authenticationHeader, token) { + const _scope = _fileScope('introspectToken'); + + const formData = common.formData({ token }); + const postIntrospectConfig = Communication._axiosConfig('POST', introspectionUrlObj, formData, {}, { + [Enum.Header.Authentication]: authenticationHeader, + [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, + [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`, + }); + delete postIntrospectConfig.validateStatus; // only accept success + + let tokenInfo; + try { + const response = await this.axios(postIntrospectConfig); + this.logger.debug(_scope, 'response', { response }); + // check status + try { + tokenInfo = JSON.parse(response.data); + const { + active, + me, + client_id: clientId, + scope, + exp, + iat, + } = tokenInfo; + + return { + active, + ...(me && { me }), + ...(clientId && { clientId }), + ...(scope && { scope: scope.split(scopeSplitRE) }), + ...(exp && { exp: Number(exp) }), + ...(iat && { iat: Number(iat) }), + }; + } catch (e) { + this.logger.error(_scope, 'failed to parse json', { error: e, response }); + throw e; + } + } catch (e) { + this.logger.error(_scope, 'introspect token request failed', { error: e, url: introspectionUrlObj.href }); + throw e; + } + } + + + /** + * Attempt to deliver a ticket to an endpoint. + * N.B. does not absorb errors + * @param {*} ticketEndpointUrlObj + * @param {*} resourceUrlObj + * @param {*} subjectUrlObj + * @param {*} ticket + * @returns {Promise} + */ + async deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket) { + const _scope = _fileScope('deliverTicket'); + + try { + const ticketPayload = { + ticket, + resource: resourceUrlObj.href, + subject: subjectUrlObj.href, + }; + const ticketConfig = Communication._axiosConfig('POST', ticketEndpointUrlObj, ticketPayload, {}, { + [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, + }); + return await this.axios(ticketConfig); + } catch (e) { + this.logger.error(_scope, 'ticket delivery request failed', { error: e, url: ticketEndpointUrlObj.href }); + throw e; + } + } + } -module.exports = Communication; \ No newline at end of file +module.exports = Communication; diff --git a/test/lib/common.js b/test/lib/common.js index fe02407..7ebc91b 100644 --- a/test/lib/common.js +++ b/test/lib/common.js @@ -120,4 +120,14 @@ describe('common', function () { }); }); // properURLComponentName + describe('formData', function () { + it('covers', function () { + const result = common.formData({ + key: 'value', + foo: 'bar', + }); + assert.strictEqual(result, 'key=value&foo=bar'); + }); + }); // formData + }); // common \ No newline at end of file diff --git a/test/lib/communication.js b/test/lib/communication.js index 9781cd6..c79d261 100644 --- a/test/lib/communication.js +++ b/test/lib/communication.js @@ -13,8 +13,6 @@ const dns = require('dns'); const stubLogger = require('../stub-logger'); const testData = require('../test-data/communication'); -const noExpectedException = 'did not get expected exception'; - describe('Communication', function () { let communication, options; @@ -69,12 +67,7 @@ describe('Communication', function () { assert.strictEqual(result.codeChallengeMethod, 'S256'); }); it('covers error', async function () { - try { - await Communication.generatePKCE(1); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof RangeError); - } + await assert.rejects(() => Communication.generatePKCE(1)); }); }); // generatePKCE @@ -104,12 +97,7 @@ describe('Communication', function () { const method = 'MD5'; const challenge = 'xkfP7DUYDsnu07Kg6ogc8A'; const verifier = 'VGhpcyBpcyBhIHNlY3JldC4u'; - try { - Communication.verifyChallenge(challenge, verifier, method); - assert.fail(noExpectedException); - } catch (e) { - assert(e.message.includes('unsupported')); - } + assert.throws(() => Communication.verifyChallenge(challenge, verifier, method)); }); }); // verifyChallenge @@ -539,7 +527,7 @@ describe('Communication', function () { let url, validationOptions; beforeEach(function () { url = 'https://example.com/'; - options = {}; + validationOptions = {}; sinon.stub(dns, 'lookupAsync').resolves([{ family: 4, address: '10.11.12.14' }]); }); it('rejects invalid url', async function () { @@ -561,79 +549,39 @@ describe('Communication', function () { let url, validationOptions; beforeEach(function () { url = 'https://example.com/'; - options = {}; + validationOptions = {}; sinon.stub(dns, 'lookupAsync').resolves([{ family: 4, address: '10.11.12.13' }]); }); it('rejects invalid url', async function () { - try { - await communication.validateClientIdentifier('bad url'); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier('bad url'), ValidationError); }); it('rejects invalid scheme', async function () { url = 'ftp://example.com/'; - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('rejects fragment', async function () { url = 'https://example.com/#foo'; - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('rejects username', async function () { url = 'https://user@example.com/'; - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('rejects password', async function () { url = 'https://:foo@example.com/'; - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('rejects relative path', async function () { url = 'https://example.com/client/../sneaky'; - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('rejects ipv4', async function () { url = 'https://10.11.12.13/'; - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('rejects ipv6', async function () { url = 'https://[fd64:defa:00e5:caf4:0dff::ad39]/'; - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('accepts ipv4 loopback', async function () { url = 'https://127.0.0.1/'; @@ -661,21 +609,11 @@ describe('Communication', function () { }); it('rejects resolution failure', async function () { dns.lookupAsync.rejects(new Error('oh no')); - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('rejects mismatched resolutions', async function () { dns.lookupAsync.onCall(1).resolves([{ family: 4, address: '10.9.8.7' }]); - try { - await communication.validateClientIdentifier(url, validationOptions); - assert.fail(noExpectedException); - } catch (e) { - assert(e instanceof ValidationError); - } + await assert.rejects(() => communication.validateClientIdentifier(url, validationOptions), ValidationError); }); it('ignores unknown dns family', async function () { dns.lookupAsync.resolves([{ family: 5, address: '10.9.8.7' }]); @@ -934,7 +872,7 @@ describe('Communication', function () { describe('redeemProfileCode', function () { let expected, urlObj, code, codeVerifier, clientId, redirectURI; - this.beforeEach(function () { + beforeEach(function () { urlObj = new URL('https://example.com/auth'); code = Buffer.allocUnsafe(42).toString('base64').replace('/', '_').replace('+', '-'); codeVerifier = Buffer.allocUnsafe(42).toString('base64').replace('/', '_').replace('+', '-'); @@ -960,5 +898,64 @@ describe('Communication', function () { assert.strictEqual(result, undefined); }); - }); + }); // redeemProfileCode + + describe('introspectToken', function () { + let introspectionUrlObj, authenticationHeader, token; + beforeEach(function () { + introspectionUrlObj = new URL('https://ia.example.com/introspect'); + authenticationHeader = 'Bearer XXX'; + token = 'xxx'; + }); + it('covers success active', async function () { + const nowEpoch = Math.ceil(Date.now() / 1000); + communication.axios.resolves({ + data: JSON.stringify({ + active: true, + me: 'https://profile.example.com/', + 'client_id': 'https://app.example.com/', + scope: 'create profile email', + exp: nowEpoch + 86400, + iat: nowEpoch, + }), + }); + const result = await communication.introspectToken(introspectionUrlObj, authenticationHeader, token); + assert.strictEqual(result.active, true); + }); + it('covers success inactive', async function () { + communication.axios.resolves({ + data: JSON.stringify({ + active: false, + }), + }); + const result = await communication.introspectToken(introspectionUrlObj, authenticationHeader, token); + assert.strictEqual(result.active, false); + }); + it('covers failure', async function () { + communication.axios.resolves('what kind of response is this?'); + await assert.rejects(() => communication.introspectToken(introspectionUrlObj, authenticationHeader, token)); + }); + }); // introspectToken + + describe('deliverTicket', function () { + let ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket; + beforeEach(function () { + ticketEndpointUrlObj = new URL('https://ticket.example.com/'); + resourceUrlObj = new URL('https://resource.example.com/'); + subjectUrlObj = new URL('https://subject.example.com/'); + ticket = 'XXXThisIsATicketXXX'; + }); + it('covers success', async function () { + const expected = { data: 'blah', statusCode: 200 }; + communication.axios.resolves(expected); + const result = await communication.deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket); + assert.deepStrictEqual(result, expected); + }); + it('covers failure', async function () { + const expectedException = new Error('oh no'); + communication.axios.rejects(expectedException); + await assert.rejects(() => communication.deliverTicket(ticketEndpointUrlObj, resourceUrlObj, subjectUrlObj, ticket), expectedException); + }); + }); // deliverTicket + }); // Communication \ No newline at end of file -- 2.45.2