X-Git-Url: https://git.squeep.com/?a=blobdiff_plain;f=lib%2Fcommunication.js;h=82400c354e2864d5124b605fcaf965169188ecf8;hb=786217e7862a4d956c6510c6a171c86ab160fda6;hp=0116548d6539eb70fe229df71aee35cf86305034;hpb=cf9590ecbcd4b0a7c01f153cacade619518f84f0;p=squeep-indieauth-helper diff --git a/lib/communication.js b/lib/communication.js index 0116548..82400c3 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -2,7 +2,6 @@ const axios = require('axios'); const { mf2 } = require('microformats-parser'); -const { base64ToBase64URL } = require('@squeep/base64url'); const { parse: parseLinkHeader } = require('@squeep/web-linking'); const { Iconv } = require('iconv'); const { version: packageVersion, name: packageName } = require('../package.json'); @@ -22,6 +21,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 +52,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')); + return hash.digest('base64url'); } + + /** + * @typedef PKCEData + * @property {String} codeChallengeMethod + * @property {String} codeVerifier + * @property {String} codeChallenge + */ /** * Create a code verifier and its challenge. - * @param {Number} length - * @returns {Object} + * @param {Number} length of verifier string, between 43 and 128 + * @returns {Promise} */ static async generatePKCE(length = 128) { if (length < 43 || length > 128) { @@ -70,7 +82,7 @@ class Communication { const bufferLength = Math.floor(length * 3 / 4); const randomBuffer = await randomBytesAsync(bufferLength); - const verifier = base64ToBase64URL(randomBuffer.toString('base64')); + const verifier = randomBuffer.toString('base64url'); const challenge = Communication._challengeFromVerifier(verifier); @@ -124,6 +136,18 @@ class Communication { } + /** + * Valid response statuses. + * Allow 401 as a workaround for one specific client which return such on + * its client identifier endpoint when not yet authenticated. + * @param {Number} status + * @returns {Boolean} + */ + static _validateStatus(status) { + return (status >= 200 && status < 300) || status == 401; + } + + /** * A request config skeleton. * @param {String} method @@ -144,6 +168,8 @@ class Communication { responseType: 'text', // So force the matter by eliding all response transformations transformResponse: [ (res) => res ], + + validateStatus: Communication._validateStatus, }; Object.entries(params).map(([k, v]) => config.params.set(k, v)); return config; @@ -192,9 +218,10 @@ class Communication { /** - * Parse and add any header link relations to mf data. + * Parse and add any header link relations from response to microformat data. * @param {Object} microformat * @param {Object} response + * @param {Object} response.headers */ _mergeLinkHeader(microformat, response) { const _scope = _fileScope('_mergeLinkHeader'); @@ -247,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'); @@ -304,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'); @@ -377,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; @@ -466,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); @@ -506,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); @@ -606,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 @@ -656,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 @@ -684,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', @@ -702,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 } @@ -717,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 @@ -727,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`, }); @@ -755,6 +788,87 @@ class Communication { } } + + /** + * Verify a token with an IdP endpoint, using the Authentication header supplied. + * @param {URL} introspectionUrlObj + * @param {String} authorizationHeader + * @param {String} token + */ + async introspectToken(introspectionUrlObj, authorizationHeader, token) { + const _scope = _fileScope('introspectToken'); + + const formData = common.formData({ token }); + const postIntrospectConfig = Communication._axiosConfig('POST', introspectionUrlObj, formData, {}, { + [Enum.Header.Authorization]: authorizationHeader, + [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;