Skip to content

Commit

Permalink
Use refetch token, if available (#51)
Browse files Browse the repository at this point in the history
* Refresh Token Support
* Support Path cookie attribute

---------

Co-authored-by: Franz Urlhardt <[email protected]>
  • Loading branch information
maverick089 and drx-urlfra authored Apr 17, 2023
1 parent aba9d45 commit 28a3ff2
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 77 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ For an explanation of the interactions between CloudFront, Cognito and Lambda@Ed
* `userPoolAppId` *string* Cognito UserPool Application ID (eg: `63gcbm2jmskokurt5ku9fhejc6`)
* `userPoolAppSecret` *string* (Optional) Cognito UserPool Application Secret (eg: `oh470px2i0uvy4i2ha6sju0vxe4ata9ol3m63ufhs2t8yytwjn7p`)
* `userPoolDomain` *string* Cognito UserPool domain (eg: `your-domain.auth.us-east-1.amazoncognito.com`)
* `cookieExpirationDays` *number* (Optional) Number of day to set cookies expiration date, default to 365 days (eg: `365`)
* `cookieExpirationDays` *number* (Optional) Number of day to set cookies expiration date, default to 365 days (eg: `365`). It's recommended to set this value to match `refreshTokenValidity` parameter of the pool client.
* `disableCookieDomain` *boolean* (Optional) Sets domain attribute in cookies, defaults to false (eg: `false`)
* `httpOnly` *boolean* (Optional) Forbids JavaScript from accessing the cookies, defaults to false (eg: `false`). Note, if this is set to `true`, the cookies will not be accessible to Amplify auth if you are using it client side.
* `sameSite` *Strict | Lax | None* (Optional) Allows you to declare if your cookie should be restricted to a first-party or same-site context (eg: `SameSite=None`).
* `cookiePath` *string* (Optional) Sets Path attribute in cookies
* `logLevel` *string* (Optional) Logging level. Default: `'silent'`. One of `'fatal'`, `'error'`, `'warn'`, `'info'`, `'debug'`, `'trace'` or `'silent'`.

*This is the class constructor.*
Expand Down
132 changes: 111 additions & 21 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ global.Date = class extends Date {
};

describe('private functions', () => {
let authenticator;
let authenticator : Authenticator;

beforeEach(() => {
authenticator = new Authenticator({
Expand All @@ -35,7 +35,7 @@ describe('private functions', () => {

return authenticator._fetchTokensFromCode('htt://redirect', 'AUTH_CODE')
.then(res => {
expect(res).toEqual(tokenData);
expect(res).toMatchObject({refreshToken: tokenData.refresh_token, accessToken: tokenData.access_token, idToken: tokenData.id_token});
});
});

Expand All @@ -51,7 +51,7 @@ describe('private functions', () => {
jest.spyOn(authenticator._jwtVerifier, 'verify');
authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username }));

const response = await authenticator._getRedirectResponse(tokenData, domain, path);
const response = await authenticator._getRedirectResponse({refreshToken: tokenData.refresh_token, accessToken: tokenData.access_token, idToken: tokenData.id_token}, domain, path);
expect(response).toMatchObject({
status: '302',
headers: {
Expand All @@ -61,7 +61,7 @@ describe('private functions', () => {
}],
},
});
expect(response.headers['set-cookie']).toEqual(expect.arrayContaining([
expect(response?.headers?.['set-cookie']).toEqual(expect.arrayContaining([
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.accessToken=${tokenData.access_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure`},
Expand Down Expand Up @@ -89,7 +89,7 @@ describe('private functions', () => {
jest.spyOn(authenticatorWithNoCookieDomain._jwtVerifier, 'verify');
authenticatorWithNoCookieDomain._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username }));

const response = await authenticatorWithNoCookieDomain._getRedirectResponse(tokenData, domain, path);
const response = await authenticatorWithNoCookieDomain._getRedirectResponse({'accessToken': tokenData.access_token, 'idToken': tokenData.id_token, 'refreshToken': tokenData.refresh_token}, domain, path);
expect(response).toMatchObject({
status: '302',
headers: {
Expand All @@ -99,7 +99,7 @@ describe('private functions', () => {
}],
},
});
expect(response.headers['set-cookie']).toEqual(expect.arrayContaining([
expect(response?.headers?.['set-cookie']).toEqual(expect.arrayContaining([
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.accessToken=${tokenData.access_token}; Expires=${DATE.toUTCString()}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Expires=${DATE.toUTCString()}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Expires=${DATE.toUTCString()}; Secure`},
Expand Down Expand Up @@ -128,7 +128,7 @@ describe('private functions', () => {
jest.spyOn(authenticatorWithHttpOnly._jwtVerifier, 'verify');
authenticatorWithHttpOnly._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username }));

const response = await authenticatorWithHttpOnly._getRedirectResponse(tokenData, domain, path);
const response = await authenticatorWithHttpOnly._getRedirectResponse({ accessToken: tokenData.access_token, idToken: tokenData.id_token, refreshToken: tokenData.refresh_token }, domain, path);
expect(response).toMatchObject({
status: '302',
headers: {
Expand All @@ -138,7 +138,7 @@ describe('private functions', () => {
}],
},
});
expect(response.headers['set-cookie']).toEqual(expect.arrayContaining([
expect(response?.headers?.['set-cookie']).toEqual(expect.arrayContaining([
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.accessToken=${tokenData.access_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`},
Expand Down Expand Up @@ -168,7 +168,7 @@ describe('private functions', () => {
jest.spyOn(authenticatorWithSameSite._jwtVerifier, 'verify');
authenticatorWithSameSite._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username }));

const response = await authenticatorWithSameSite._getRedirectResponse(tokenData, domain, path);
const response = await authenticatorWithSameSite._getRedirectResponse({ accessToken: tokenData.access_token, idToken: tokenData.id_token, refreshToken: tokenData.refresh_token }, domain, path);
expect(response).toMatchObject({
status: '302',
headers: {
Expand All @@ -188,10 +188,50 @@ describe('private functions', () => {
expect(authenticatorWithSameSite._jwtVerifier.verify).toHaveBeenCalled();
});

test('should set Path on cookies', async () => {
const cookiePath = '/test/path';
const authenticatorWithPath = new Authenticator({
region: 'us-east-1',
userPoolId: 'us-east-1_abcdef123',
userPoolAppId: '123456789qwertyuiop987abcd',
userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com',
cookieExpirationDays: 365,
disableCookieDomain: false,
logLevel: 'error',
cookiePath,
});
authenticatorWithPath._jwtVerifier.cacheJwks(jwksData);

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

const response = await authenticatorWithPath._getRedirectResponse({ accessToken: tokenData.access_token, idToken: tokenData.id_token, refreshToken: tokenData.refresh_token }, domain, path);
expect(response).toMatchObject({
status: '302',
headers: {
location: [{
key: 'Location',
value: path,
}],
},
});
expect(response?.headers?.['set-cookie']).toEqual(expect.arrayContaining([
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.accessToken=${tokenData.access_token}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.idToken=${tokenData.id_token}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.LastAuthUser=${username}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`},
]));
expect(authenticatorWithPath._jwtVerifier.verify).toHaveBeenCalled();
});

test('should getIdTokenFromCookie', () => {
const appClientName = 'toto,./;;..-_lol123';
expect(
authenticator._getIdTokenFromCookie([{
authenticator._getTokensFromCookie([{
key: 'Cookie',
value: [
Cookies.serialize(`CognitoIdentityServiceProvider.5uka3k8840tap1g1i1617jh8pi.${appClientName}.idToken`, 'wrong'),
Expand All @@ -200,25 +240,33 @@ describe('private functions', () => {
Cookies.serialize(`CognitoIdentityServiceProvider.5ukasw8840tap1g1i1617jh8pi.${appClientName}.idToken`, 'wrong'),
].join('; '),
}]),
).toBe(tokenData.id_token);
).toMatchObject({idToken: tokenData.id_token});

expect(
authenticator._getIdTokenFromCookie([{
authenticator._getTokensFromCookie([{
key: 'Cookie',
value: [
Cookies.serialize(`CognitoIdentityServiceProvider.5uka3k8840tap1g1i1617jh8pi.${appClientName}.accessToken`, 'someValue'),
Cookies.serialize(`CognitoIdentityServiceProvider.5uka3k8840tap1g1i1617jh8pi.${appClientName}.accessToken`, tokenData.access_token),
Cookies.serialize(`CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${appClientName}.idToken`, tokenData.id_token),
].join('; '),
}]),
).toBe(tokenData.id_token);
});
).toMatchObject({ idToken: tokenData.id_token});

test('should getIdTokenFromCookie throw on cookies', () => {
expect(() => authenticator._getIdTokenFromCookie([])).toThrow('idToken');

expect(
authenticator._getTokensFromCookie([{
key: 'Cookie',
value: [
Cookies.serialize(`CognitoIdentityServiceProvider.5uka3k8840tap1g1i1617jh8pi.${appClientName}.accessToken`, tokenData.access_token),
Cookies.serialize(`CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${appClientName}.idToken`, tokenData.id_token),
Cookies.serialize(`CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${appClientName}.refreshToken`, tokenData.refresh_token),
].join('; '),
}]),
).toMatchObject({ idToken: tokenData.id_token, refreshToken: tokenData.refresh_token});
});

test('should getIdTokenFromCookie throw on no cookies', () => {
expect(() => authenticator._getIdTokenFromCookie()).toThrow('Cookies weren\'t present in the request');
test('should getTokensFromCookie throw on cookies', () => {
expect(() => authenticator._getTokensFromCookie([])).toThrow('idToken');
});
});

Expand Down Expand Up @@ -256,6 +304,11 @@ describe('createAuthenticator', () => {
expect(typeof new Authenticator(params)).toBe('object');
});

test('should create authenticator without cookiePath', () => {
delete params.cookiePath;
expect(typeof new Authenticator(params)).toBe('object');
});

test('should create authenticator with unvalidated samesite', () => {
params.sameSite = '123';
expect(() => new Authenticator(params)).toThrow('Expected params');
Expand Down Expand Up @@ -322,6 +375,11 @@ describe('createAuthenticator', () => {
params.httpOnly = '123';
expect(() => new Authenticator(params)).toThrow('httpOnly');
});

test('should fail when creating authenticator with invalid cookiePath', () => {
params.cookiePath = 123;
expect(() => new Authenticator(params)).toThrow('cookiePath');
});
});

describe('handle', () => {
Expand All @@ -337,18 +395,50 @@ describe('handle', () => {
logLevel: 'debug',
});
authenticator._jwtVerifier.cacheJwks(jwksData);
jest.spyOn(authenticator, '_getIdTokenFromCookie');
jest.spyOn(authenticator, '_getTokensFromCookie');
jest.spyOn(authenticator, '_fetchTokensFromCode');
jest.spyOn(authenticator, '_fetchTokensFromRefreshToken');
jest.spyOn(authenticator, '_getRedirectResponse');
jest.spyOn(authenticator, '_getRedirectToCognitoUserPoolResponse');
jest.spyOn(authenticator._jwtVerifier, 'verify');
});

test('should forward request if authenticated', () => {
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._getTokensFromCookie).toHaveBeenCalled();
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
});
});

test('should fetch with refresh token if available', () => {
authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.reject({}));
authenticator._getTokensFromCookie.mockReturnValueOnce({refreshToken: tokenData.refresh_token});
authenticator._fetchTokensFromRefreshToken.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._getTokensFromCookie).toHaveBeenCalled();
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
expect(authenticator._fetchTokensFromRefreshToken).toHaveBeenCalled();
expect(authenticator._getRedirectResponse).toHaveBeenCalledWith(tokenData, 'd111111abcdef8.cloudfront.net', '/lol');
});
});

test('should redirect to cognito if refresh token is invalid', () => {
authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.reject({}));
authenticator._getTokensFromCookie.mockReturnValueOnce({refreshToken: tokenData.refresh_token});
authenticator._fetchTokensFromRefreshToken.mockReturnValueOnce(Promise.reject({}));
authenticator._getRedirectToCognitoUserPoolResponse.mockReturnValueOnce({ response: 'toto' });
const request = getCloudfrontRequest();
return expect(authenticator.handle(request)).resolves.toEqual({ response: 'toto' })
.then(() => {
expect(authenticator._getTokensFromCookie).toHaveBeenCalled();
expect(authenticator._jwtVerifier.verify).toHaveBeenCalled();
expect(authenticator._fetchTokensFromRefreshToken).toHaveBeenCalled();
});
});

Expand Down
Loading

0 comments on commit 28a3ff2

Please sign in to comment.