From 9d6df8a8df588759cab628a3922c3a417e397e57 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Tue, 12 May 2026 04:20:03 +0530 Subject: [PATCH 1/2] feat: implement secure session management using HttpOnly cookies for API key authentication --- src/middleware/auth.ts | 14 ++++- src/routes/dashboard.tsx | 41 +++++++++++-- src/views/dashboard.js | 92 ++++++++++++++++++++--------- tests/integration/api.test.ts | 21 ++++++- tests/integration/dashboard.test.ts | 21 +++++++ 5 files changed, 152 insertions(+), 37 deletions(-) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 90d94ca..55496d6 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,14 +1,22 @@ import type { Context, Next } from "hono"; +import { getCookie } from "hono/cookie"; import { config } from "../config"; export const authMiddleware = async (c: Context, next: Next) => { const authHeader = c.req.header("Authorization"); + const cookieToken = getCookie(c, "toknd_api_key"); - if (!authHeader?.startsWith("Bearer ")) { - return c.json({ error: "Missing or invalid authorization header" }, 401); + let token: string | undefined; + + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.split(" ")[1]; + } else if (cookieToken) { + token = cookieToken; } - const token = authHeader.split(" ")[1]; + if (!token) { + return c.json({ error: "Missing or invalid authorization" }, 401); + } if (token !== config.API_KEY) { return c.json({ error: "Invalid API key" }, 403); diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 8bf833a..9d6edd2 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -1,11 +1,13 @@ /** @jsxImportSource hono/jsx */ import { Hono } from "hono"; +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { html } from "hono/html"; import type { Child } from "hono/jsx"; +import { config } from "../config"; const dashboardRoutes = new Hono({ strict: false }); -export const Layout = (props: { title: string; children: Child }) => ( +export const Layout = (props: { title: string; children: Child; isUnlocked?: boolean }) => ( <> {html``} @@ -37,7 +39,7 @@ export const Layout = (props: { title: string; children: Child }) => ( {props.children} @@ -47,8 +49,8 @@ export const Layout = (props: { title: string; children: Child }) => ( ); -export const Dashboard = () => ( - +export const Dashboard = (props: { isUnlocked: boolean }) => ( + @@ -484,7 +494,28 @@ export const Success = (props: { provider: string }) => ( ); dashboardRoutes.get("/", async (c) => { - return c.html(); + const isUnlocked = getCookie(c, "toknd_api_key") === config.API_KEY; + return c.html(); +}); + +dashboardRoutes.post("/unlock", async (c) => { + const { apiKey } = await c.req.json(); + if (apiKey !== config.API_KEY) { + return c.json({ error: "Invalid API Key" }, 401); + } + setCookie(c, "toknd_api_key", apiKey, { + httpOnly: true, + secure: true, + sameSite: "Strict", + path: "/", + maxAge: 60 * 60 * 24 * 7, // 1 week + }); + return c.json({ success: true }); +}); + +dashboardRoutes.post("/logout", async (c) => { + deleteCookie(c, "toknd_api_key", { path: "/" }); + return c.json({ success: true }); }); dashboardRoutes.get("/success", async (c) => { diff --git a/src/views/dashboard.js b/src/views/dashboard.js index 17bd765..7a3d7fe 100644 --- a/src/views/dashboard.js +++ b/src/views/dashboard.js @@ -10,9 +10,9 @@ document.addEventListener("alpine:init", () => { return date.toLocaleDateString(); }; - window.Alpine.data("dashboard", () => ({ - apiKey: localStorage.getItem("toknd_api_key") || "", - isUnlocked: false, + window.Alpine.data("dashboard", ({ initialIsUnlocked }) => ({ + apiKey: "", + isUnlocked: initialIsUnlocked, loading: false, providers: [], form: { @@ -30,8 +30,8 @@ document.addEventListener("alpine:init", () => { }, init() { - if (this.apiKey) { - this.unlock(); + if (this.isUnlocked) { + this.fetchProviders(); } }, @@ -39,14 +39,34 @@ document.addEventListener("alpine:init", () => { if (!this.apiKey) return; this.loading = true; try { - localStorage.setItem("toknd_api_key", this.apiKey); - await this.fetchProviders(); + const res = await fetch("/app/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: this.apiKey }), + }); + + if (!res.ok) throw new Error("Invalid API Key"); + this.isUnlocked = true; + await this.fetchProviders(); + this.apiKey = ""; // Clear after success } catch (err) { - this.showNotification(`Failed to unlock: ${err.message}. Check your API Key`, "error"); - localStorage.removeItem("toknd_api_key"); + this.showNotification(err.message, "error"); this.isUnlocked = false; - this.apiKey = ""; + } finally { + this.loading = false; + } + }, + + async logout() { + this.loading = true; + try { + await fetch("/app/logout", { method: "POST" }); + this.isUnlocked = false; + this.providers = []; + this.showNotification("Logged out successfully"); + } catch (err) { + this.showNotification(`Logout failed: ${err.message}`, "error"); } finally { this.loading = false; } @@ -56,41 +76,53 @@ document.addEventListener("alpine:init", () => { this.loading = true; try { const [configRes, statusRes] = await Promise.all([ - fetch("/api/config", { headers: { Authorization: `Bearer ${this.apiKey}` } }), - fetch("/api/status", { headers: { Authorization: `Bearer ${this.apiKey}` } }), + fetch("/api/config"), + fetch("/api/status"), ]); - if (!configRes.ok || !statusRes.ok) throw new Error("Unauthorized"); + if (configRes.status === 401 || statusRes.status === 401) { + return this.handleSessionExpired(); + } - const config = await configRes.json(); - const status = await statusRes.json(); + if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data"); - this.providers = Object.entries(config).map(([name, cfg]) => ({ - name, - config: cfg, - status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null }, - })); + const [config, status] = await Promise.all([configRes.json(), statusRes.json()]); + this.providers = this.mapProviders(config, status); } catch (err) { this.showNotification(err.message, "error"); - throw err; } finally { this.loading = false; } }, - async saveConfig(event) { - if (event) event.preventDefault(); + mapProviders(config, status) { + return Object.entries(config).map(([name, cfg]) => ({ + name, + config: cfg, + status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null }, + })); + }, + + handleSessionExpired() { + this.isUnlocked = false; + this.providers = []; + this.showNotification("Session expired", "error"); + }, + + async saveConfig() { this.loading = true; try { const res = await fetch(`/api/config/${this.form.providerName}`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(this.form), }); + if (res.status === 401) { + this.isUnlocked = false; + throw new Error("Session expired"); + } + if (!res.ok) throw new Error("Failed to save"); this.showNotification("Saved successfully"); @@ -116,9 +148,13 @@ document.addEventListener("alpine:init", () => { try { const res = await fetch(`/api/refresh/${name}`, { method: "POST", - headers: { Authorization: `Bearer ${this.apiKey}` }, }); + if (res.status === 401) { + this.isUnlocked = false; + throw new Error("Session expired"); + } + if (!res.ok) throw new Error("Refresh failed"); this.showNotification(`Refreshed ${name}`); diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index da11da8..3915b7e 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -24,7 +24,7 @@ describe("API Integration", () => { expect(res.status).toBe(401); const body = await res.json(); - expect(body).toEqual({ error: "Missing or invalid authorization header" }); + expect(body).toEqual({ error: "Missing or invalid authorization" }); }); it("should return 200 for health check (no auth needed)", async () => { @@ -67,6 +67,25 @@ describe("API Integration", () => { expect(body.trakt.accessToken).toBe("current-access-token"); }); + it("should return 200 for status with valid Cookie", async () => { + redis.keys.mockReturnValue(Promise.resolve(["config:trakt"])); + redis.get.mockImplementation((key) => { + if (key.includes("config")) return Promise.resolve(mockTraktConfig); + if (key.includes("access_token")) return Promise.resolve("current-access-token"); + return Promise.resolve(null); + }); + + const res = await app.request("/api/status", { + headers: { + Cookie: "toknd_api_key=test-api-key", + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.trakt).toBeDefined(); + }); + it("should return 404 for unknown provider token", async () => { const res = await app.request("/api/token/unconfigured-provider", { headers: { diff --git a/tests/integration/dashboard.test.ts b/tests/integration/dashboard.test.ts index 507f952..1af0ed9 100644 --- a/tests/integration/dashboard.test.ts +++ b/tests/integration/dashboard.test.ts @@ -34,4 +34,25 @@ describe("Dashboard & Common Integration", () => { const body = await res.json(); expect(body.error).toBe("Internal Server Error"); }); + + it("should set a cookie on successful unlock", async () => { + const res = await app.request("/app/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: "test-api-key" }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=test-api-key"); + expect(res.headers.get("Set-Cookie")).toContain("HttpOnly"); + }); + + it("should clear the cookie on logout", async () => { + const res = await app.request("/app/logout", { + method: "POST", + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=;"); + }); }); -- 2.52.0 From 1d417d672cc5cb2e15ed73cb3cf52a8c1f5e741f Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Tue, 12 May 2026 04:25:07 +0530 Subject: [PATCH 2/2] fix: disable refresh button when provider access token is missing --- src/routes/dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 9d6edd2..aa5bca3 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -429,7 +429,7 @@ export const Dashboard = (props: { isUnlocked: boolean }) => ( type="button" x-on:click="forceRefresh(provider.name)" class="btn btn-base w-full" - x-bind:disabled="loading" + x-bind:disabled="loading || !provider.status.accessToken" > -- 2.52.0