Skip to content

Commit

Permalink
Provides an alternative source for keysets (#202)
Browse files Browse the repository at this point in the history
* getKeysInterceptor

Allow a user to provide an interceptor for providing keys before checking an endpoint.  This will allow users to provide keys from env variables, local files, and temp cache in aws.

* Fix typo

* Fix test

* Fix typo in unrelated test

fixes #197

* Update src/utils.js

Co-authored-by: Adam Mcgrath <adam.mcgrath@auth0.com>
  • Loading branch information
davidpatrick and adamjmcgrath committed Dec 7, 2020
1 parent 6cfa98f commit 4446484
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 32 deletions.
14 changes: 14 additions & 0 deletions README.md
Expand Up @@ -114,6 +114,20 @@ There are two ways to configure the usage of a proxy:
- Provide the ```proxy``` option when initialiting the client as shown above
- Provide the ```HTTP_PROXY```, ```HTTPS_PROXY``` and ```NO_PROXY``` environment variables

### Loading keys from local file, environment variable, or other externals

The `getKeysInterceptor` property can be used to fetch keys before sending a request to the `jwksUri` endpoint. This can be helpful when wanting to load keys from a file, env variable, or an external cache. If a KID cannot be found in the keys returned from the interceptor, it will fallback to the `jwksUri` endpoint. This property will continue to work with the provided LRU cache, if the cache is enabled.

```js
const client = new JwksClient({
jwksUri: 'https://my-enterprise-id-provider/.well-known/jwks.json',
getKeysInterceptor: (cb) => {
const file = fs.readFileSync(jwksFile);
return cb(null, file.keys);
}
});
```

## Running Tests

```
Expand Down
4 changes: 4 additions & 0 deletions index.d.ts
Expand Up @@ -29,9 +29,13 @@ declare namespace JwksRsa {
strictSsl?: boolean;
requestHeaders?: Headers;
timeout?: number;
getKeysInterceptor?(cb: (err: Error | null, keys: SigningKey[]) => void): void;
}

interface ClientOptionsWithObject extends Omit<ClientOptions, 'jwksUri'> {
/**
* @deprecated jwksObject should not be used. Use getKeysInterceptor as a replacement
*/
jwksObject: { keys: SigningKey[] };
}

Expand Down
39 changes: 9 additions & 30 deletions src/JwksClient.js
Expand Up @@ -4,12 +4,12 @@ import JwksError from './errors/JwksError';
import SigningKeyNotFoundError from './errors/SigningKeyNotFoundError';

import {
certToPEM,
rsaPublicKeyToPEM
retrieveSigningKeys
} from './utils';
import {
cacheSigningKey,
rateLimitSigningKey
rateLimitSigningKey,
getKeysInterceptor
} from './wrappers';

export class JwksClient {
Expand All @@ -23,6 +23,10 @@ export class JwksClient {
this.logger = debug('jwks');

// Initialize wrappers.
if (this.options.getKeysInterceptor) {
this.getSigningKey = getKeysInterceptor(this, options);
}

if (this.options.rateLimit) {
this.getSigningKey = rateLimitSigningKey(this, options);
}
Expand All @@ -37,6 +41,7 @@ export class JwksClient {

getKeys(cb) {
if (this.options.jwksObject) {
this.logger('DEPRECATED: jwksObject is deprecated -- use getKeysInterceptor');
this.logger('Returning directly provided keyset.');
return cb(null, this.options.jwksObject.keys);
}
Expand Down Expand Up @@ -74,32 +79,7 @@ export class JwksClient {
return cb(new JwksError('The JWKS endpoint did not contain any keys'));
}

const signingKeys = keys
.filter((key) => {
if(key.kty !== 'RSA') {
return false;
}
if(key.hasOwnProperty('use') && key.use !== 'sig') {
return false;
}
return ((key.x5c && key.x5c.length) || (key.n && key.e));
})
.map(key => {
const jwk = {
kid: key.kid,
alg: key.alg,
nbf: key.nbf
};
const hasCertificateChain = key.x5c && key.x5c.length;
if (hasCertificateChain) {
jwk.publicKey = certToPEM(key.x5c[0]);
jwk.getPublicKey = () => jwk.publicKey;
} else {
jwk.rsaPublicKey = rsaPublicKeyToPEM(key.n, key.e);
jwk.getPublicKey = () => jwk.rsaPublicKey;
}
return jwk;
});
const signingKeys = retrieveSigningKeys(keys);

if (!signingKeys.length) {
return cb(new JwksError('The JWKS endpoint did not contain any signing keys'));
Expand All @@ -112,7 +92,6 @@ export class JwksClient {

getSigningKey = (kid, cb) => {
this.logger(`Fetching signing key for '${kid}'`);

this.getSigningKeys((err, keys) => {
if (err) {
return cb(err);
Expand Down
29 changes: 29 additions & 0 deletions src/utils.js
Expand Up @@ -55,3 +55,32 @@ export function rsaPublicKeyToPEM(modulusB64, exponentB64) {
pem += '\n-----END RSA PUBLIC KEY-----\n';
return pem;
}

export function retrieveSigningKeys(keys) {
return keys
.filter((key) => {
if(key.kty !== 'RSA') {
return false;
}
if(key.hasOwnProperty('use') && key.use !== 'sig') {
return false;
}
return ((key.x5c && key.x5c.length) || (key.n && key.e));
})
.map(key => {
const jwk = {
kid: key.kid,
alg: key.alg,
nbf: key.nbf
};
const hasCertificateChain = key.x5c && key.x5c.length;
if (hasCertificateChain) {
jwk.publicKey = certToPEM(key.x5c[0]);
jwk.getPublicKey = () => jwk.publicKey;
} else {
jwk.rsaPublicKey = rsaPublicKeyToPEM(key.n, key.e);
jwk.getPublicKey = () => jwk.rsaPublicKey;
}
return jwk;
});
}
1 change: 1 addition & 0 deletions src/wrappers/index.js
@@ -1,2 +1,3 @@
export cacheSigningKey from './cache';
export rateLimitSigningKey from './rateLimit';
export getKeysInterceptor from './interceptor';
32 changes: 32 additions & 0 deletions src/wrappers/interceptor.js
@@ -0,0 +1,32 @@
import { retrieveSigningKeys } from '../utils';

/**
* Uses getKeysInterceptor to allow users to retrieve keys from a file,
* external cache, or provided object before falling back to the jwksUri endpoint
*/
export default function(client, { getKeysInterceptor } = options) {
const getSigningKey = client.getSigningKey;

return (kid, cb) => {
getKeysInterceptor((err, keys) => {
if (err) {
return cb(err);
}

let signingKeys;
if (keys && keys.length) {
signingKeys = retrieveSigningKeys(keys);
}

if (signingKeys && signingKeys.length) {
const key = signingKeys.find(k => !kid || k.kid === kid);

if (key) {
return cb(null, key);
}
}

return getSigningKey(kid, cb);
});
};
}
61 changes: 60 additions & 1 deletion tests/jwksClient.tests.js
Expand Up @@ -80,7 +80,7 @@ describe('JwksClient', () => {

it('should set request agentOptions when provided', done => {
nock(jwksHost)
.get('./well-known/jwks.json')
.get('/.well-known/jwks.json')
.reply(function() {
expect(this.req.agentOptions).not.to.be.null;
expect(this.req.agentOptions['ca']).to.be.equal('loadCA()');
Expand Down Expand Up @@ -565,6 +565,65 @@ describe('JwksClient', () => {
done();
});
});

describe('#getKeysInterceptor', () => {
it('should return a matching key provided from the interceptor', done => {
const client = new JwksClient({
jwksUri: 'http://invalidUri',
getKeysInterceptor: (cb) => cb(null, jwksObject.keys)
});

client.getSigningKey('abc', (err, key) => {
expect(err).to.be.null;
expect(key).not.to.be.null;
expect(key.kid).to.equal(jwksObject.keys[0].kid);
done();
});
});

it('should fallback to using the jwksUri when key is not found in the interceptor', done => {
nock(jwksHost)
.get('/.well-known/jwks.json')
.reply(200, {
keys: [
{
kid: 'nonExistentKid',
use: 'sig',
kty: 'RSA',
e: 'AQAB',
n:
'tLDZVZ2Eq_DFwNp24yeSq_Ha0MYbYOJs_WXIgVxQGabu5cZ9561OUtYWdB6xXXZLaZxFG02P5U2rC_CT1r0lPfC_KHYrviJ5Y_Ekif7iFV_1omLAiRksQziwA1i-hND32N5kxwEGNmZViVjWMBZ43wbIdWss4IMhrJy1WNQ07Fqp1Ee6o7QM1hTBve7bbkJkUAfjtC7mwIWqZdWoYIWBTZRXvhMgs_Aeb_pnDekosqDoWQ5aMklk3NvaaBBESqlRAJZUUf5WDFoJh7yRELOFF4lWJxtArTEiQPWVTX6PCs0klVPU6SRQqrtc4kKLCp1AC5EJqPYRGiEJpSz2nUhmAQ'
}
]
});

const client = new JwksClient({
jwksUri: `${jwksHost}/.well-known/jwks.json`,
getKeysInterceptor: (cb) => cb(null, jwksObject.keys)
});

client.getSigningKey('nonExistentKid', (err, key) => {
expect(err).to.be.null;
expect(key).not.to.be.null;
expect(key.kid).to.equal('nonExistentKid');
done();
});
});
});

it('should handle errors passed from the interceptor', done => {
const error = new Error('interceptor error');
const client = new JwksClient({
jwksUri: 'http://invalidUri',
getKeysInterceptor: (cb) => cb(error)
});

client.getSigningKey('abc', (err) => {
expect(err).not.to.be.null;
expect(err).to.equal(error);
done();
});
});
});

describe('#getSigningKeysAsync', () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/mocks/jwks.json
@@ -1 +1 @@
{"keys": [{"alg":"RS256","kty":"RSA","use":"sig","x5c":["pk1"],"kid":"ABC"},{"alg":"RS256","kty":"RSA","use":"sig","x5c":[],"kid":"123"}]}
{"keys":[{"alg":"RS256","kty":"RSA","use":"sig","x5c":["MIICsDCCAhmgAwIBAgIJAP0uzO56NPNDMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYwODAyMTIyMjMyWhcNMTYwOTAxMTIyMjMyWjBFMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQABo4GnMIGkMB0GA1UdDgQWBBR7ZjPnt+i/E8VUy4tinxi0+H5vbTB1BgNVHSMEbjBsgBR7ZjPnt+i/E8VUy4tinxi0+H5vbaFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAP0uzO56NPNDMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAnMA5ZAyEQgXrUl6JT/JFcg6HGXj9yTy71EEMVp3Md3B8WwDvs+di4JFcq8FKSoGtTY4Pb5WE9QVUAmwEsSQoETNYW3quRmYJCkpIHWnvUW/OAf2/Ejr6zXquhBC6WoCeKQuesMvo2qO1rStCUWahUh2/RQt9XozEWPWJ9Oe6a7c="],"kid":"abc"},{"alg":"RS256","kty":"RSA","use":"sig","x5c":["MIICsDCCAhmgAwIBAgIJAP0uzO56NPNDMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYwODAyMTIyMjMyWhcNMTYwOTAxMTIyMjMyWjBFMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugdUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQABo4GnMIGkMB0GA1UdDgQWBBR7ZjPnt+i/E8VUy4tinxi0+H5vbTB1BgNVHSMEbjBsgBR7ZjPnt+i/E8VUy4tinxi0+H5vbaFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAP0uzO56NPNDMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAnMA5ZAyEQgXrUl6JT/JFcg6HGXj9yTy71EEMVp3Md3B8WwDvs+di4JFcq8FKSoGtTY4Pb5WE9QVUAmwEsSQoETNYW3quRmYJCkpIHWnvUW/OAf2/Ejr6zXquhBC6WoCeKQuesMvo2qO1rStCUWahUh2/RQt9XozEWPWJ9Oe6a7c="],"kid":"xyz"}]}

0 comments on commit 4446484

Please sign in to comment.