Skip to content

Commit

Permalink
fix: auth cookie serialization/deserialization (#93)
Browse files Browse the repository at this point in the history
* fix: auth cookie serialization/deserialization

* format

* add suffix

* format

* fix connection loop

* chore: add test coverage to the new issues

* chore: bump version

* chore: PR comments

* feat: use real cookies for testing

* chore: restore timer

---------

Co-authored-by: Alexandru Ciobanu <alex+git@ciobanu.org>
  • Loading branch information
matus-vacula and pavkam committed Jan 12, 2024
1 parent bb79b37 commit 60be189
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 60 deletions.
3 changes: 2 additions & 1 deletion package.json
@@ -1,6 +1,6 @@
{
"name": "@bucketco/tracking-sdk",
"version": "2.1.8",
"version": "2.1.9",
"license": "MIT",
"private": false,
"repository": {
Expand Down Expand Up @@ -34,6 +34,7 @@
"@preact/preset-vite": "^2.5.0",
"@types/js-cookie": "^3.0.3",
"@types/node": "^20.4.4",
"@types/jsdom": "^21.1.6",
"@types/webpack": "^5.28.2",
"@types/webpack-node-externals": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^6.4.0",
Expand Down
24 changes: 17 additions & 7 deletions src/prompt-storage.ts
Expand Up @@ -26,7 +26,7 @@ export const rememberAuthToken = (
token: string,
expiresAt: Date,
) => {
Cookies.set(`bucket-token-${userId}`, `${channel}:${token}`, {
Cookies.set(`bucket-token-${userId}`, JSON.stringify({ channel, token }), {
expires: expiresAt,
sameSite: "strict",
secure: true,
Expand All @@ -39,13 +39,23 @@ export const getAuthToken = (userId: string) => {
return undefined;
}

const [channel, token] = val.split(":");
if (!channel?.length || !token?.length) {
try {
const { channel, token } = JSON.parse(val) as {
channel: string;
token: string;
};
if (!channel?.length || !token?.length) {
return undefined;
}
return {
channel,
token,
};
} catch (e) {
return undefined;
}
};

return {
channel,
token,
};
export const forgetAuthToken = (userId: string) => {
Cookies.remove(`bucket-token-${userId}`);
};
11 changes: 8 additions & 3 deletions src/sse.ts
@@ -1,7 +1,11 @@
import fetch from "cross-fetch";

import { SSE_REALTIME_HOST } from "./config";
import { getAuthToken, rememberAuthToken } from "./prompt-storage";
import {
forgetAuthToken,
getAuthToken,
rememberAuthToken,
} from "./prompt-storage";

interface AblyTokenDetails {
token: string;
Expand All @@ -12,8 +16,8 @@ interface AblyTokenRequest {
keyName: string;
}

const ABLY_TOKEN_ERROR_MIN = 40140;
const ABLY_TOKEN_ERROR_MAX = 40149;
const ABLY_TOKEN_ERROR_MIN = 40000;
const ABLY_TOKEN_ERROR_MAX = 49999;

export class AblySSEChannel {
private isOpen: boolean = false;
Expand Down Expand Up @@ -130,6 +134,7 @@ export class AblySSEChannel {
errorCode <= ABLY_TOKEN_ERROR_MAX
) {
this.log("event source token expired, refresh required");
forgetAuthToken(this.userId);
}
} else {
const connectionState = (e as any)?.target?.readyState;
Expand Down
242 changes: 198 additions & 44 deletions test/prompt-storage.test.ts
@@ -1,82 +1,236 @@
import Cookies from "js-cookie";
import { describe, expect, test, vi } from "vitest";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
test,
vi,
} from "vitest";

import {
checkPromptMessageCompleted,
forgetAuthToken,
getAuthToken,
markPromptMessageCompleted,
rememberAuthToken,
} from "../src/prompt-storage";

vi.mock("js-cookie");

describe("prompt-storage", () => {
test("markPromptMessageCompleted", async () => {
const spy = vi.spyOn(Cookies, "set");
beforeAll(() => {
const cookies: Record<string, string> = {};

Object.defineProperty(document, "cookie", {
set: (val: string) => {
if (!val) {
Object.keys(cookies).forEach((k) => delete cookies[k]);
return;
}
const i = val.indexOf("=");
cookies[val.slice(0, i)] = val.slice(i + 1);
},
get: () =>
Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; "),
});

markPromptMessageCompleted("user", "prompt", new Date("2021-01-01"));
vi.setSystemTime(new Date("2024-01-11T09:55:37.000Z"));
});

expect(spy).toHaveBeenCalledWith("bucket-prompt-user", "prompt", {
expires: new Date("2021-01-01"),
sameSite: "strict",
secure: true,
});
afterEach(() => {
document.cookie = undefined!;
vi.clearAllMocks();
});

test("checkPromptMessageCompleted with positive result", async () => {
const spy = vi.spyOn(Cookies, "get").mockReturnValue("prompt" as any);
afterAll(() => {
vi.useRealTimers();
});

expect(checkPromptMessageCompleted("user", "prompt")).toBe(true);
describe("markPromptMessageCompleted", () => {
test("adds new cookie", async () => {
markPromptMessageCompleted(
"user",
"prompt2",
new Date("2024-01-04T14:01:20.000Z"),
);

expect(document.cookie).toBe(
"bucket-prompt-user=prompt2; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
);
});

test("rewrites existing cookie", async () => {
document.cookie =
"bucket-prompt-user=prompt1; path=/; expires=Thu, 04 Jan 2021 14:01:20 GMT; sameSite=strict; secure";

markPromptMessageCompleted(
"user",
"prompt2",
new Date("2024-01-04T14:01:20.000Z"),
);

expect(spy).toHaveBeenCalledWith("bucket-prompt-user");
expect(document.cookie).toBe(
"bucket-prompt-user=prompt2; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
);
});
});

test("checkPromptMessageCompleted with negative result", async () => {
const spy = vi.spyOn(Cookies, "get").mockReturnValue("other" as any);
describe("checkPromptMessageCompleted", () => {
test("cookie with same use and prompt results in true", async () => {
document.cookie =
"bucket-prompt-user=prompt; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";

expect(checkPromptMessageCompleted("user", "prompt")).toBe(false);
expect(checkPromptMessageCompleted("user", "prompt")).toBe(true);

expect(spy).toHaveBeenCalledWith("bucket-prompt-user");
});
expect(document.cookie).toBe(
"bucket-prompt-user=prompt; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure",
);
});

test("cookie with different prompt results in false", async () => {
document.cookie =
"bucket-prompt-user=prompt1; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";

test("rememberAuthToken", async () => {
const spy = vi.spyOn(Cookies, "set");
expect(checkPromptMessageCompleted("user", "prompt2")).toBe(false);
});

rememberAuthToken("user", "channel", "token", new Date("2021-01-01"));
test("cookie with different user results in false", async () => {
document.cookie =
"bucket-prompt-user1=prompt1; path=/; expires=Thu, 04 Jan 2024 14:01:20 GMT; sameSite=strict; secure";

expect(checkPromptMessageCompleted("user2", "prompt1")).toBe(false);
});

expect(spy).toHaveBeenCalledWith("bucket-token-user", "channel:token", {
expires: new Date("2021-01-01"),
sameSite: "strict",
secure: true,
test("no cookie results in false", async () => {
expect(checkPromptMessageCompleted("user", "prompt2")).toBe(false);
});
});

test("getAuthToken with positive result", async () => {
const spy = vi
.spyOn(Cookies, "get")
.mockReturnValue("channel:token" as any);
describe("rememberAuthToken", () => {
test("adds new cookie if none was there", async () => {
expect(document.cookie).toBe("");

expect(getAuthToken("user")).toStrictEqual({
channel: "channel",
token: "token",
rememberAuthToken(
'user1"%%',
"channel:suffix",
"secret$%",
new Date("2024-01-02T15:02:20.000Z"),
);

expect(document.cookie).toBe(
"bucket-token-user1%22%25%25={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure",
);
});

expect(spy).toHaveBeenCalledWith("bucket-token-user");
test("replaces existing cookie for same user", async () => {
document.cookie =
"bucket-token-user1%22%25%25={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

rememberAuthToken(
'user1"%%',
"channel2:suffix2",
"secret2$%",
new Date("2023-01-02T15:02:20.000Z"),
);

expect(document.cookie).toBe(
"bucket-token-user1%22%25%25={%22channel%22:%22channel2:suffix2%22%2C%22token%22:%22secret2$%25%22}; path=/; expires=Mon, 02 Jan 2023 15:02:20 GMT; sameSite=strict; secure",
);
});
});

test("getAuthToken with error", async () => {
const spy = vi.spyOn(Cookies, "get").mockReturnValue("token" as any);
describe("forgetAuthToken", () => {
test("clears the user's cookie if even if there was nothing before", async () => {
forgetAuthToken("user");

expect(getAuthToken("user")).toBeUndefined();
expect(document.cookie).toBe(
"bucket-token-user=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT",
);
});

expect(spy).toHaveBeenCalledWith("bucket-token-user");
test("clears the user's cookie", async () => {
document.cookie =
"bucket-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

forgetAuthToken("user1");

expect(document.cookie).toBe(
"bucket-token-user1=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT",
);
});

test("does nothing if there is a cookie for a different user", async () => {
document.cookie =
"bucket-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2028 15:02:20 GMT; sameSite=strict; secure";

forgetAuthToken("user2");

expect(document.cookie).toBe(
"bucket-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2028 15:02:20 GMT; sameSite=strict; secure; bucket-token-user2=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT",
);
});
});

test("getAuthToken with negative result", async () => {
const spy = vi.spyOn(Cookies, "get").mockReturnValue(undefined as any);
describe("getAuthToken", () => {
test("returns the auth token if it's available for the user", async () => {
document.cookie =
"bucket-token-user1%22%25%25={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

expect(getAuthToken('user1"%%')).toStrictEqual({
channel: "channel:suffix",
token: "secret$%",
});
});

test("return undefined if no cookie for user", async () => {
document.cookie =
"bucket-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

expect(getAuthToken("user")).toBeUndefined();
expect(getAuthToken("user2")).toBeUndefined();
});

test("returns undefined if no cookie", async () => {
expect(getAuthToken("user")).toBeUndefined();
});

test("return undefined if corrupted cookie", async () => {
document.cookie =
"bucket-token-user={channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

expect(getAuthToken("user")).toBeUndefined();
});

test("return undefined if a field is missing", async () => {
document.cookie =
"bucket-token-user={%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure";

expect(getAuthToken("user")).toBeUndefined();
});
});

expect(spy).toHaveBeenCalledWith("bucket-token-user");
test("manages all cookies for the user", () => {
rememberAuthToken(
"user1",
"channel:suffix",
"secret$%",
new Date("2024-01-02T15:02:20.000Z"),
);

markPromptMessageCompleted(
"user1",
"alex-prompt",
new Date("2024-01-02T15:03:20.000Z"),
);

expect(document.cookie).toBe(
"bucket-token-user1={%22channel%22:%22channel:suffix%22%2C%22token%22:%22secret$%25%22}; path=/; expires=Tue, 02 Jan 2024 15:02:20 GMT; sameSite=strict; secure; bucket-prompt-user1=alex-prompt; path=/; expires=Tue, 02 Jan 2024 15:03:20 GMT; sameSite=strict; secure",
);

forgetAuthToken("user1");

expect(document.cookie).toBe(
"bucket-token-user1=; path=/; expires=Wed, 10 Jan 2024 09:55:37 GMT; bucket-prompt-user1=alex-prompt; path=/; expires=Tue, 02 Jan 2024 15:03:20 GMT; sameSite=strict; secure",
);
});
});

0 comments on commit 60be189

Please sign in to comment.