Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use aws-jwt-verify to verify JSON Web Tokens #15

Merged
merged 1 commit into from
Oct 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 20 additions & 58 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
const axios = require('axios');
const jwt = require('jsonwebtoken');

jest.mock('axios');
jest.mock('jwk-to-pem');
jest.mock('jsonwebtoken');

const { Authenticator } = require('../index');

Expand All @@ -30,33 +27,6 @@ describe('private functions', () => {
});
});

test('JWKS should be false by default', () => {
expect(authenticator._jwks).toBeFalsy();
});

test('should fetch JWKS', () => {
axios.get.mockResolvedValue({ data: jwksData });
return authenticator._fetchJWKS('http://something')
.then(() => {
expect(authenticator._jwks).toEqual({
'1234example=': { 'kid': '1234example=', 'alg': 'RS256', 'kty': 'RSA', 'e': 'AQAB', 'n': '1234567890', 'use': 'sig' },
'5678example=': { 'kid': '5678example=', 'alg': 'RS256', 'kty': 'RSA', 'e': 'AQAB', 'n': '987654321', 'use': 'sig' },
});
});
});

test('should throw if unable to fetch JWKS', () => {
axios.get.mockRejectedValue(new Error('Unexpected error'));
return expect(() => authenticator._fetchJWKS('http://something')).rejects.toThrow();
});

test('should get valid decoded token', () => {
authenticator._jwks = {};
jwt.decode.mockReturnValueOnce({ header: { kid: 'kid' } });
jwt.verify.mockReturnValueOnce({ token_use: 'id', attribute: 'valid' });
expect(authenticator._getVerifiedToken('valid-token')).toEqual({ token_use: 'id', attribute: 'valid' });
});

test('should fetch token', () => {
axios.request.mockResolvedValue({ data: tokenData });

Expand All @@ -71,14 +41,14 @@ describe('private functions', () => {
return expect(() => authenticator._fetchTokensFromCode('htt://redirect', 'AUTH_CODE')).rejects.toThrow();
});

test('should getRedirectResponse', () => {
test('should getRedirectResponse', async () => {
const username = 'toto';
const domain = 'example.com';
const path = '/test';
jest.spyOn(authenticator, '_getVerifiedToken');
authenticator._getVerifiedToken.mockReturnValueOnce({ token_use: 'id', 'cognito:username': username });
jest.spyOn(authenticator._jwtVerifier, 'verify');
authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username }));

const response = authenticator._getRedirectResponse(tokenData, domain, path);
const response = await authenticator._getRedirectResponse(tokenData, domain, path);
expect(response).toMatchObject({
status: '302',
headers: {
Expand All @@ -95,10 +65,10 @@ describe('private functions', () => {
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.idToken=${tokenData.id_token}; Domain=${domain}; Expires=${DATE}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.LastAuthUser=${username}; Domain=${domain}; Expires=${DATE}; Secure`},
]));
expect(authenticator._getVerifiedToken).toHaveBeenCalled();
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
});

test('should not return cookie domain', () => {
test('should not return cookie domain', async () => {
const authenticatorWithNoCookieDomain = new Authenticator({
region: 'us-east-1',
userPoolId: 'us-east-1_abcdef123',
Expand All @@ -108,14 +78,15 @@ describe('private functions', () => {
disableCookieDomain: true,
logLevel: 'error',
});
authenticatorWithNoCookieDomain._jwtVerifier.cacheJwks(jwksData);

const username = 'toto';
const domain = 'example.com';
const path = '/test';
jest.spyOn(authenticatorWithNoCookieDomain, '_getVerifiedToken');
authenticatorWithNoCookieDomain._getVerifiedToken.mockReturnValueOnce({ token_use: 'id', 'cognito:username': username });
jest.spyOn(authenticatorWithNoCookieDomain._jwtVerifier, 'verify');
authenticatorWithNoCookieDomain._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username }));

const response = authenticatorWithNoCookieDomain._getRedirectResponse(tokenData, domain, path);
const response = await authenticatorWithNoCookieDomain._getRedirectResponse(tokenData, domain, path);
expect(response).toMatchObject({
status: '302',
headers: {
Expand All @@ -132,7 +103,7 @@ describe('private functions', () => {
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.idToken=${tokenData.id_token}; Expires=${DATE}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.LastAuthUser=${username}; Expires=${DATE}; Secure`},
]));
expect(authenticatorWithNoCookieDomain._getVerifiedToken).toHaveBeenCalled();
expect(authenticatorWithNoCookieDomain._jwtVerifier.verify).toHaveBeenCalled();
});

test('should getIdTokenFromCookie', () => {
Expand Down Expand Up @@ -162,7 +133,7 @@ describe('createAuthenticator', () => {
userPoolAppId: '123456789qwertyuiop987abcd',
userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com',
cookieExpirationDays: 365,
disableCookieDomain: true
disableCookieDomain: true,
};
});

Expand Down Expand Up @@ -248,47 +219,38 @@ describe('handle', () => {
cookieExpirationDays: 365,
logLevel: 'debug',
});
authenticator._jwks = jwksData;
jest.spyOn(authenticator, '_fetchJWKS');
jest.spyOn(authenticator, '_getVerifiedToken');
authenticator._jwtVerifier.cacheJwks(jwksData);
jest.spyOn(authenticator, '_getIdTokenFromCookie');
jest.spyOn(authenticator, '_fetchTokensFromCode');
jest.spyOn(authenticator, '_getRedirectResponse');
});

test('should fetch JWKS if not present', () => {
authenticator._jwks = undefined;
authenticator._fetchJWKS.mockResolvedValueOnce(jwksData);
return authenticator.handle(getCloudfrontRequest())
.catch(err => err)
.finally(() => expect(authenticator._fetchJWKS).toHaveBeenCalled());
jest.spyOn(authenticator._jwtVerifier, 'verify');
});

test('should forward request if authenticated', () => {
authenticator._getVerifiedToken.mockReturnValueOnce({});
authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({}));
return expect(authenticator.handle(getCloudfrontRequest())).resolves.toEqual(getCloudfrontRequest().Records[0].cf.request)
.then(() => {
expect(authenticator._getIdTokenFromCookie).toHaveBeenCalled();
expect(authenticator._getVerifiedToken).toHaveBeenCalled();
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
});
});

test('should fetch and set token if code is present', () => {
authenticator._getVerifiedToken.mockImplementationOnce(() => { throw new Error();});
authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error();});
authenticator._fetchTokensFromCode.mockResolvedValueOnce(tokenData);
authenticator._getRedirectResponse.mockReturnValueOnce({ response: 'toto' });
const request = getCloudfrontRequest();
request.Records[0].cf.request.querystring = 'code=54fe5f4e&state=/lol';
return expect(authenticator.handle(request)).resolves.toEqual({ response: 'toto' })
.then(() => {
expect(authenticator._getVerifiedToken).toHaveBeenCalled();
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
expect(authenticator._fetchTokensFromCode).toHaveBeenCalled();
expect(authenticator._getRedirectResponse).toHaveBeenCalledWith(tokenData, 'd111111abcdef8.cloudfront.net', '/lol');
});
});

test('should redirect to auth domain if unauthenticated and no code', () => {
authenticator._getVerifiedToken.mockImplementationOnce(() => { throw new Error();});
authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error();});
return expect(authenticator.handle(getCloudfrontRequest())).resolves.toEqual(
{
status: 302,
Expand All @@ -301,7 +263,7 @@ describe('handle', () => {
},
)
.then(() => {
expect(authenticator._getVerifiedToken).toHaveBeenCalled();
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
});
});
});
Expand Down
58 changes: 12 additions & 46 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
const jwt = require('jsonwebtoken');
const jwkToPem = require('jwk-to-pem');
const assert = require('assert');
const axios = require('axios');
const querystring = require('querystring');
const pino = require('pino');
const awsJwtVerify = require('aws-jwt-verify');

class Authenticator {
constructor(params) {
Expand All @@ -15,12 +13,16 @@ class Authenticator {
this._userPoolDomain = params.userPoolDomain;
this._cookieExpirationDays = params.cookieExpirationDays || 365;
this._disableCookieDomain = ('disableCookieDomain' in params && params.disableCookieDomain === true) ? true : false;
this._issuer = `https://cognito-idp.${params.region}.amazonaws.com/${params.userPoolId}`;
this._cookieBase = `CognitoIdentityServiceProvider.${params.userPoolAppId}`;
this._logger = pino({
level: params.logLevel || 'silent', // Default to silent
base: null, //Remove pid, hostname and name logging as not usefull for Lambda
});
this._jwtVerifier = awsJwtVerify.CognitoJwtVerifier.create({
userPoolId: params.userPoolId,
clientId: params.userPoolAppId,
tokenUse: 'id',
});
}

/**
Expand All @@ -45,39 +47,6 @@ class Authenticator {
}
}

/**
* Download JSON Web Key Set (JWKS) from the UserPool.
* @param {String} issuer URI of the UserPool.
* @return {Promise} Request.
*/
_fetchJWKS() {
this._jwks = {};
const URL = `${this._issuer}/.well-known/jwks.json`;
this._logger.debug(`Fetching JWKS from ${URL}`);
return axios.get(URL)
.then(resp => {
resp.data.keys.forEach(key => this._jwks[key.kid] = key);
})
.catch(err => {
this._logger.error(`Unable to fetch JWKS from ${URL}`);
throw err;
});
}

/**
* Verify that the current token is valid. Throw an error if not.
* @param {String} token Token to verify.
* @return {Object} Decoded token.
*/
_getVerifiedToken(token) {
this._logger.debug({ msg: 'Verifying token...', token });
const decoded = jwt.decode(token, {complete: true});
const kid = decoded.header.kid;
const verified = jwt.verify(token, jwkToPem(this._jwks[kid]), { audience: this._userPoolAppId, issuer: this._issuer });
assert.strictEqual(verified.token_use, 'id');
return verified;
}

/**
* Exchange authorization code for tokens.
* @param {String} redirectURI Redirection URI.
Expand Down Expand Up @@ -119,8 +88,8 @@ class Authenticator {
* @param {String} location Path to redirection.
* @return {Object} Lambda@Edge response.
*/
_getRedirectResponse(tokens, domain, location) {
const decoded = this._getVerifiedToken(tokens.id_token);
async _getRedirectResponse(tokens, domain, location) {
const decoded = await this._jwtVerifier.verify(tokens.id_token);
const username = decoded['cognito:username'];
const usernameBase = `${this._cookieBase}.${username}`;
const directives = (!this._disableCookieDomain) ?
Expand Down Expand Up @@ -193,22 +162,19 @@ class Authenticator {
async handle(event) {
this._logger.debug({ msg: 'Handling Lambda@Edge event', event });

if (!this._jwks) {
await this._fetchJWKS();
}

const { request } = event.Records[0].cf;
const requestParams = querystring.parse(request.querystring);
const cfDomain = request.headers.host[0].value;
const redirectURI = `https://${cfDomain}`;

try {
const token = this._getIdTokenFromCookie(request.headers.cookie);
const user = this._getVerifiedToken(token);
this._logger.info({ msg: 'Forwading request', path: request.uri, user });
this._logger.debug({ msg: 'Verifying token...', token });
const user = await this._jwtVerifier.verify(token);
this._logger.info({ msg: 'Forwarding request', path: request.uri, user });
return request;
} catch (err) {
this._logger.debug("User isn't authenticated");
this._logger.debug("User isn't authenticated: %s", err);
if (requestParams.code) {
return this._fetchTokensFromCode(redirectURI, requestParams.code)
.then(tokens => this._getRedirectResponse(tokens, cfDomain, decodeURIComponent(requestParams.state)));
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@
"test": "jest --coverage"
},
"dependencies": {
"aws-jwt-verify": "^1.0.1",
"axios": "^0.21.1",
"jsonwebtoken": "^8.2.1",
"jwk-to-pem": "^2.0.4",
"pino": "^6.10.0"
},
"devDependencies": {
Expand Down