Skip to content

Commit

Permalink
feat: detect self-issued OP and validate ID Token accordingly
Browse files Browse the repository at this point in the history
closes #220
closes #221
  • Loading branch information
panva committed Jan 10, 2020
1 parent 61c486c commit c5d3158
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ openid-client.
- client_secret_post
- client_secret_jwt
- private_key_jwt
- Consuming Self-Issued OpenID Provider ID Token response
- [RFC8414 - OAuth 2.0 Authorization Server Metadata][feature-oauth-discovery] and [OpenID Connect Discovery 1.0][feature-discovery]
- Discovery of OpenID Provider (Issuer) Metadata
- Discovery of OpenID Provider (Issuer) Metadata via user provided inputs (via [webfinger][documentation-webfinger])
Expand Down
26 changes: 24 additions & 2 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
}

Expand Down
81 changes: 81 additions & 0 deletions test/client/self_issued.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
});

0 comments on commit c5d3158

Please sign in to comment.