diff --git a/README.md b/README.md index 5d7f4b4..640cf03 100644 --- a/README.md +++ b/README.md @@ -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.* diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index e6c1200..47893eb 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -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( @@ -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 diff --git a/__tests__/util/cookie.test.ts b/__tests__/util/cookie.test.ts index f360459..3ecc26a 100644 --- a/__tests__/util/cookie.test.ts +++ b/__tests__/util/cookie.test.ts @@ -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', () => { @@ -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']); + }); }); diff --git a/src/index.ts b/src/index.ts index 0fda922..f0b7d10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; @@ -14,6 +16,7 @@ interface AuthenticatorParams { cookieExpirationDays?: number; disableCookieDomain?: boolean; httpOnly?: boolean; + sameSite?: SameSite; logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; } @@ -26,6 +29,7 @@ export class Authenticator { _cookieExpirationDays: number; _disableCookieDomain: boolean; _httpOnly: boolean; + _sameSite?: SameSite; _cookieBase: string; _logger; _jwtVerifier; @@ -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 @@ -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'); + } } /** @@ -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), diff --git a/src/util/cookie.ts b/src/util/cookie.ts index d41aafe..8ac522c 100644 --- a/src/util/cookie.ts +++ b/src/util/cookie.ts @@ -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 */ @@ -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. @@ -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('; '); }