add some support for tickets, introspection method, minor fixes
authorJustin Wind <justin.wind+git@gmail.com>
Sat, 12 Nov 2022 19:35:47 +0000 (11:35 -0800)
committerJustin Wind <justin.wind+git@gmail.com>
Sat, 12 Nov 2022 19:35:47 +0000 (11:35 -0800)
.nycrc.json [new file with mode: 0644]
README.md
lib/common.js
lib/communication.js
test/lib/common.js
test/lib/communication.js

diff --git a/.nycrc.json b/.nycrc.json
new file mode 100644 (file)
index 0000000..497d8af
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "reporter": [
+    "lcov",
+    "text"
+  ]
+}
index e2df28d1b7e24391845b58cbd588c4661d2d226c..fe3e36296cee72e413ba9d4b382c4a24c0f1a0ba 100644 (file)
--- 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.
index dc0585471303780c5720ab0d5f62fdf55d11d059..f199b4947ed1ba537a29350c617322cf64678512 100644 (file)
@@ -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
index 8f6110f6e3a9c3438a0855ead9af3db3eb59b0e9..d52d784690917d503e852309f4256650cfc4c60e 100644 (file)
@@ -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<PKCEData>}
    */
   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<Object>}
    */
   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<Object>}
    */
   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<void>}
    */
   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<void>}
    */
   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<URL>}
    */
   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<AxiosResponse>}
+   */
+  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;
index fe0240718bf8192f8e3761219ef33fd665e8d544..7ebc91b45b6cf81c47306b48a7cfc62c5ceffe78 100644 (file)
@@ -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
index 9781cd65b05088664a1d1dee04282d12ff31bf05..c79d2613f5388485862a3eb83ef7d84304b8582c 100644 (file)
@@ -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