UNPKG

@guardian/pan-domain-node

Version:

NodeJs implementation of Guardian pan-domain auth verification

369 lines (298 loc) 15 kB
import { CookieFailure, FreshSuccess, gracePeriodInMillis, guardianValidation, StaleSuccess, User, UserValidationFailure } from '../src/api'; import { verifyUser, createCookie, PanDomainAuthentication } from '../src/panda'; import { fetchPublicKey } from '../src/fetch-public-key'; import { sampleCookie, sampleCookieWithoutMultifactor, sampleNonGuardianCookie, publicKey, privateKey } from './fixtures'; import {decodeBase64, parseCookie, ParsedCookie, parseUser} from "../src/utils"; jest.mock('../src/fetch-public-key'); jest.useFakeTimers(); function userFromCookie(cookie: string): User { // This function is only used to generate a `User` object from // a well-formed text fixture cookie, in order to check that successful // `AuthenticationResult`s have the right shape. As such we don't want // to have to deal with the case of a bad cookie so we just cast to `ParsedCookie`. const parsedCookie = parseCookie(cookie) as ParsedCookie; return parseUser(parsedCookie.data); } describe('verifyUser', function () { test("fail to authenticate if cookie is missing", () => { const expected: CookieFailure = { success: false, reason: 'no-cookie' }; expect(verifyUser(undefined, "", new Date(0), guardianValidation)).toStrictEqual(expected); }); test("fail to authenticate if signature is malformed", () => { const [data, signature] = sampleCookie.split("."); const testCookie = data + ".1234"; const expected: CookieFailure = { success: false, reason: 'invalid-cookie' }; expect(verifyUser(testCookie, publicKey, new Date(0), guardianValidation)).toStrictEqual(expected); }); test("fail to authenticate if cookie expired and we're outside the grace period", () => { // Cookie expires at epoch time 1234 const afterEndOfGracePeriod = new Date(1234 + gracePeriodInMillis + 1) const expected: CookieFailure = { success: false, reason: 'expired-cookie' }; expect(verifyUser(sampleCookie, publicKey, afterEndOfGracePeriod, guardianValidation)).toStrictEqual(expected); }); test("fail to authenticate if user fails validation function", () => { expect(verifyUser(sampleCookieWithoutMultifactor, publicKey, new Date(0), guardianValidation)).toStrictEqual({ success: false, reason: 'invalid-user', user: userFromCookie(sampleCookieWithoutMultifactor) }); expect(verifyUser(sampleNonGuardianCookie, publicKey, new Date(0), guardianValidation)).toStrictEqual({ success: false, reason: 'invalid-user', user: userFromCookie(sampleNonGuardianCookie) }); }); test("fail to authenticate with invalid-cookie reason if signature is not valid", () => { const expected: CookieFailure = { success: false, reason: 'invalid-cookie' }; const slightlyBadCookie = sampleCookie.slice(0, -2); expect(verifyUser(slightlyBadCookie, publicKey, new Date(0), guardianValidation)).toStrictEqual(expected); }); test("fail to authenticate with invalid-cookie reason if data part is not base64", () => { const expected: CookieFailure = { success: false, reason: 'invalid-cookie' }; const [_, signature] = sampleCookie.split("."); const nonBase64Data = "not-base64-data"; const testCookie = `${nonBase64Data}.${signature}`; expect(verifyUser(testCookie, publicKey, new Date(0), guardianValidation)).toStrictEqual(expected); }); test("fail to authenticate with invalid-cookie reason if signature part is not base64", () => { const expected: CookieFailure = { success: false, reason: 'invalid-cookie' }; const [data, _] = sampleCookie.split("."); const nonBase64Signature = "not-base64-signature"; const testCookie = `${data}.${nonBase64Signature}`; expect(verifyUser(testCookie, publicKey, new Date(0), guardianValidation)).toStrictEqual(expected); }); test("fail to authenticate with invalid-cookie reason if cookie has no dot separator", () => { const expected: CookieFailure = { success: false, reason: 'invalid-cookie' }; const noDotCookie = sampleCookie.replace(".", ""); expect(verifyUser(noDotCookie, publicKey, new Date(0), guardianValidation)).toStrictEqual(expected); }); test("fail to authenticate with invalid-cookie reason if cookie has multiple dot separators", () => { const expected: CookieFailure = { success: false, reason: 'invalid-cookie' }; const multipleDotsCookie = sampleCookie.replace(".", ".."); expect(verifyUser(multipleDotsCookie, publicKey, new Date(0), guardianValidation)).toStrictEqual(expected); }); test("authenticate if the cookie and user are valid", () => { const expected: FreshSuccess = { success: true, // Cookie is not expired so no need to refresh credentials shouldRefreshCredentials: false, user: userFromCookie(sampleCookie) }; expect(verifyUser(sampleCookie, publicKey, new Date(0), guardianValidation)).toStrictEqual(expected); }); test("authenticate with shouldRefreshCredentials if cookie expired but we're within the grace period", () => { const beforeEndOfGracePeriod = new Date(1234 + gracePeriodInMillis - 1); const expected: StaleSuccess = { success: true, user: userFromCookie(sampleCookie), shouldRefreshCredentials: true, mustRefreshByEpochTimeMillis: 1234 + gracePeriodInMillis }; expect(verifyUser(sampleCookie, publicKey, beforeEndOfGracePeriod, guardianValidation)).toStrictEqual(expected); }); }); describe('createCookie', function () { it('should return the same cookie based on the user details being provided', function () { const user: User = { firstName: "Test", lastName: "User", email: "test.user@guardian.co.uk", authenticatingSystem: "test", authenticatedIn: ["test"], expires: 1234, multifactor: true }; const cookie = createCookie(user, privateKey); expect(decodeBase64(cookie)).toEqual(decodeBase64(sampleCookie)); expect(cookie).toEqual(sampleCookie) }); }); describe('panda class', function () { beforeEach(() => { (fetchPublicKey as jest.MockedFunction<typeof fetchPublicKey>).mockResolvedValue({ key: 'PUBLIC KEY', lastUpdated: new Date() }); }); describe('stop', () => { it('stops auto refresh', () => { const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u)=> true); expect(panda.keyUpdateTimer).not.toBeUndefined(); panda.stop(); expect(panda.keyUpdateTimer).toBeUndefined(); }); }); describe('getPublicKey', () => { it('getsPublicKey immediately when last fetch is within the cache time', async () => { const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u)=> true); const fetchesBeforeGet = (fetchPublicKey as jest.MockedFunction<typeof fetchPublicKey>).mock.calls.length; await expect(panda.getPublicKey()).resolves.toEqual('PUBLIC KEY'); const fetchesAfterGet = (fetchPublicKey as jest.MockedFunction<typeof fetchPublicKey>).mock.calls.length; expect(fetchesAfterGet).toEqual(fetchesBeforeGet); }); it('getsPublicKey after refetching when last fetch is outside the cache time', async () => { // cache time is 1 min const fiveMinsAgo = new Date(); fiveMinsAgo.setMinutes(fiveMinsAgo.getMinutes() - 5); (fetchPublicKey as jest.MockedFunction<typeof fetchPublicKey>).mockResolvedValue({ key: 'PUBLIC KEY', lastUpdated: fiveMinsAgo }); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u)=> true); const fetchesBefore = (fetchPublicKey as jest.MockedFunction<typeof fetchPublicKey>).mock.calls.length; await expect(panda.getPublicKey()).resolves.toEqual('PUBLIC KEY'); (fetchPublicKey as jest.MockedFunction<typeof fetchPublicKey>).mockResolvedValue({ key: 'PUBLIC KEY 2', lastUpdated: fiveMinsAgo }); const fetchesAfter = (fetchPublicKey as jest.MockedFunction<typeof fetchPublicKey>).mock.calls.length; await expect(panda.getPublicKey()).resolves.toEqual('PUBLIC KEY 2'); expect(fetchesAfter).toEqual(fetchesBefore + 1); }); }); describe('verify', () => { beforeEach(() => { (fetchPublicKey as jest.MockedFunction<typeof fetchPublicKey>).mockResolvedValue({ key: publicKey, lastUpdated: new Date() }); }); it('should authenticate if cookie and user are valid', async () => { jest.setSystemTime(100); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u)=> true); const authenticationResult = await panda.verify(`cookiename=${sampleCookie}`); const expected: FreshSuccess = { success: true, // Cookie is not expired shouldRefreshCredentials: false, user: userFromCookie(sampleCookie) } expect(authenticationResult).toStrictEqual(expected); }); it('should authenticate if cookie and user are valid when multiple cookies are passed', async () => { jest.setSystemTime(100); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u)=> true); const authenticationResult = await panda.verify(`a=blah; b=stuff; cookiename=${sampleCookie}; c=4958345`); const expected: FreshSuccess = { success: true, // Cookie is not expired shouldRefreshCredentials: false, user: userFromCookie(sampleCookie) }; expect(authenticationResult).toStrictEqual(expected); }); it('should fail to authenticate if cookie expired and we\'re outside the grace period', async () => { // Cookie expiry is 1234 const afterEndOfGracePeriodEpochMillis = 1234 + gracePeriodInMillis + 1 jest.setSystemTime(afterEndOfGracePeriodEpochMillis); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u)=> true); const authenticationResult = await panda.verify(`cookiename=${sampleCookie}`); const expected: CookieFailure = { success: false, reason: 'expired-cookie' }; expect(authenticationResult).toStrictEqual(expected); }); it('authenticate with shouldRefreshCredentials if cookie expired but we\'re within the grace period', async () => { // Cookie expiry is 1234 const beforeEndOfGracePeriodEpochMillis = 1234 + gracePeriodInMillis - 1; jest.setSystemTime(beforeEndOfGracePeriodEpochMillis); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u)=> true); const authenticationResult = await panda.verify(`cookiename=${sampleCookie}`); const expected: StaleSuccess = { success: true, shouldRefreshCredentials: true, mustRefreshByEpochTimeMillis: 1234 + gracePeriodInMillis, user: userFromCookie(sampleCookie) }; expect(authenticationResult).toStrictEqual(expected); }); it('should fail to authenticate if user is not valid', async () => { jest.setSystemTime(100); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', guardianValidation); const authenticationResult = await panda.verify(`cookiename=${sampleNonGuardianCookie}`); const expected: UserValidationFailure = { success: false, reason: 'invalid-user', user: userFromCookie(sampleNonGuardianCookie) }; expect(authenticationResult).toStrictEqual(expected); }); it('should fail to authenticate if there is no cookie with the correct name', async () => { jest.setSystemTime(100); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', guardianValidation); const authenticationResult = await panda.verify(`wrongcookiename=${sampleNonGuardianCookie}`); const expected: CookieFailure = { success: false, reason: "no-cookie" }; expect(authenticationResult).toStrictEqual(expected); }); it('should fail to authenticate if the cookie request header is malformed', async () => { jest.setSystemTime(100); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', guardianValidation); // The cookie headers should be semicolon-separated name=valueg const authenticationResult = await panda.verify(sampleNonGuardianCookie); const expected: CookieFailure = { success: false, reason: "no-cookie" }; expect(authenticationResult).toStrictEqual(expected); }); it('should fail to authenticate if there is no cookie with the correct name out of multiple cookies', async () => { jest.setSystemTime(100); const panda = new PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', guardianValidation); const authenticationResult = await panda.verify(`wrongcookiename=${sampleNonGuardianCookie}; anotherwrongcookiename=${sampleNonGuardianCookie}`); const expected: CookieFailure = { success: false, reason: "no-cookie" }; expect(authenticationResult).toStrictEqual(expected); }); it('should fail to authenticate with invalid-cookie reason if cookie is malformed', async () => { jest.setSystemTime(100); const panda = new PanDomainAuthentication('rightcookiename', 'region', 'bucket', 'keyfile', guardianValidation); // There is a valid Panda cookie in here, but it's under the wrong name const authenticationResult = await panda.verify(`wrongcookiename=${sampleNonGuardianCookie}; rightcookiename=not-valid-panda-cookie`); const expected: CookieFailure = { success: false, reason: "invalid-cookie" }; expect(authenticationResult).toStrictEqual(expected); }); it('should fail to authenticate with no-cookie reason if no cookie is present at all', async () => { jest.setSystemTime(100); const panda = new PanDomainAuthentication('rightcookiename', 'region', 'bucket', 'keyfile', guardianValidation); const noCookie = undefined; const authenticationResult = await panda.verify(noCookie); const expected: CookieFailure = { success: false, reason: "no-cookie" }; expect(authenticationResult).toStrictEqual(expected); }); }); });