Skip to content

Commit

Permalink
Support SameSite cookie (#50)
Browse files Browse the repository at this point in the history
* Support SameSite directive similar to feat: httpOnly param #41
* Add type for SameSite
* Set SameSite using existing pattern
* Unit test for addition and for incorrect value validation
  • Loading branch information
ckifer authored Dec 13, 2022
1 parent 28b70d2 commit 49865ae
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ For an explanation of the interactions between CloudFront, Cognito and Lambda@Ed
* `cookieExpirationDays` *number* (Optional) Number of day to set cookies expiration date, default to 365 days (eg: `365`)
* `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`).
* `logLevel` *string* (Optional) Logging level. Default: `'silent'`. One of `'fatal'`, `'error'`, `'warn'`, `'info'`, `'debug'`, `'trace'` or `'silent'`.

*This is the class constructor.*
Expand Down
45 changes: 45 additions & 0 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,46 @@ describe('private functions', () => {
expect(authenticatorWithHttpOnly._jwtVerifier.verify).toHaveBeenCalled();
});

test('should set SameSite on cookies', async () => {
const authenticatorWithSameSite = 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,
httpOnly: true,
logLevel: 'error',
sameSite: 'Strict',
});
authenticatorWithSameSite._jwtVerifier.cacheJwks(jwksData);

const username = 'toto';
const domain = 'example.com';
const path = '/test';
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);
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}; Expires=${DATE.toUTCString()}; Secure; HttpOnly; SameSite=Strict`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly; SameSite=Strict`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly; SameSite=Strict`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.idToken=${tokenData.id_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly; SameSite=Strict`},
{key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.LastAuthUser=${username}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly; SameSite=Strict`},
]));
expect(authenticatorWithSameSite._jwtVerifier.verify).toHaveBeenCalled();
});

test('should getIdTokenFromCookie', () => {
const appClientName = 'toto,./;;..-_lol123';
expect(
Expand Down Expand Up @@ -216,6 +256,11 @@ describe('createAuthenticator', () => {
expect(typeof new Authenticator(params)).toBe('object');
});

test('should create authenticator with unvalidated samesite', () => {
params.sameSite = '123';
expect(() => new Authenticator(params)).toThrow('Expected params');
});

test('should fail when creating authenticator without params', () => {
// @ts-ignore
// ts-ignore is used here to override typescript's type check in the constructor
Expand Down
7 changes: 6 additions & 1 deletion __tests__/util/cookie.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CookieAttributes, Cookies } from '../../src/util/cookie';
import { CookieAttributes, Cookies, SAME_SITE_VALUES } from '../../src/util/cookie';

describe('parse tests', () => {
test('should parse valid cookie string', () => {
Expand Down Expand Up @@ -96,4 +96,9 @@ describe('serialize tests', () => {
expect(Cookies.parse(serialized))
.toStrictEqual([{ name, value }]);
});

test('should have correct SAME_SITE_VALUES', () => {
expect(SAME_SITE_VALUES).toHaveLength(3);
expect(SAME_SITE_VALUES).toEqual(['Strict', 'Lax', 'None']);
});
});
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { parse, stringify } from 'querystring';
import pino from 'pino';
import { CognitoJwtVerifier } from 'aws-jwt-verify';
import { CloudFrontRequestEvent, CloudFrontRequestResult } from 'aws-lambda';
import { CookieAttributes, Cookies } from './util/cookie';
import { CookieAttributes, Cookies, SameSite, SAME_SITE_VALUES } from './util/cookie';



interface AuthenticatorParams {
region: string;
Expand All @@ -14,6 +16,7 @@ interface AuthenticatorParams {
cookieExpirationDays?: number;
disableCookieDomain?: boolean;
httpOnly?: boolean;
sameSite?: SameSite;
logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
}

Expand All @@ -26,6 +29,7 @@ export class Authenticator {
_cookieExpirationDays: number;
_disableCookieDomain: boolean;
_httpOnly: boolean;
_sameSite?: SameSite;
_cookieBase: string;
_logger;
_jwtVerifier;
Expand All @@ -40,6 +44,7 @@ export class Authenticator {
this._cookieExpirationDays = params.cookieExpirationDays || 365;
this._disableCookieDomain = ('disableCookieDomain' in params && params.disableCookieDomain === true);
this._httpOnly = ('httpOnly' in params && params.httpOnly === true);
this._sameSite = params.sameSite;
this._cookieBase = `CognitoIdentityServiceProvider.${params.userPoolAppId}`;
this._logger = pino({
level: params.logLevel || 'silent', // Default to silent
Expand Down Expand Up @@ -75,6 +80,9 @@ export class Authenticator {
if ('httpOnly' in params && typeof params.httpOnly !== 'boolean') {
throw new Error('Expected params.httpOnly to be a boolean');
}
if ('sameSite' in params && !SAME_SITE_VALUES.includes(params.sameSite)) {
throw new Error('Expected params.sameSite to be a Strict || Lax || None');
}
}

/**
Expand Down Expand Up @@ -127,6 +135,7 @@ export class Authenticator {
expires: new Date(Date.now() + this._cookieExpirationDays * 864e+5),
secure: true,
httpOnly: this._httpOnly,
sameSite: this._sameSite,
};
const cookies = [
Cookies.serialize(`${usernameBase}.accessToken`, tokens.access_token, cookieAttributes),
Expand Down
10 changes: 10 additions & 0 deletions src/util/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export interface Cookie {
value: string;
}

export type SameSite = 'Strict' | 'Lax' | 'None';
export const SAME_SITE_VALUES: SameSite[] = ['Strict', 'Lax', 'None'];

/**
* Cookie attributes to be used inside 'Set-Cookie' header
*/
Expand All @@ -27,6 +30,12 @@ export interface CookieAttributes {
*/
httpOnly?: boolean;

/**
* The SameSite attribute allows you to declare if your cookie should be restricted to a first-party or same-site context.
* Refer to {@link https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#name-samesite-cookies RFC 6265 section 8.8.} for more details.
*/
sameSite?: SameSite;

/**
* The Max-Age attribute indicates the maximum lifetime of the cookie, represented as the number of seconds until
* the cookie expires.
Expand Down Expand Up @@ -94,6 +103,7 @@ export class Cookies {
...(attributes.maxAge ? [`Max-Age=${attributes.maxAge}`] : []),
...(attributes.secure ? ['Secure'] : []),
...(attributes.httpOnly ? ['HttpOnly'] : []),
...(attributes.sameSite ? [`SameSite=${attributes.sameSite}`] : []),
].join('; ');
}

Expand Down

0 comments on commit 49865ae

Please sign in to comment.