diff --git a/packages/jwt-verifier/README.md b/packages/jwt-verifier/README.md index 465f74dad..6fbe0e8af 100644 --- a/packages/jwt-verifier/README.md +++ b/packages/jwt-verifier/README.md @@ -3,11 +3,19 @@ [![npm version](https://img.shields.io/npm/v/@okta/jwt-verifier.svg?style=flat-square)](https://www.npmjs.com/package/@okta/jwt-verifier) [![build status](https://img.shields.io/travis/okta/okta-oidc-js/master.svg?style=flat-square)](https://travis-ci.org/okta/okta-oidc-js) -This library verifies Okta access tokens (issued by [Okta Custom Authorization servers](https://developer.okta.com/docs/concepts/auth-servers/) by fetching the public keys from the JWKS endpoint of the authorization server. If the access token is valid it will be converted to a JSON object and returned to your code. +This library verifies Okta access and ID tokens by fetching the public keys from the JWKS endpoint of the authorization server. -This library does not yet verify id tokens. You can learn about [access tokens](https://developer.okta.com/docs/reference/api/oidc/#access-token) and [id tokens](https://developer.okta.com/docs/reference/api/oidc/#id-token) in our [OIDC and OAuth 2.0 API Referece](https://developer.okta.com/docs/reference/api/oidc/). +> This library is for Node.js applications and will not compile into a front-end application. If you need to work with tokens in front-end applications, please see [okta-auth-js](https://github.com/okta/okta-auth-js). + +Using Express? Our [Express Resource Server Example](https://github.com/okta/samples-nodejs-express-4/tree/master/resource-server) will show you how to use this library in your Express application. + +## Access Tokens -> Okta Custom Authorization Servers require the API Access Management license. If you are using Okta Org Authorization Servers (which don’t require API Access Management) you can manually validate against the /introspect endpoint ( https://developer.okta.com/docs/reference/api/oidc/#introspect ). +This library verifies Okta access tokens (issued by [Okta Custom Authorization servers](https://developer.okta.com/docs/concepts/auth-servers/#custom-authorization-server)) by fetching the public keys from the JWKS endpoint of the authorization server. If the access token is valid it will be converted to a JSON object and returned to your code. + +You can learn about [access tokens](https://developer.okta.com/docs/reference/api/oidc/#access-token), [scopes](https://developer.okta.com/docs/reference/api/oidc/#scopes) and [claims](https://developer.okta.com/docs/reference/api/oidc/#claims) in our [OIDC and OAuth 2.0 API Referece](https://developer.okta.com/docs/reference/api/oidc/). + +> Okta Custom Authorization Servers require the [API Access Management](https://developer.okta.com/docs/concepts/api-access-management/) license. If you are using Okta Org Authorization Servers (which don’t require API Access Management) you can manually validate against the /introspect endpoint ( https://developer.okta.com/docs/reference/api/oidc/#introspect ). For any access token to be valid, the following are asserted: * Signature is valid (the token was signed by a private key which has a corresponding public key in the JWKS response from the authorization server). @@ -16,11 +24,23 @@ For any access token to be valid, the following are asserted: * The `iss` claim matches the issuer the verifier is constructed with. * Any custom claim assertions that have been configured. -> This library is for Node.js applications and will not compile into a front-end application. If you need to work with tokens in front-end applications, please see [okta-auth-js](https://github.com/okta/okta-auth-js). +To learn more about verification cases and Okta's tokens please read [Validate Access Tokens](https://developer.okta.com/docs/guides/validate-access-tokens/go/overview/). -Using Express? Our [Express Resource Server Example](https://github.com/okta/samples-nodejs-express-4/tree/master/resource-server) will show you how to use this library in your Express application. +## ID Tokens + +This library verifies Okta ID tokens (issued by [Okta Custom Authorization servers](https://developer.okta.com/docs/concepts/auth-servers/#custom-authorization-server) or [Okta Org Authorization Server](https://developer.okta.com/docs/concepts/auth-servers/#org-authorization-server)) by fetching the public keys from the JWKS endpoint of the authorization server. If the token is valid it will be converted to a JSON object and returned to your code. + +You can learn about [ID tokens](https://developer.okta.com/docs/reference/api/oidc/#id-token), [scopes](https://developer.okta.com/docs/reference/api/oidc/#scopes) and [claims](https://developer.okta.com/docs/reference/api/oidc/#claims) in our [OIDC and OAuth 2.0 API Referece](https://developer.okta.com/docs/reference/api/oidc/). + +For any ID token to be valid, the following are asserted: +* Signature is valid (the token was signed by a private key which has a corresponding public key in the JWKS response from the authorization server). +* ID token is not expired (requires local system time to be in sync with Okta, checks the `exp` claim of the ID token). +* The `aud` claim matches the expected client ID passed to `verifyIdToken()`. +* The `iss` claim matches the issuer the verifier is constructed with. +* The `nonce` claim matches the expected nonce. +* Any custom claim assertions that have been configured. -To learn more about verification cases and Okta's tokens please read [Working With OAuth 2.0 Tokens](https://developer.okta.com/authentication-guide/tokens/) +To learn more about verification cases and Okta's tokens please read [Validate ID Tokens](https://developer.okta.com/docs/guides/validate-id-tokens/overview/). ## Upgrading @@ -42,7 +62,7 @@ const oktaJwtVerifier = new OktaJwtVerifier({ }); ``` -With a verifier, you can now verify access tokens: +### Verify access tokens ```javascript oktaJwtVerifier.verifyAccessToken(accessTokenString, expectedAud) @@ -68,6 +88,22 @@ oktaJwtVerifier.verifyAccessToken(accessTokenString, [ 'api://special', 'api://d .catch(err => console.warn('token failed validation') ); ``` +### Verify ID tokens + +```javascript +oktaJwtVerifier.verifyIdToken(idTokenString, expectedClientId, expectedNonce) +.then(jwt => { + // the token is valid (per definition of 'valid' above) + console.log(jwt.claims); +}) +.catch(err => { + // a validation failed, inspect the error +}); +``` + +The expected client ID passed to `verifyIdToken()` is required. Expected nonce value is optional and required if the claim is present in the token body. + + ## Custom Claims Assertions For basic use cases, you can ask the verifier to assert a custom set of claims. For example, if you need to assert that this JWT was issued for a given client id: @@ -82,7 +118,7 @@ const verifier = new OktaJwtVerifier({ }); ``` -Validation fails and an error is returned if an access token does not have the configured claim. +Validation fails and an error is returned if the token does not have the configured claim. For more complex use cases, you can ask the verifier to assert that a claim includes one or more values. This is useful for array type claims as well as claims that have space-separated values in a string. diff --git a/packages/jwt-verifier/lib.js b/packages/jwt-verifier/lib.js index d503a2979..71a21b782 100644 --- a/packages/jwt-verifier/lib.js +++ b/packages/jwt-verifier/lib.js @@ -89,12 +89,36 @@ function verifyAudience(expected, aud) { } } +function verifyClientId(expected, aud) { + if( !expected ) { + throw new Error('expected client id is required'); + } + + assertClientId(expected); + + if ( aud !== expected ) { + throw new Error(`audience claim ${aud} does not match expected client id: ${expected}`); + } +} + function verifyIssuer(expected, issuer) { if( issuer !== expected ) { throw new Error(`issuer ${issuer} does not match expected issuer: ${expected}`); } } +function verifyNonce(expected, nonce) { + if( nonce && !expected ) { + throw new Error('expected nonce is required'); + } + if (!nonce && expected) { + throw new Error(`nonce claim is missing but expected: ${expected}`); + } + if( nonce && expected && nonce !== expected ) { + throw new Error(`nonce claim ${nonce} does not match expected nonce: ${expected}`); + } +} + class OktaJwtVerifier { constructor(options = {}) { // Assert configuration options exist and are well-formed (not necessarily correct!) @@ -114,16 +138,20 @@ class OktaJwtVerifier { rateLimit: true }); this.verifier = nJwt.createVerifier().setSigningAlgorithm('RS256').withKeyResolver((kid, cb) => { - this.jwksClient.getSigningKey(kid, (err, key) => { - cb(err, key && (key.publicKey || key.rsaPublicKey)); - }); + if (kid) { + this.jwksClient.getSigningKey(kid, (err, key) => { + cb(err, key && (key.publicKey || key.rsaPublicKey)); + }); + } else { + cb("No KID specified", null); + } }); } - async verifyAsPromise(accessTokenString) { + async verifyAsPromise(tokenString) { return new Promise((resolve, reject) => { // Convert to a promise - this.verifier.verify(accessTokenString, (err, jwt) => { + this.verifier.verify(tokenString, (err, jwt) => { if (err) { return reject(err); } @@ -151,6 +179,24 @@ class OktaJwtVerifier { return jwt; } + + async verifyIdToken(idTokenString, expectedClientId, expectedNonce) { + // njwt verifies expiration and signature. + // We require RS256 in the base verifier. + // Remaining to verify: + // - audience claim (must match client id) + // - issuer claim + // - nonce claim (if present) + // - any custom claims passed in + + const jwt = await this.verifyAsPromise(idTokenString); + verifyClientId(expectedClientId, jwt.claims.aud); + verifyIssuer(this.issuer, jwt.claims.iss); + verifyNonce(expectedNonce, jwt.claims.nonce); + verifyAssertedClaims(this, jwt.claims); + + return jwt; + } } module.exports = OktaJwtVerifier; diff --git a/packages/jwt-verifier/test/internal-ci/token.spec.js b/packages/jwt-verifier/test/internal-ci/token.spec.js index 3360312e1..e21399d4b 100644 --- a/packages/jwt-verifier/test/internal-ci/token.spec.js +++ b/packages/jwt-verifier/test/internal-ci/token.spec.js @@ -15,23 +15,25 @@ const constants = require('../constants') const LONG_TIMEOUT = 15000; const OktaJwtVerifier = require('../../lib'); -const getAccessToken = require('../util').getAccessToken; +const { getAccessToken, getIdToken } = require('../util'); // These need to be exported in the environment, from a working Okta org const ISSUER = constants.ISSUER; const CLIENT_ID = constants.CLIENT_ID; const USERNAME = constants.USERNAME; const PASSWORD = constants.PASSWORD; -const REDIRECT_URI = constants.REDIRECT_URI -const OKTA_TESTING_DISABLEHTTPSCHECK = constants.OKTA_TESTING_DISABLEHTTPSCHECK +const REDIRECT_URI = constants.REDIRECT_URI; +const OKTA_TESTING_DISABLEHTTPSCHECK = constants.OKTA_TESTING_DISABLEHTTPSCHECK; +const NONCE = 'foo'; -// Used to get an access token from the AS -const issuer1AccessTokenParams = { +// Used to get an access token and id token from the AS +const issuer1TokenParams = { ISSUER, CLIENT_ID, USERNAME, PASSWORD, - REDIRECT_URI + REDIRECT_URI, + NONCE }; describe('Access token test with api call', () => { @@ -44,10 +46,29 @@ describe('Access token test with api call', () => { }); it('should allow me to verify Okta access tokens', () => { - return getAccessToken(issuer1AccessTokenParams) + return getAccessToken(issuer1TokenParams) .then(accessToken => verifier.verifyAccessToken(accessToken, expectedAud)) .then(jwt => { expect(jwt.claims.iss).toBe(ISSUER); }); }, LONG_TIMEOUT); }); + +describe('ID token test with api call', () => { + const expectedClientId = CLIENT_ID; + const verifier = new OktaJwtVerifier({ + issuer: ISSUER, + testing: { + disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK + } + }); + + it('should allow me to verify Okta ID tokens', () => { + return getIdToken(issuer1TokenParams) + .then(idToken => verifier.verifyIdToken(idToken, expectedClientId, NONCE)) + .then(jwt => { + expect(jwt.claims.iss).toBe(ISSUER); + }); + }, LONG_TIMEOUT); +}); + diff --git a/packages/jwt-verifier/test/spec/verifier.spec.js b/packages/jwt-verifier/test/spec/verifiy_access_token.spec.js similarity index 61% rename from packages/jwt-verifier/test/spec/verifier.spec.js rename to packages/jwt-verifier/test/spec/verifiy_access_token.spec.js index c194fb039..7ecb531a8 100644 --- a/packages/jwt-verifier/test/spec/verifier.spec.js +++ b/packages/jwt-verifier/test/spec/verifiy_access_token.spec.js @@ -10,23 +10,18 @@ * See the License for the specific language governing permissions and limitations under the License. */ -const fs = require('fs'); -const njwt = require('njwt'); const nock = require('nock'); -const path = require('path'); const tk = require('timekeeper'); -const constants = require('../constants') +const constants = require('../constants'); -const OktaJwtVerifier = require('../../lib'); -const getAccessToken = require('../util').getAccessToken; +const { getAccessToken, createToken, createVerifier, createCustomClaimsVerifier, rsaKeyPair } = require('../util'); // These need to be exported in the environment, from a working Okta org const ISSUER = constants.ISSUER; const CLIENT_ID = constants.CLIENT_ID; const USERNAME = constants.USERNAME; const PASSWORD = constants.PASSWORD; -const REDIRECT_URI = constants.REDIRECT_URI -const OKTA_TESTING_DISABLEHTTPSCHECK = constants.OKTA_TESTING_DISABLEHTTPSCHECK +const REDIRECT_URI = constants.REDIRECT_URI; // Some tests makes LIVE requests using getAccessToken(). These may take much longer than normal tests const LONG_TIMEOUT = 60000; @@ -40,25 +35,10 @@ const issuer1AccessTokenParams = { REDIRECT_URI }; -const NODE_MODULES = path.resolve(__dirname, '../../node_modules'); -const publicKeyPath = path.normalize(path.join(NODE_MODULES, '/njwt/test/rsa.pub')); -const privateKeyPath = path.normalize(path.join(NODE_MODULES, '/njwt/test/rsa.priv')); -const wrongPublicKeyPath = path.normalize(path.join(__dirname, '../keys/rsa-fake.pub')); -const rsaKeyPair = { - public: fs.readFileSync(publicKeyPath, 'utf8'), - private: fs.readFileSync(privateKeyPath, 'utf8'), - wrongPublic: fs.readFileSync(wrongPublicKeyPath, 'utf8') -}; - -describe('Jwt Verifier', () => { +describe('Jwt Verifier - Verify Access Token', () => { describe('Access token tests with api calls', () => { const expectedAud = 'api://default'; - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } - }); + const verifier = createVerifier(); it('should allow me to verify Okta access tokens', () => { return getAccessToken(issuer1AccessTokenParams) @@ -75,12 +55,7 @@ describe('Jwt Verifier', () => { .then(accessToken => verifier.verifyAccessToken(accessToken, expectedAud)) .then(jwt => { // Create an access token with the same claims and kid, then re-sign it with another RSA private key - this should fail - const token = new njwt.Jwt(jwt.claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setIssuer(ISSUER) - .setHeader('kid', jwt.header.kid) - .compact(); + const token = createToken(jwt.claims, { kid: jwt.header.kid }); return verifier.verifyAccessToken(token, expectedAud) .catch(err => expect(err.message).toBe('Signature verification failed')); }); @@ -91,11 +66,7 @@ describe('Jwt Verifier', () => { .then(accessToken => verifier.verifyAccessToken(accessToken, expectedAud)) .then(jwt => { // Create an access token that does not have a kid - const token = new njwt.Jwt(jwt.claims) - .setIssuer(ISSUER) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .compact(); + const token = createToken(jwt.claims); return verifier.verifyAccessToken(token, expectedAud) .catch(err => expect(err.message).toBe('Error while resolving signing key for kid "undefined"')); }); @@ -106,11 +77,7 @@ describe('Jwt Verifier', () => { .then(accessToken => verifier.verifyAccessToken(accessToken, expectedAud)) .then(jwt => { // Create an access token with the same claims but a kid that will not resolve - const token = new njwt.Jwt(jwt.claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', 'foo') - .compact(); + const token = createToken(jwt.claims, { kid: 'foo' }); return verifier.verifyAccessToken(token, expectedAud) .catch(err => expect(err.message).toBe('Error while resolving signing key for kid "foo"')); }); @@ -137,14 +104,10 @@ describe('Jwt Verifier', () => { }, LONG_TIMEOUT); it('should allow me to assert custom claims', () => { - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, + const verifier = createVerifier({ assertClaims: { cid: 'baz', foo: 'bar' - }, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK } }); return getAccessToken(issuer1AccessTokenParams) @@ -162,12 +125,8 @@ describe('Jwt Verifier', () => { }, LONG_TIMEOUT); it('should cache the jwks for the configured amount of time', () => { - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - cacheMaxAge: 500, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + const verifier = createVerifier({ + cacheMaxAge: 500 }); return getAccessToken(issuer1AccessTokenParams) .then(accessToken => { @@ -198,12 +157,8 @@ describe('Jwt Verifier', () => { }, LONG_TIMEOUT); it('should rate limit jwks endpoint requests on cache misses', () => { - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - jwksRequestsPerMinute: 2, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + const verifier = createVerifier({ + jwksRequestsPerMinute: 2 }); return getAccessToken(issuer1AccessTokenParams) .then((accessToken => { @@ -211,11 +166,7 @@ describe('Jwt Verifier', () => { return verifier.verifyAccessToken(accessToken, expectedAud) .then(jwt => { // Create an access token with the same claims but a kid that will not resolve - const token = new njwt.Jwt(jwt.claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', 'foo') - .compact(); + const token = createToken(jwt.claims, { kid: 'foo' }); return verifier.verifyAccessToken(token, expectedAud) .catch(err => verifier.verifyAccessToken(token, expectedAud)) .catch(err => { @@ -236,22 +187,14 @@ describe('Jwt Verifier', () => { }; it('fails if the signature is invalid', () => { - const claims = { + const token = createToken({ aud: 'http://myapp.com/', - }; - const token = new njwt.Jwt(claims) - .setIssuer(ISSUER) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.wrongPublic) - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + iss: ISSUER, + }, { + kid: rsaKeyPair.wrongPublic, }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); return verifier.verifyAccessToken(token, 'http://myapp.com/') @@ -262,48 +205,31 @@ describe('Jwt Verifier', () => { }); it('passes if the signature is valid', () => { - const claims = { + const token = createToken({ aud: 'http://myapp.com/', iss: ISSUER, - }; - const token = new njwt.Jwt(claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.public) - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + }, { + kid: rsaKeyPair.public }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); return verifier.verifyAccessToken(token, 'http://myapp.com/'); }); it('fails if iss claim does not match verifier issuer', () => { - const claims = { + const token = createToken({ aud: 'http://myapp.com/', iss: 'not-the-issuer', - }; - - const token = new njwt.Jwt(claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.public) // For override of key retrieval below - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + }, { + kid: rsaKeyPair.public // For override of key retrieval below }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); - return verifier.verifyAccessToken(token, claims.aud) + return verifier.verifyAccessToken(token, 'http://myapp.com/') .then( () => { throw new Error('invalid issuer did not throw an error'); } ) .catch( err => { expect(err.message).toBe(`issuer not-the-issuer does not match expected issuer: ${ISSUER}`); @@ -311,23 +237,14 @@ describe('Jwt Verifier', () => { }); it('fails when no audience expectation is passed', () => { - const claims = { + const token = createToken({ aud: 'http://any-aud.com/', iss: ISSUER, - }; - - const token = new njwt.Jwt(claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.public) // For override of key retrieval below - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + }, { + kid: rsaKeyPair.public // For override of key retrieval below }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); return verifier.verifyAccessToken(token) @@ -338,69 +255,42 @@ describe('Jwt Verifier', () => { }); it('passes when given an audience matching expectation string', () => { - const claims = { + const token = createToken({ aud: 'http://myapp.com/', iss: ISSUER, - }; - - const token = new njwt.Jwt(claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.public) // For override of key retrieval below - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + }, { + kid: rsaKeyPair.public // For override of key retrieval below }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); return verifier.verifyAccessToken(token, 'http://myapp.com/'); }); it('passes when given an audience matching expectation array', () => { - const claims = { + const token = createToken({ aud: 'http://myapp.com/', iss: ISSUER, - }; - - const token = new njwt.Jwt(claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.public) // For override of key retrieval below - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + }, { + kid: rsaKeyPair.public // For override of key retrieval below }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); return verifier.verifyAccessToken(token, [ 'one', 'http://myapp.com/', 'three'] ); }); it('fails with a invalid audience when given a valid expectation', () => { - const claims = { + const token = createToken({ aud: 'http://wrong-aud.com/', iss: ISSUER, - }; - - const token = new njwt.Jwt(claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.public) // For override of key retrieval below - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + }, { + kid: rsaKeyPair.public // For override of key retrieval below }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); return verifier.verifyAccessToken(token, 'http://myapp.com/') @@ -411,23 +301,14 @@ describe('Jwt Verifier', () => { }); it('fails with a invalid audience when given an array of expectations', () => { - const claims = { + const token = createToken({ aud: 'http://wrong-aud.com/', iss: ISSUER, - }; - - const token = new njwt.Jwt(claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.public) // For override of key retrieval below - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + }, { + kid: rsaKeyPair.public // For override of key retrieval below }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); return verifier.verifyAccessToken(token, ['one', 'http://myapp.com/', 'three']) @@ -438,23 +319,14 @@ describe('Jwt Verifier', () => { }); it('fails when given an empty array of audience expectations', () => { - const claims = { + const token = createToken({ aud: 'http://any-aud.com/', iss: ISSUER, - }; - - const token = new njwt.Jwt(claims) - .setSigningAlgorithm('RS256') - .setSigningKey(rsaKeyPair.private) - .setHeader('kid', rsaKeyPair.public) // For override of key retrieval below - .compact(); - - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } + }, { + kid: rsaKeyPair.public // For override of key retrieval below }); + + const verifier = createVerifier(); mockKidAsKeyFetch(verifier); return verifier.verifyAccessToken(token, []) @@ -467,31 +339,18 @@ describe('Jwt Verifier', () => { describe('Access Token custom claim tests with stubs', () => { - const otherClaims = { iss: ISSUER, aud: 'http://myapp.com/', }; - const verifier = new OktaJwtVerifier({ - issuer: ISSUER, - testing: { - disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK - } - }); + const verifier = createVerifier(); it('should only allow includes operator for custom claims', () => { verifier.claimsToAssert = {'groups.blarg': 'Everyone'}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - groups: ['Everyone', 'Another'] - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another'] + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) .catch(err => expect(err.message).toBe( @@ -501,16 +360,9 @@ describe('Jwt Verifier', () => { it('should succeed in asserting claims where includes is flat, claim is array', () => { verifier.claimsToAssert = {'groups.includes': 'Everyone'}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - groups: ['Everyone', 'Another'] - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another'] + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) .then(jwt => expect(jwt.claims.groups).toEqual(['Everyone', 'Another'])); @@ -518,16 +370,9 @@ describe('Jwt Verifier', () => { it('should succeed in asserting claims where includes is flat, claim is flat', () => { verifier.claimsToAssert = {'scp.includes': 'promos:read'}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - scp: 'promos:read promos:write' - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + scp: 'promos:read promos:write' + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) .then(jwt => expect(jwt.claims.scp).toBe('promos:read promos:write')); @@ -535,18 +380,12 @@ describe('Jwt Verifier', () => { it('should fail in asserting claims where includes is flat, claim is array', () => { verifier.claimsToAssert = {'groups.includes': 'Yet Another'}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - groups: ['Everyone', 'Another'] - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another'] + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) + .then( () => { throw new Error(`Invalid 'groups' claim was accepted`) } ) .catch(err => expect(err.message).toBe( `claim 'groups' value 'Everyone,Another' does not include expected value 'Yet Another'` )); @@ -555,18 +394,12 @@ describe('Jwt Verifier', () => { it('should fail in asserting claims where includes is flat, claim is flat', () => { const expectedAud = 'http://myapp.com/'; verifier.claimsToAssert = {'scp.includes': 'promos:delete'}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - scp: 'promos:read promos:write' - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + scp: 'promos:read promos:write' + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) + .then( () => { throw new Error(`Invalid 'scp' claim was accepted`) } ) .catch(err => expect(err.message).toBe( `claim 'scp' value 'promos:read promos:write' does not include expected value 'promos:delete'` )); @@ -574,16 +407,9 @@ describe('Jwt Verifier', () => { it('should succeed in asserting claims where includes is array, claim is array', () => { verifier.claimsToAssert = {'groups.includes': ['Everyone', 'Yet Another']}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - groups: ['Everyone', 'Another', 'Yet Another'] - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another', 'Yet Another'] + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) .then(jwt => expect(jwt.claims.groups).toEqual(['Everyone', 'Another', 'Yet Another'])); @@ -591,16 +417,9 @@ describe('Jwt Verifier', () => { it('should succeed in asserting claims where includes is array, claim is flat', () => { verifier.claimsToAssert = {'scp.includes': ['promos:read', 'promos:delete']}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - scp: 'promos:read promos:write promos:delete' - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + scp: 'promos:read promos:write promos:delete' + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) .then(jwt => expect(jwt.claims.scp).toBe('promos:read promos:write promos:delete')); @@ -608,18 +427,12 @@ describe('Jwt Verifier', () => { it('should fail in asserting claims where includes is array, claim is array', () => { verifier.claimsToAssert = {'groups.includes': ['Yet Another']}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - groups: ['Everyone', 'Another'] - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another'] + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) + .then( () => { throw new Error(`Invalid 'groups' claim was accepted`) } ) .catch(err => expect(err.message).toBe( `claim 'groups' value 'Everyone,Another' does not include expected value 'Yet Another'` )); @@ -627,18 +440,12 @@ describe('Jwt Verifier', () => { it('should fail in asserting claims where includes is array, claim is flat', () => { verifier.claimsToAssert = {'scp.includes': ['promos:delete']}; - verifier.verifier = { - verify: function(jwt, cb) { - cb(null, { - body: { - ...otherClaims, - scp: 'promos:read promos:write' - } - }) - } - }; + verifier.verifier = createCustomClaimsVerifier({ + scp: 'promos:read promos:write' + }, otherClaims); return verifier.verifyAccessToken('anything', otherClaims.aud) + .then( () => { throw new Error(`Invalid 'scp' claim was accepted`) } ) .catch(err => expect(err.message).toBe( `claim 'scp' value 'promos:read promos:write' does not include expected value 'promos:delete'` )); diff --git a/packages/jwt-verifier/test/spec/verify_id_token.spec.js b/packages/jwt-verifier/test/spec/verify_id_token.spec.js new file mode 100644 index 000000000..65309b5c9 --- /dev/null +++ b/packages/jwt-verifier/test/spec/verify_id_token.spec.js @@ -0,0 +1,514 @@ +/*! + * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +const nock = require('nock'); +const tk = require('timekeeper'); +const constants = require('../constants') + +const { getIdToken, createToken, createVerifier, createCustomClaimsVerifier, rsaKeyPair } = require('../util'); + +// These need to be exported in the environment, from a working Okta org +const ISSUER = constants.ISSUER; +const CLIENT_ID = constants.CLIENT_ID; +const USERNAME = constants.USERNAME; +const PASSWORD = constants.PASSWORD; +const REDIRECT_URI = constants.REDIRECT_URI; +const NONCE = 'foo'; + +// Some tests makes LIVE requests using getIdToken(). These may take much longer than normal tests +const LONG_TIMEOUT = 60000; + +// Used to get an ID token and id token from the AS +const issuer1IdTokenParams = { + ISSUER, + CLIENT_ID, + USERNAME, + PASSWORD, + REDIRECT_URI, + NONCE +}; + + +describe('Jwt Verifier - Verify ID Token', () => { + + describe('ID token tests with api calls', () => { + const expectedClientId = CLIENT_ID; + const verifier = createVerifier(); + + it('should allow me to verify Okta ID tokens', () => { + return getIdToken(issuer1IdTokenParams) + .then(idToken => { + return verifier.verifyIdToken(idToken, expectedClientId, NONCE); + }) + .then(jwt => { + expect(jwt.claims.iss).toBe(ISSUER); + }); + }, LONG_TIMEOUT); + + it('should fail if the signature is invalid', () => { + return getIdToken(issuer1IdTokenParams) + .then(idToken => verifier.verifyIdToken(idToken, expectedClientId, NONCE)) + .then(jwt => { + // Create an ID token with the same claims and kid, then re-sign it with another RSA private key - this should fail + const token = createToken(jwt.claims, { kid: jwt.header.kid }); + + return verifier.verifyIdToken(token, expectedClientId, NONCE) + .catch(err => expect(err.message).toBe('Signature verification failed')); + }); + }, LONG_TIMEOUT); + + it('should fail if no kid is present in the JWT header', () => { + return getIdToken(issuer1IdTokenParams) + .then(idToken => verifier.verifyIdToken(idToken, expectedClientId, NONCE)) + .then(jwt => { + // Create an ID token that does not have a kid + const token = createToken(jwt.claims); + return verifier.verifyIdToken(token, expectedClientId, NONCE) + .catch(err => expect(err.message).toBe('Error while resolving signing key for kid "undefined"')); + }); + }, LONG_TIMEOUT); + + it('should fail if the kid cannot be found', () => { + return getIdToken(issuer1IdTokenParams) + .then(idToken => verifier.verifyIdToken(idToken, expectedClientId, NONCE)) + .then(jwt => { + // Create an ID token with the same claims but a kid that will not resolve + const token = createToken(jwt.claims, { kid: 'foo' }); + return verifier.verifyIdToken(token, expectedClientId, NONCE) + .catch(err => expect(err.message).toBe('Error while resolving signing key for kid "foo"')); + }); + }, LONG_TIMEOUT); + + it('should fail if the token is expired (exp)', () => { + return getIdToken(issuer1IdTokenParams) + .then(idToken => + verifier.verifyIdToken(idToken, expectedClientId, NONCE) + .then(jwt => { + // Now advance time past the exp claim + const now = new Date(); + const then = new Date((jwt.claims.exp * 1000) + 1000); + tk.travel(then); + return verifier.verifyIdToken(idToken, expectedClientId, NONCE) + .then(() => { + throw new Error('Should have errored'); + }) + .catch(err => { + tk.travel(now); + expect(err.message).toBe('Jwt is expired'); + }); + })); + }, LONG_TIMEOUT); + + it('should allow me to assert custom claims', () => { + const verifier = createVerifier({ + assertClaims: { + aud: 'baz', + foo: 'bar' + } + }); + return getIdToken(issuer1IdTokenParams) + .then(idToken => + verifier.verifyIdToken(idToken, expectedClientId, NONCE) + .catch(err => { + // Extra debugging for an intermittent issue + const result = typeof idToken === 'string' ? 'idToken is a string' : idToken; + expect(result).toBe('idToken is a string'); + expect(err.message).toBe( + `claim 'aud' value '${CLIENT_ID}' does not match expected value 'baz', claim 'foo' value 'undefined' does not match expected value 'bar'` + ); + }) + ); + }, LONG_TIMEOUT); + + it('should cache the jwks for the configured amount of time', () => { + const verifier = createVerifier({ + cacheMaxAge: 500 + }); + return getIdToken(issuer1IdTokenParams) + .then(idToken => { + nock.recorder.rec({ + output_objects: true, + dont_print: true + }); + const nockCallObjects = nock.recorder.play(); + return verifier.verifyIdToken(idToken, expectedClientId, NONCE) + .then(jwt => { + expect(nockCallObjects.length).toBe(1); + return verifier.verifyIdToken(idToken, expectedClientId, NONCE); + }) + .then(jwt => { + expect(nockCallObjects.length).toBe(1); + return new Promise((resolve, reject) => { + setTimeout(() => { + verifier.verifyIdToken(idToken, expectedClientId, NONCE) + .then(jwt => { + expect(nockCallObjects.length).toBe(2); + resolve(); + }) + .catch(reject); + }, 1000); + }); + }) + }); + }, LONG_TIMEOUT); + + it('should rate limit jwks endpoint requests on cache misses', () => { + const verifier = createVerifier({ + jwksRequestsPerMinute: 2 + }); + return getIdToken(issuer1IdTokenParams) + .then((idToken => { + nock.recorder.clear(); + return verifier.verifyIdToken(idToken, expectedClientId, NONCE) + .then(jwt => { + // Create an ID token with the same claims but a kid that will not resolve + const token = createToken(jwt.claims, { kid: 'foo' }); + return verifier.verifyIdToken(token, expectedClientId, NONCE) + .catch(err => verifier.verifyIdToken(token, expectedClientId, NONCE)) + .catch(err => { + const nockCallObjects = nock.recorder.play(); + // Expect 1 request for the valid kid, and 1 request for the 2 attempts with an invalid kid + expect(nockCallObjects.length).toBe(2); + }); + }) + })); + }); + + }, LONG_TIMEOUT); + + describe('ID Token basic validation', () => { + const mockKidAsKeyFetch = (verifier) => { + verifier.jwksClient.getSigningKey = jest.fn( ( kid, onKeyResolve ) => { + onKeyResolve(null, { publicKey: kid } ); + }); + }; + + it('fails if the signature is invalid', () => { + const token = createToken({ + aud: '0oaoesxtxmPf08QHk0h7', + iss: ISSUER, + }, { + kid: rsaKeyPair.wrongPublic, + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7') + .then( () => { throw new Error('Invalid Signature was accepted'); } ) + .catch( err => { + expect(err.message).toBe('Signature verification failed'); + }); + }); + + it('passes if the signature is valid', () => { + const token = createToken({ + aud: '0oaoesxtxmPf08QHk0h7', + iss: ISSUER, + }, { + kid: rsaKeyPair.public + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7'); + }); + + it('fails if iss claim does not match verifier issuer', () => { + const token = createToken({ + aud: '0oaoesxtxmPf08QHk0h7', + iss: 'not-the-issuer', + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7') + .then( () => { throw new Error('invalid issuer did not throw an error'); } ) + .catch( err => { + expect(err.message).toBe(`issuer not-the-issuer does not match expected issuer: ${ISSUER}`); + }); + }); + + it('fails when no audience expectation is passed', () => { + const token = createToken({ + aud: 'any_client_id', + iss: ISSUER, + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token) + .then( () => { throw new Error('expected client id should be required, but was not'); } ) + .catch( err => { + expect(err.message).toBe(`expected client id is required`); + }); + }); + + it('passes when given an audience matching expectation string', () => { + const token = createToken({ + aud: '0oaoesxtxmPf08QHk0h7', + iss: ISSUER, + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7'); + }); + + it('fails with a invalid audience when given a valid expectation', () => { + const token = createToken({ + aud: 'wrong_client_id', + iss: ISSUER, + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7') + .then( () => { throw new Error('Invalid audience claim was accepted') } ) + .catch(err => { + expect(err.message).toBe(`audience claim wrong_client_id does not match expected client id: 0oaoesxtxmPf08QHk0h7`); + }); + }); + + it('fails with a invalid client id', () => { + const token = createToken({ + aud: '{clientId}', + iss: ISSUER, + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '{clientId}') + .then( () => { throw new Error('Invalid client id was accepted') } ) + .catch(err => { + expect(err.message).toBe("Replace {clientId} with the client ID of your Application. You can copy it from the Okta Developer Console in the details for the Application you created. Follow these instructions to find it: https://bit.ly/finding-okta-app-credentials"); + }); + }); + + it('fails when no nonce expectation is passed', () => { + const token = createToken({ + aud: '0oaoesxtxmPf08QHk0h7', + iss: ISSUER, + nonce: 'foo' + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7') + .then( () => { throw new Error('expected nonce should be required, but was not'); } ) + .catch( err => { + expect(err.message).toBe(`expected nonce is required`); + }); + }); + + it('fails when an nonce expectation is passed but claim is missing', () => { + const token = createToken({ + aud: '0oaoesxtxmPf08QHk0h7', + iss: ISSUER + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7', 'some') + .then( () => { throw new Error('should not pass verification'); } ) + .catch( err => { + expect(err.message).toBe(`nonce claim is missing but expected: some`); + }); + }); + + it('passes when given an nonce matching expectation string', () => { + const token = createToken({ + aud: '0oaoesxtxmPf08QHk0h7', + iss: ISSUER, + nonce: 'foo' + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7', 'foo'); + }); + + it('fails with an invalid nonce when given a valid expectation', () => { + const token = createToken({ + aud: '0oaoesxtxmPf08QHk0h7', + iss: ISSUER, + nonce: 'foo' + }, { + kid: rsaKeyPair.public // For override of key retrieval below + }); + + const verifier = createVerifier(); + mockKidAsKeyFetch(verifier); + + // Not valid expectation + return verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7', 'bar') + .then( () => { throw new Error('Invalid nonce claim was accepted') } ) + .catch(err => { + expect(err.message).toBe(`nonce claim foo does not match expected nonce: bar`); + }) + // Expectation matches claim but in different case + .then( () => verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7', 'FOO') ) + .then( () => { throw new Error('Invalid nonce claim was accepted') } ) + .catch(err => { + expect(err.message).toBe(`nonce claim foo does not match expected nonce: FOO`); + }) + // Value is not a string + .then( () => verifier.verifyIdToken(token, '0oaoesxtxmPf08QHk0h7', {}) ) + .then( () => { throw new Error('Invalid nonce claim was accepted') } ) + .catch(err => { + expect(err.message).toBe(`nonce claim foo does not match expected nonce: [object Object]`); + }) + }); + + }); + + + describe('ID Token custom claim tests with stubs', () => { + const otherClaims = { + iss: ISSUER, + aud: '0oaoesxtxmPf08QHk0h7', + }; + + const verifier = createVerifier(); + + it('should only allow includes operator for custom claims', () => { + verifier.claimsToAssert = {'groups.blarg': 'Everyone'}; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another'] + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .catch(err => expect(err.message).toBe( + `operator: 'blarg' invalid. Supported operators: 'includes'.` + )); + }); + + it('should succeed in asserting claims where includes is flat, claim is array', () => { + verifier.claimsToAssert = {'groups.includes': 'Everyone'}; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another'] + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .then(jwt => expect(jwt.claims.groups).toEqual(['Everyone', 'Another'])); + }); + + it('should succeed in asserting claims where includes is flat, claim is flat', () => { + verifier.claimsToAssert = {'scp.includes': 'promos:read'}; + verifier.verifier = createCustomClaimsVerifier({ + scp: 'promos:read promos:write' + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .then(jwt => expect(jwt.claims.scp).toBe('promos:read promos:write')); + }); + + it('should fail in asserting claims where includes is flat, claim is array', () => { + verifier.claimsToAssert = {'groups.includes': 'Yet Another'}; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another'] + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .then( () => { throw new Error(`Invalid 'groups' claim was accepted`) } ) + .catch(err => expect(err.message).toBe( + `claim 'groups' value 'Everyone,Another' does not include expected value 'Yet Another'` + )); + }); + + it('should fail in asserting claims where includes is flat, claim is flat', () => { + const expectedAud = '0oaoesxtxmPf08QHk0h7'; + verifier.claimsToAssert = {'scp.includes': 'promos:delete'}; + verifier.verifier = createCustomClaimsVerifier({ + scp: 'promos:read promos:write' + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .then( () => { throw new Error(`Invalid 'scp' claim was accepted`) } ) + .catch(err => expect(err.message).toBe( + `claim 'scp' value 'promos:read promos:write' does not include expected value 'promos:delete'` + )); + }); + + it('should succeed in asserting claims where includes is array, claim is array', () => { + verifier.claimsToAssert = {'groups.includes': ['Everyone', 'Yet Another']}; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another', 'Yet Another'] + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .then(jwt => expect(jwt.claims.groups).toEqual(['Everyone', 'Another', 'Yet Another'])); + }); + + it('should succeed in asserting claims where includes is array, claim is flat', () => { + verifier.claimsToAssert = {'scp.includes': ['promos:read', 'promos:delete']}; + verifier.verifier = createCustomClaimsVerifier({ + scp: 'promos:read promos:write promos:delete' + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .then(jwt => expect(jwt.claims.scp).toBe('promos:read promos:write promos:delete')); + }); + + it('should fail in asserting claims where includes is array, claim is array', () => { + verifier.claimsToAssert = {'groups.includes': ['Yet Another']}; + verifier.verifier = createCustomClaimsVerifier({ + groups: ['Everyone', 'Another'] + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .then( () => { throw new Error(`Invalid 'groups' claim was accepted`) } ) + .catch(err => expect(err.message).toBe( + `claim 'groups' value 'Everyone,Another' does not include expected value 'Yet Another'` + )); + }); + + it('should fail in asserting claims where includes is array, claim is flat', () => { + verifier.claimsToAssert = {'scp.includes': ['promos:delete']}; + verifier.verifier = createCustomClaimsVerifier({ + scp: 'promos:read promos:write' + }, otherClaims); + + return verifier.verifyIdToken('anything', otherClaims.aud) + .then( () => { throw new Error(`Invalid 'scp' claim was accepted`) } ) + .catch(err => expect(err.message).toBe( + `claim 'scp' value 'promos:read promos:write' does not include expected value 'promos:delete'` + )); + }); + }); + +}); diff --git a/packages/jwt-verifier/test/util.js b/packages/jwt-verifier/test/util.js index 5dd7b6937..f14283be2 100644 --- a/packages/jwt-verifier/test/util.js +++ b/packages/jwt-verifier/test/util.js @@ -10,17 +10,39 @@ * See the License for the specific language governing permissions and limitations under the License. */ +const fs = require('fs'); +const path = require('path'); const qs = require('qs'); const fetch = require('node-fetch'); const url = require('url'); +const querystring = require('querystring'); +const njwt = require('njwt'); +const constants = require('./constants'); +const OktaJwtVerifier = require('../lib'); -function getAccessToken(options = {}) { +const ISSUER = constants.ISSUER; +const OKTA_TESTING_DISABLEHTTPSCHECK = constants.OKTA_TESTING_DISABLEHTTPSCHECK + +const NODE_MODULES = path.resolve(__dirname, '../node_modules'); +const publicKeyPath = path.normalize(path.join(NODE_MODULES, '/njwt/test/rsa.pub')); +const privateKeyPath = path.normalize(path.join(NODE_MODULES, '/njwt/test/rsa.priv')); +const wrongPublicKeyPath = path.normalize(path.join(__dirname, '/keys/rsa-fake.pub')); +const rsaKeyPair = { + public: fs.readFileSync(publicKeyPath, 'utf8'), + private: fs.readFileSync(privateKeyPath, 'utf8'), + wrongPublic: fs.readFileSync(wrongPublicKeyPath, 'utf8') +}; + + +function getTokens(options = {}) { const { ISSUER, CLIENT_ID, REDIRECT_URI, USERNAME, - PASSWORD + PASSWORD, + NONCE, + RESPONSE_TYPE } = options; return new Promise((resolve, reject) => { @@ -48,12 +70,12 @@ function getAccessToken(options = {}) { const authorizeParams = { sessionToken: body.sessionToken, - response_type: 'token', + response_type: RESPONSE_TYPE || 'id_token token', client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: 'openid', - nonce: 'foo', - state: 'foo' + state: 'foo', + nonce: NONCE || 'foo' } const authorizeUrl = ISSUER + '/v1/authorize?' + qs.stringify(authorizeParams); @@ -64,17 +86,16 @@ function getAccessToken(options = {}) { } const parsedUrl = url.parse(resp.headers.get('location'), true); - if (parsedUrl.query.error) { - throw new Error(`/api/v1/authorize error in query: ${parsedUrl.query.error}`); - } - - const match = resp.headers.get('location').match(/access_token=([^&]+)/); - const accessToken = match && match[1]; - if (!accessToken){ - throw new Error('Could not parse access token from URI'); + const parsedParams = parsedUrl.hash ? querystring.parse(parsedUrl.hash.slice(1)) : parsedUrl.query; + + if (parsedParams.error) { + throw new Error(`/api/v1/authorize error in query: ${parsedParams.error}`); } - - resolve(accessToken); + + resolve({ + accessToken: parsedParams.access_token, + idToken: parsedParams.id_token, + }); }).catch(err => { console.error(err.message || err); reject(err) @@ -82,6 +103,64 @@ function getAccessToken(options = {}) { }); } +function getAccessToken(options = {}) { + return getTokens({...options, RESPONSE_TYPE: 'token'}).then(({accessToken: accessToken}) => { + if (!accessToken){ + throw new Error('Could not parse access token from URI'); + } + return accessToken; + }); +} + +function getIdToken(options = {}) { + return getTokens({...options, RESPONSE_TYPE: 'id_token'}).then(({idToken: idToken}) => { + if (!idToken){ + throw new Error('Could not parse ID token from URI'); + } + return idToken; + }); +} + +function createToken(claims, headers = {}) { + let token = new njwt.Jwt(claims) + .setSigningAlgorithm('RS256') + .setSigningKey(rsaKeyPair.private); + + for (const [k, v] of Object.entries(headers)) { + token = token.setHeader(k, v); + } + + return token.compact(); +} + +function createVerifier(options = {}) { + return new OktaJwtVerifier({ + issuer: ISSUER, + testing: { + disableHttpsCheck: OKTA_TESTING_DISABLEHTTPSCHECK + }, + ...options + }); +} + +function createCustomClaimsVerifier(customClaims, otherClaims) { + return { + verify: function(jwt, cb) { + cb(null, { + body: { + ...otherClaims, + ...customClaims + } + }) + } + }; +} + module.exports = { - getAccessToken + getAccessToken, + getIdToken, + createToken, + createVerifier, + createCustomClaimsVerifier, + rsaKeyPair };