Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FLEXIN-212] - Now generating security headers #43

Merged
merged 4 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/__tests__/sessionDataHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import fetchMock from "fetch-mock-jest";

import { sessionDataHandler } from "../sessionDataHandler";

Object.defineProperty(navigator, "mediaCapabilities", {
writable: true,
value: {
decodingInfo: jest.fn().mockResolvedValue({} as unknown as MediaCapabilitiesDecodingInfo)
}
});

describe("session data handler", () => {
afterEach(() => {
jest.clearAllMocks();
Expand All @@ -24,15 +31,22 @@ describe("session data handler", () => {
it("should store new token data in local storage", async () => {
const setLocalStorageItemSpy = jest.spyOn(Object.getPrototypeOf(window.localStorage), "setItem");

jest.useFakeTimers().setSystemTime(new Date("2023-01-01"));

const currentTime = Date.now();
const tokenPayload = {
expiration: Date.now() + 10e5,
expiration: currentTime + 10e5,
token: "new token",
conversationSid: "sid"
};
fetchMock.once(() => true, tokenPayload);
await sessionDataHandler.fetchAndStoreNewSession({ formData: {} });

expect(setLocalStorageItemSpy).toHaveBeenCalledWith("TWILIO_WEBCHAT_WIDGET", JSON.stringify(tokenPayload));
const expected = {
...tokenPayload,
loginTimestamp: currentTime
};
expect(setLocalStorageItemSpy).toHaveBeenCalledWith("TWILIO_WEBCHAT_WIDGET", JSON.stringify(expected));
});

it("should return a new token", async () => {
Expand Down Expand Up @@ -121,6 +135,8 @@ describe("session data handler", () => {
});

it("should store new token in local storage", async () => {
jest.useFakeTimers().setSystemTime(new Date("2023-01-01"));

jest.spyOn(Object.getPrototypeOf(window.localStorage), "getItem").mockReturnValueOnce(
JSON.stringify({
expiration: Date.now() + 10e5,
Expand Down
33 changes: 24 additions & 9 deletions src/sessionDataHandler.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import log from "loglevel";

import { Token } from "./definitions";
import { generateSecurityHeaders } from "./utils/generateSecurityHeaders";

const LOCAL_STORAGE_ITEM_ID = "TWILIO_WEBCHAT_WIDGET";
export const LOCALSTORAGE_SESSION_ITEM_ID = "TWILIO_WEBCHAT_WIDGET";

let _endpoint = "";

type SessionDataStorage = Token & {
loginTimestamp: number | null;
};

export async function contactBackend<T>(endpointRoute: string, body: Record<string, unknown> = {}): Promise<T> {
const securityHeaders = await generateSecurityHeaders();
const response = await fetch(_endpoint + endpointRoute, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
"Content-Type": "application/json",
...securityHeaders
},
body: JSON.stringify(body)
});
Expand All @@ -23,12 +30,12 @@ export async function contactBackend<T>(endpointRoute: string, body: Record<stri
return response.json();
}

function storeSessionData(data: Token) {
localStorage.setItem(LOCAL_STORAGE_ITEM_ID, JSON.stringify(data));
function storeSessionData(data: SessionDataStorage) {
localStorage.setItem(LOCALSTORAGE_SESSION_ITEM_ID, JSON.stringify(data));
}

function getStoredSessionData() {
const item = localStorage.getItem(LOCAL_STORAGE_ITEM_ID);
const item = localStorage.getItem(LOCALSTORAGE_SESSION_ITEM_ID);
let storedData: Token;

if (!item) {
Expand All @@ -42,7 +49,7 @@ function getStoredSessionData() {
return null;
}

return storedData;
return storedData as SessionDataStorage;
}

export const sessionDataHandler = {
Expand All @@ -69,6 +76,11 @@ export const sessionDataHandler = {
}

log.debug("sessionDataHandler: existing token still valid, using existing session data");

storeSessionData({
...storedTokenData,
loginTimestamp: storedTokenData.loginTimestamp || null
vinesh-kumar marked this conversation as resolved.
Show resolved Hide resolved
});
return storedTokenData;
},

Expand Down Expand Up @@ -97,12 +109,12 @@ export const sessionDataHandler = {
};

storeSessionData(updatedSessionData);

return updatedSessionData;
},

fetchAndStoreNewSession: async ({ formData }: { formData: Record<string, unknown> }) => {
log.debug("sessionDataHandler: trying to create new session");
const loginTimestamp = Date.now();

let newTokenData;

Expand All @@ -113,12 +125,15 @@ export const sessionDataHandler = {
}

log.debug("sessionDataHandler: new session successfully created");
storeSessionData(newTokenData);
storeSessionData({
...newTokenData,
loginTimestamp
});

return newTokenData;
},

clear: () => {
localStorage.removeItem(LOCAL_STORAGE_ITEM_ID);
localStorage.removeItem(LOCALSTORAGE_SESSION_ITEM_ID);
}
};
127 changes: 127 additions & 0 deletions src/utils/generateSecurityHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
generateSecurityHeaders,
DEFAULT_COOKIE_ENABLED,
DEFAULT_LOGIN_TIMESTAMP,
DEFAULT_NAVIGATOR_LANG,
HEADER_SEC_WEBCHAT,
HEADER_SEC_BROWSEROS,
HEADER_SEC_USERSETTINGS,
HEADER_SEC_DECODER
} from "./generateSecurityHeaders";

describe("Generate Security Headers", () => {
it("Should generateSecurityHeaders", async () => {
jest.useFakeTimers().setSystemTime(new Date("2023-01-01"));
// eslint-disable-next-line no-proto
jest.spyOn(localStorage.__proto__, "getItem").mockReturnValue(
JSON.stringify({
token: "token",
loginTimestamp: "TODAY"
})
);
Object.defineProperty(navigator, "mediaCapabilities", {
writable: true,
value: {
decodingInfo: jest
.fn()
.mockResolvedValue({ decodingInfo: true } as unknown as MediaCapabilitiesDecodingInfo)
}
});
Object.defineProperty(navigator, "userAgent", {
writable: true,
value: "USER_AGENT"
});
Object.defineProperty(navigator, "cookieEnabled", {
writable: true,
value: true
});
Object.defineProperty(navigator, "language", {
writable: true,
value: "en_US"
});

const headers = await generateSecurityHeaders();

expect(headers).not.toBeFalsy();
expect(headers[HEADER_SEC_BROWSEROS]).toEqual("USER_AGENT");
expect(JSON.parse(headers[HEADER_SEC_USERSETTINGS])).toMatchObject({
language: "en_US",
cookieEnabled: true,
userTimezone: new Date().getTimezoneOffset()
});
expect(JSON.parse(headers[HEADER_SEC_DECODER])).toMatchObject({
audio: { decodingInfo: true },
video: { decodingInfo: true }
});
expect(JSON.parse(headers[HEADER_SEC_WEBCHAT])).toMatchObject({
loginTimestamp: "TODAY",
deploymentKey: null
});
});

// eslint-disable-next-line sonarjs/no-identical-functions
it("generateSecurityHeaders should work with default values", async () => {
const sampleDefaultCodecInfo = {
powerEfficient: false,
smooth: false,
supported: false,
keySystemAccess: "twilio-keySystemAccess"
};
jest.useFakeTimers().setSystemTime(new Date("2023-01-01"));
// eslint-disable-next-line no-proto
jest.spyOn(localStorage.__proto__, "getItem").mockReturnValue(
JSON.stringify({
token: "token",
loginTimestamp: null
})
);
Object.defineProperty(navigator, "mediaCapabilities", {
writable: true,
value: {
decodingInfo: jest.fn().mockRejectedValue(null)
}
});
Object.defineProperty(navigator, "userAgent", {
writable: true,
value: "USER_AGENT_2"
});
Object.defineProperty(navigator, "cookieEnabled", {
writable: true,
value: null
});
Object.defineProperty(navigator, "language", {
writable: true,
value: null
});

let headers = await generateSecurityHeaders();

expect(headers).not.toBeFalsy();
expect(headers[HEADER_SEC_BROWSEROS]).toEqual("USER_AGENT_2");
expect(JSON.parse(headers[HEADER_SEC_USERSETTINGS])).toMatchObject({
language: DEFAULT_NAVIGATOR_LANG,
cookieEnabled: DEFAULT_COOKIE_ENABLED,
userTimezone: new Date().getTimezoneOffset()
});
expect(JSON.parse(headers[HEADER_SEC_DECODER])).toMatchObject({
audio: sampleDefaultCodecInfo,
video: sampleDefaultCodecInfo
});
expect(JSON.parse(headers[HEADER_SEC_WEBCHAT])).toMatchObject({
loginTimestamp: DEFAULT_LOGIN_TIMESTAMP,
deploymentKey: null
});

Object.defineProperty(navigator, "mediaCapabilities", {
writable: true,
value: null
});

headers = await generateSecurityHeaders();

expect(JSON.parse(headers[HEADER_SEC_DECODER])).toMatchObject({
audio: sampleDefaultCodecInfo,
video: sampleDefaultCodecInfo
});
});
});
109 changes: 109 additions & 0 deletions src/utils/generateSecurityHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import log from "loglevel";

import { LOCALSTORAGE_SESSION_ITEM_ID } from "../sessionDataHandler";
import { store } from "../store/store";

export const HEADER_SEC_DECODER = "X-Sec-Decoders";
export const HEADER_SEC_BROWSEROS = "X-Sec-Browseros";
export const HEADER_SEC_USERSETTINGS = "X-Sec-Usersettings";
export const HEADER_SEC_WEBCHAT = "X-Sec-Webchatinfo";

type SecurityHeadersType = {
[HEADER_SEC_BROWSEROS]: string;
[HEADER_SEC_USERSETTINGS]: string;
[HEADER_SEC_WEBCHAT]: string;
[HEADER_SEC_DECODER]: string;
};

type MediaCapabilitiesInfo = MediaCapabilitiesDecodingInfo | MediaCapabilitiesEncodingInfo;

export const DEFAULT_NAVIGATOR_LANG = "en_IN";
export const DEFAULT_COOKIE_ENABLED = false;
export const DEFAULT_LOGIN_TIMESTAMP = "9999999";
const DEFAULT_CODEC_INFO = {
powerEfficient: false,
smooth: false,
supported: false,
keySystemAccess: "twilio-keySystemAccess"
} as MediaCapabilitiesInfo;

const getUserSpecificSettings = () => {
return {
language: navigator.language ?? DEFAULT_NAVIGATOR_LANG,
cookieEnabled: navigator.cookieEnabled ?? DEFAULT_COOKIE_ENABLED,
userTimezone: new Date().getTimezoneOffset()
};
};

const getWebchatInfo = () => {
const sessionStorage = localStorage.getItem(LOCALSTORAGE_SESSION_ITEM_ID) as string;
const reduxState = store.getState();

let parsedStorage = null;

try {
parsedStorage = JSON.parse(sessionStorage);
} catch (e) {
log.log("Couldn't parse locally stored data");
}

return {
loginTimestamp: parsedStorage?.loginTimestamp || DEFAULT_LOGIN_TIMESTAMP,
deploymentKey: reduxState?.config?.deploymentKey || null
};
};

const getAudioVideoDecoders = async () => {
const audioDecorder = navigator.mediaCapabilities?.decodingInfo({
type: "file",
audio: {
contentType: "audio/mp3",
channels: "2",
bitrate: 132700,
samplerate: 5200
}
});
const videoDecorder = navigator.mediaCapabilities?.decodingInfo({
type: "file",
audio: {
contentType: "audio/mp4",
channels: "2",
bitrate: 132700,
samplerate: 5200
}
});

return Promise.allSettled([audioDecorder, videoDecorder]).then(
(results: Array<PromiseSettledResult<MediaCapabilitiesInfo>>) => {
const allFullfied = results.every((result) => result.status === "fulfilled" && Boolean(result.value));

let audio: MediaCapabilitiesInfo = DEFAULT_CODEC_INFO;
let video: MediaCapabilitiesInfo = DEFAULT_CODEC_INFO;

if (allFullfied) {
const _res = results as Array<PromiseFulfilledResult<MediaCapabilitiesInfo>>;

audio = _res[0].value;
video = _res[1].value;
}

return {
audio,
video
};
}
);
};

// eslint-disable-next-line import/no-unused-modules
export const generateSecurityHeaders = async (): Promise<SecurityHeadersType> => {
const headers = {} as SecurityHeadersType;
return getAudioVideoDecoders().then((decoders) => {
headers[HEADER_SEC_WEBCHAT] = JSON.stringify(getWebchatInfo());
headers[HEADER_SEC_USERSETTINGS] = JSON.stringify(getUserSpecificSettings());
headers[HEADER_SEC_DECODER] = JSON.stringify(decoders);
headers[HEADER_SEC_BROWSEROS] = navigator.userAgent;

return headers;
});
};