Skip to content

Single User Auth #202

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

Merged
merged 16 commits into from
Aug 9, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
39 changes: 39 additions & 0 deletions packages/cli/src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ export async function promptApiToken(): Promise<string> {
return cfApiToken;
}

export async function promptAppPassword(): Promise<string> {
const appPassword = await password({
message: "Enter the password you will use to access the Counterscale Dashboard",
mask: "*",
validate: (val) => {
if (val.length === 0) {
return "Value is required";
}
},
});

if (isCancel(appPassword)) {
bail();
}

if (typeof appPassword !== "string") {
throw new Error("App password is required");
}

return appPassword;
}

export async function promptDeploy(
counterscaleVersion: string,
): Promise<boolean> {
Expand Down Expand Up @@ -284,6 +306,23 @@ Your token needs these permissions:
throw new Error("Error setting Cloudflare API token");
}
}

const appPassword = await promptAppPassword();
if (appPassword) {
const s = spinner();
s.start(`Setting CounterScale Application Password ...`);

if (
await cloudflare.setCloudflareSecrets({
CF_APP_PASSWORD: appPassword,
})
) {
s.stop("Setting CounterScale Application Password ... Done!");
} else {
s.stop("Error setting CounterScale Application Password", 1);
throw new Error("Error setting CounterScale Application Password");
}
}
} catch (err) {
console.error(err);
process.exit(1);
Expand Down
3 changes: 2 additions & 1 deletion packages/server/app/analytics/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface AnalyticsCountResult {
bounces: number;
}

export type ViewsGroupedByInterval = [string, AnalyticsCountResult][];
/** Given an AnalyticsCountResult object, and an object representing a row returned from
* CF Analytics Engine w/ counts grouped by isVisitor, accumulate view,
* visit, and visitor counts.
Expand Down Expand Up @@ -193,7 +194,7 @@ export class AnalyticsEngineAPI {
endDateTime: Date, // end date/time in local timezone
tz?: string, // local timezone
filters: SearchFilters = {},
) {
): Promise<ViewsGroupedByInterval> {
let intervalCount = 1;

// keeping this code here once we start allowing bigger intervals (e.g. intervals of 2 hours)
Expand Down
177 changes: 177 additions & 0 deletions packages/server/app/lib/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { login, logout, requireAuth, getUser } from "../auth";
import { createSessionStorage } from "../session";

vi.mock("../session");
vi.mock("react-router", () => ({
redirect: vi.fn((url, options) => ({ url, options })),
}));

const mockSessionStorage = {
getSession: vi.fn(),
commitSession: vi.fn(),
destroySession: vi.fn(),
};

const mockSession = {
get: vi.fn(),
set: vi.fn(),
};

const mockEnv = {
CF_APP_PASSWORD: "test-password",
} as Env;

describe("auth", () => {
beforeEach(() => {
vi.mocked(createSessionStorage).mockReturnValue(mockSessionStorage as any);

Check warning on line 27 in packages/server/app/lib/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
mockSessionStorage.getSession.mockResolvedValue(mockSession);
mockSessionStorage.commitSession.mockResolvedValue("session-cookie");
mockSessionStorage.destroySession.mockResolvedValue("destroyed-cookie");
mockSession.get.mockReturnValue(false);
mockSession.set.mockReturnValue(undefined);
});

afterEach(() => {
vi.clearAllMocks();
});

describe("login", () => {
test("should login successfully with correct password", async () => {
const request = new Request("http://localhost", {
headers: { Cookie: "existing-cookie" },
});

const result = await login(request, "test-password", mockEnv);

expect(createSessionStorage).toHaveBeenCalledWith("test-password");
expect(mockSessionStorage.getSession).toHaveBeenCalledWith("existing-cookie");
expect(mockSession.set).toHaveBeenCalledWith("authenticated", true);
expect(mockSessionStorage.commitSession).toHaveBeenCalledWith(mockSession);
expect(result).toEqual({
url: "/dashboard",
options: {
headers: {
"Set-Cookie": "session-cookie",
},
},
});
});

test("should throw error with incorrect password", async () => {
const request = new Request("http://localhost");

await expect(login(request, "wrong-password", mockEnv)).rejects.toThrow(
"Invalid password"
);
});

test("should handle request without cookie header", async () => {
const request = new Request("http://localhost");

await login(request, "test-password", mockEnv);

expect(mockSessionStorage.getSession).toHaveBeenCalledWith(null);
});
});

describe("logout", () => {
test("should logout successfully", async () => {
const request = new Request("http://localhost", {
headers: { Cookie: "session-cookie" },
});

const result = await logout(request, mockEnv);

expect(createSessionStorage).toHaveBeenCalledWith("test-password");
expect(mockSessionStorage.getSession).toHaveBeenCalledWith("session-cookie");
expect(mockSessionStorage.destroySession).toHaveBeenCalledWith(mockSession);
expect(result).toEqual({
url: "/",
options: {
headers: {
"Set-Cookie": "destroyed-cookie",
},
},
});
});

test("should handle request without cookie header", async () => {
const request = new Request("http://localhost");

await logout(request, mockEnv);

expect(mockSessionStorage.getSession).toHaveBeenCalledWith(null);
});
});

describe("requireAuth", () => {
test("should return session when authenticated", async () => {
mockSession.get.mockReturnValue(true);
const request = new Request("http://localhost", {
headers: { Cookie: "session-cookie" },
});

const result = await requireAuth(request, mockEnv);

expect(createSessionStorage).toHaveBeenCalledWith("test-password");
expect(mockSessionStorage.getSession).toHaveBeenCalledWith("session-cookie");
expect(mockSession.get).toHaveBeenCalledWith("authenticated");
expect(result).toBe(mockSession);
});

test("should redirect when not authenticated", async () => {
mockSession.get.mockReturnValue(false);
const request = new Request("http://localhost");

await expect(requireAuth(request, mockEnv)).rejects.toEqual({
url: "/",
options: undefined,
});
});

test("should redirect when session has no authenticated value", async () => {
mockSession.get.mockReturnValue(undefined);
const request = new Request("http://localhost");

await expect(requireAuth(request, mockEnv)).rejects.toEqual({
url: "/",
options: undefined,
});
});
});

describe("getUser", () => {
test("should return user object when authenticated", async () => {
mockSession.get.mockReturnValue(true);
const request = new Request("http://localhost", {
headers: { Cookie: "session-cookie" },
});

const result = await getUser(request, mockEnv);

expect(createSessionStorage).toHaveBeenCalledWith("test-password");
expect(mockSessionStorage.getSession).toHaveBeenCalledWith("session-cookie");
expect(mockSession.get).toHaveBeenCalledWith("authenticated");
expect(result).toEqual({ authenticated: true });
});

test("should return null when not authenticated", async () => {
mockSession.get.mockReturnValue(false);
const request = new Request("http://localhost");

const result = await getUser(request, mockEnv);

expect(result).toBeNull();
});

test("should return null when session has no authenticated value", async () => {
mockSession.get.mockReturnValue(undefined);
const request = new Request("http://localhost");

const result = await getUser(request, mockEnv);

expect(result).toBeNull();
});
});
});
73 changes: 73 additions & 0 deletions packages/server/app/lib/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, test, expect, vi } from "vitest";
import { createSessionStorage } from "../session";
import { createCookieSessionStorage } from "react-router";

vi.mock("react-router", () => ({
createCookieSessionStorage: vi.fn(),
}));

describe("session", () => {
describe("createSessionStorage", () => {
test("should create session storage with correct configuration", () => {
const mockSessionStorage = { mock: "session-storage" };
vi.mocked(createCookieSessionStorage).mockReturnValue(mockSessionStorage as any);

Check warning on line 13 in packages/server/app/lib/__tests__/session.test.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type

const secret = "test-secret";
const result = createSessionStorage(secret);

expect(createCookieSessionStorage).toHaveBeenCalledWith({
cookie: {
name: "__counterscale_session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
sameSite: "lax",
secrets: [secret],
secure: true,
},
});
expect(result).toBe(mockSessionStorage);
});

test("should use provided secret in configuration", () => {
const secret = "my-custom-secret";
createSessionStorage(secret);

expect(createCookieSessionStorage).toHaveBeenCalledWith(
expect.objectContaining({
cookie: expect.objectContaining({
secrets: [secret],
}),
})
);
});

test("should configure cookie with security settings", () => {
createSessionStorage("test-secret");

expect(createCookieSessionStorage).toHaveBeenCalledWith(
expect.objectContaining({
cookie: expect.objectContaining({
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
}),
})
);
});

test("should set correct session name and expiration", () => {
createSessionStorage("test-secret");

expect(createCookieSessionStorage).toHaveBeenCalledWith(
expect.objectContaining({
cookie: expect.objectContaining({
name: "__counterscale_session",
maxAge: 2592000, // 30 days in seconds
}),
})
);
});
});
});
48 changes: 48 additions & 0 deletions packages/server/app/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { redirect } from "react-router";
import { createSessionStorage } from "./session";

export async function login(request: Request, password: string, env: Env) {
if (password !== env.CF_APP_PASSWORD) {
throw new Error("Invalid password");
}

const sessionStorage = createSessionStorage(env.CF_APP_PASSWORD);
const session = await sessionStorage.getSession(request.headers.get("Cookie"));

session.set("authenticated", true);

return redirect("/dashboard", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session),
},
});
}

export async function logout(request: Request, env: Env) {
const sessionStorage = createSessionStorage(env.CF_APP_PASSWORD);
const session = await sessionStorage.getSession(request.headers.get("Cookie"));

return redirect("/", {
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
},
});
}

export async function requireAuth(request: Request, env: Env) {
const sessionStorage = createSessionStorage(env.CF_APP_PASSWORD);
const session = await sessionStorage.getSession(request.headers.get("Cookie"));

if (!session.get("authenticated")) {
throw redirect("/");
}

return session;
}

export async function getUser(request: Request, env: Env) {
const sessionStorage = createSessionStorage(env.CF_APP_PASSWORD);
const session = await sessionStorage.getSession(request.headers.get("Cookie"));

return session.get("authenticated") ? { authenticated: true } : null;
}
15 changes: 15 additions & 0 deletions packages/server/app/lib/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createCookieSessionStorage } from "react-router";

export function createSessionStorage(secret: string) {
return createCookieSessionStorage({
cookie: {
name: "__counterscale_session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
sameSite: "lax",
secrets: [secret],
secure: true,
},
});
}
Loading
Loading