From 47c4491ffc0c6ed6685257246cbe465f2b1fa3bd Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 9 Jan 2020 17:46:20 +0100 Subject: [PATCH] feat: detect self-issued OP and validate ID Token accordingly --- lib/client.js | 26 ++++++++++- test/client/self_issued.test.js | 81 +++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 test/client/self_issued.test.js diff --git a/lib/client.js b/lib/client.js index 46cbed5b..6aaaa696 100644 --- a/lib/client.js +++ b/lib/client.js @@ -800,6 +800,7 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base * @api private */ async validateJWT(jwt, expectedAlg, required = ['iss', 'sub', 'aud', 'exp', 'iat']) { + const isSelfIssued = this.issuer.issuer === 'https://self-issued.me'; const timestamp = now(); let header; let payload; @@ -819,6 +820,10 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base }); } + if (isSelfIssued) { + required = [...required, 'sub_jwk']; // eslint-disable-line no-param-reassign + } + required.forEach(verifyPresence.bind(undefined, payload, jwt)); if (payload.iss !== undefined) { @@ -907,13 +912,30 @@ module.exports = (issuer, aadIssValidation = false) => class Client extends Base let key; - if (header.alg.startsWith('HS')) { + if (isSelfIssued) { + try { + assert(isPlainObject(payload.sub_jwk)); + key = jose.JWK.asKey(payload.sub_jwk); + assert.equal(key.type, 'public'); + } catch (err) { + throw new RPError({ + message: 'failed to use sub_jwk claim as an asymmetric JSON Web Key', + jwt, + }); + } + if (key.thumbprint !== payload.sub) { + throw new RPError({ + message: 'failed to match the subject with sub_jwk', + jwt, + }); + } + } else if (header.alg.startsWith('HS')) { key = await this.joseSecret(); } else if (header.alg !== 'none') { key = await this.issuer.queryKeyStore(header); } - if (header.alg === 'none') { + if (!key && header.alg === 'none') { return { protected: header, payload }; } diff --git a/test/client/self_issued.test.js b/test/client/self_issued.test.js new file mode 100644 index 00000000..14b63247 --- /dev/null +++ b/test/client/self_issued.test.js @@ -0,0 +1,81 @@ +const { expect } = require('chai'); +const nock = require('nock'); +const timekeeper = require('timekeeper'); +const jose = require('jose'); + +const { Issuer } = require('../../lib'); + +const fail = () => { throw new Error('expected promise to be rejected'); }; + +describe('Validating Self-Issued OP responses', () => { + afterEach(timekeeper.reset); + afterEach(nock.cleanAll); + + before(function () { + const issuer = new Issuer({ + authorization_endpoint: 'openid:', + issuer: 'https://self-issued.me', + scopes_supported: ['openid', 'profile', 'email', 'address', 'phone'], + response_types_supported: ['id_token'], + subject_types_supported: ['pairwise'], + id_token_signing_alg_values_supported: ['RS256'], + request_object_signing_alg_values_supported: ['none', 'RS256'], + registration_endpoint: 'https://self-issued.me/registration/1.0/', + }); + + const client = new issuer.Client({ + client_id: 'https://rp.example.com/cb', + response_types: ['id_token'], + token_endpoint_auth_method: 'none', + id_token_signed_response_alg: 'ES256', + }); + + Object.assign(this, { issuer, client }); + }); + + const idToken = (claims = {}) => { + const jwk = jose.JWK.generateSync('EC'); + return jose.JWT.sign({ + sub_jwk: jwk.toJWK(), + sub: jwk.thumbprint, + ...claims, + }, jwk, { expiresIn: '2h', issuer: 'https://self-issued.me', audience: 'https://rp.example.com/cb' }); + }; + + describe('consuming an ID Token response', () => { + it('consumes a self-issued response', function () { + const { client } = this; + return client.callback(undefined, { id_token: idToken() }); + }); + + it('expects sub_jwk to be in the ID Token claims', function () { + const { client } = this; + return client.callback(undefined, { id_token: idToken({ sub_jwk: undefined }) }) + .then(fail, (err) => { + expect(err.name).to.equal('RPError'); + expect(err.message).to.equal('missing required JWT property sub_jwk'); + expect(err).to.have.property('jwt'); + }); + }); + + it('expects sub_jwk to be a public JWK', function () { + const { client } = this; + return client.callback(undefined, { id_token: idToken({ sub_jwk: 'foobar' }) }) + .then(fail, (err) => { + expect(err.name).to.equal('RPError'); + expect(err.message).to.equal('failed to use sub_jwk claim as an asymmetric JSON Web Key'); + expect(err).to.have.property('jwt'); + }); + }); + + it('expects sub to be the thumbprint of the sub_jwk', function () { + const { client } = this; + return client.callback(undefined, { id_token: idToken({ sub: 'foo' }) }) + .then(fail, (err) => { + expect(err.name).to.equal('RPError'); + expect(err.message).to.equal('failed to match the subject with sub_jwk'); + expect(err).to.have.property('jwt'); + }); + }); + }); +});