--- /dev/null
+{
+ "reporter": [
+ "lcov",
+ "text"
+ ]
+}
- `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.
- `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.
/**
* Pick out useful axios response fields.
- * @param {*} res
- * @returns
+ * @param {AxiosResponse} res
+ * @returns {Object}
*/
const axiosResponseLogData = (res) => {
const data = pick(res, [
}
+/**
+ * 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,
pick,
setSymmetricDifference,
properURLComponentName,
+ formData,
};
\ No newline at end of file
const noDotPathRE = /(\/\.\/|\/\.\.\/)/;
const v6HostRE = /\[[0-9a-f:]+\]/;
const loopback4 = new Address4('127.0.0.0/8');
+const scopeSplitRE = / +/;
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) {
return (status >= 200 && status < 300) || status == 401;
}
+
/**
* A request config skeleton.
* @param {String} method
* 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');
* 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');
* N.B. Sets isLoopback on urlObj
* @param {URL} urlObj
* @param {Boolean} allowLoopback
+ * @returns {Promise<void>}
*/
static async _urlNamedHost(urlObj, allowLoopback, resolveHostname) {
let address;
* @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);
* @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);
* @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
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
issuer: 'issuer',
authorizationEndpoint: 'authorization_endpoint',
tokenEndpoint: 'token_endpoint',
+ ticketEndpoint: 'ticket_endpoint',
introspectionEndpoint: 'introspection_endpoint',
introspectionEndpointAuthMethodsSupported: 'introspection_endpoint_auth_methods_supported',
revocationEndpoint: 'revocation_endpoint',
});
// 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
}
/**
* 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
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`,
});
}
}
+
+ /**
+ * 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;
});
}); // 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
const stubLogger = require('../stub-logger');
const testData = require('../test-data/communication');
-const noExpectedException = 'did not get expected exception';
-
describe('Communication', function () {
let communication, options;
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
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
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 () {
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/';
});
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' }]);
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('+', '-');
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