add support for redeeming ticket, and sending ticket with issuer
authorJustin Wind <justin.wind+git@gmail.com>
Sat, 11 Nov 2023 01:51:18 +0000 (17:51 -0800)
committerJustin Wind <justin.wind+git@gmail.com>
Sat, 11 Nov 2023 01:51:18 +0000 (17:51 -0800)
README.md
lib/communication.js
test/lib/communication.js

index b3f85bf3bc1063f9d397694852c5459ccdd9c7ac..9a67a6d2ffa05db8463c89820d08ec8e1cabdb49 100644 (file)
--- 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.
index d160707ee3bb157559bf594da2f16d12cfcf0db3..6d2334a246f633bfea998820a7507c77829e2654 100644 (file)
@@ -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<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!
@@ -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<Response>}
    */
-  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<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;
index 332aa64921cd383d37cf73b155f2d6e754272949..42b5116f7d7043c594d8b12ac6e3335e8a4fb172 100644 (file)
@@ -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