@guardian/pan-domain-node
Version:
NodeJs implementation of Guardian pan-domain auth verification
369 lines (298 loc) • 15 kB
text/typescript
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);
});
});
});