Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d417d672c | |||
| 9d6df8a8df |
+11
-3
@@ -1,14 +1,22 @@
|
|||||||
import type { Context, Next } from "hono";
|
import type { Context, Next } from "hono";
|
||||||
|
import { getCookie } from "hono/cookie";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
|
|
||||||
export const authMiddleware = async (c: Context, next: Next) => {
|
export const authMiddleware = async (c: Context, next: Next) => {
|
||||||
const authHeader = c.req.header("Authorization");
|
const authHeader = c.req.header("Authorization");
|
||||||
|
const cookieToken = getCookie(c, "toknd_api_key");
|
||||||
|
|
||||||
if (!authHeader?.startsWith("Bearer ")) {
|
let token: string | undefined;
|
||||||
return c.json({ error: "Missing or invalid authorization header" }, 401);
|
|
||||||
|
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) {
|
if (token !== config.API_KEY) {
|
||||||
return c.json({ error: "Invalid API key" }, 403);
|
return c.json({ error: "Invalid API key" }, 403);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/** @jsxImportSource hono/jsx */
|
/** @jsxImportSource hono/jsx */
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||||
import { html } from "hono/html";
|
import { html } from "hono/html";
|
||||||
import type { Child } from "hono/jsx";
|
import type { Child } from "hono/jsx";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
const dashboardRoutes = new Hono({ strict: false });
|
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`<!DOCTYPE html>`}
|
{html`<!DOCTYPE html>`}
|
||||||
<html lang="en" data-theme="abyss">
|
<html lang="en" data-theme="abyss">
|
||||||
@@ -37,7 +39,7 @@ export const Layout = (props: { title: string; children: Child }) => (
|
|||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
|
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
|
||||||
x-data="dashboard"
|
x-data={`dashboard({ initialIsUnlocked: ${props.isUnlocked || false} })`}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
<script src="/app/dashboard.js"></script>
|
<script src="/app/dashboard.js"></script>
|
||||||
@@ -47,8 +49,8 @@ export const Layout = (props: { title: string; children: Child }) => (
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Dashboard = () => (
|
export const Dashboard = (props: { isUnlocked: boolean }) => (
|
||||||
<Layout title="toknd — Auth Broker Dashboard">
|
<Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
|
||||||
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -93,6 +95,14 @@ export const Dashboard = () => (
|
|||||||
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
|
||||||
<span class="ml-1 hidden md:inline" x-text="loading ? 'Unlocking...' : 'Unlock'"></span>
|
<span class="ml-1 hidden md:inline" x-text="loading ? 'Unlocking...' : 'Unlock'"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
x-show="isUnlocked"
|
||||||
|
x-on:click="logout()"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm join-item text-error hover:bg-error/10"
|
||||||
|
>
|
||||||
|
<i class="ph-bold ph-power text-lg"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,7 +429,7 @@ export const Dashboard = () => (
|
|||||||
type="button"
|
type="button"
|
||||||
x-on:click="forceRefresh(provider.name)"
|
x-on:click="forceRefresh(provider.name)"
|
||||||
class="btn btn-base w-full"
|
class="btn btn-base w-full"
|
||||||
x-bind:disabled="loading"
|
x-bind:disabled="loading || !provider.status.accessToken"
|
||||||
>
|
>
|
||||||
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
|
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
|
||||||
<span class="text-xs uppercase font-bold tracking-widest">
|
<span class="text-xs uppercase font-bold tracking-widest">
|
||||||
@@ -484,7 +494,28 @@ export const Success = (props: { provider: string }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
dashboardRoutes.get("/", async (c) => {
|
dashboardRoutes.get("/", async (c) => {
|
||||||
return c.html(<Dashboard />);
|
const isUnlocked = getCookie(c, "toknd_api_key") === config.API_KEY;
|
||||||
|
return c.html(<Dashboard isUnlocked={isUnlocked} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
dashboardRoutes.get("/success", async (c) => {
|
||||||
|
|||||||
+64
-28
@@ -10,9 +10,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.Alpine.data("dashboard", () => ({
|
window.Alpine.data("dashboard", ({ initialIsUnlocked }) => ({
|
||||||
apiKey: localStorage.getItem("toknd_api_key") || "",
|
apiKey: "",
|
||||||
isUnlocked: false,
|
isUnlocked: initialIsUnlocked,
|
||||||
loading: false,
|
loading: false,
|
||||||
providers: [],
|
providers: [],
|
||||||
form: {
|
form: {
|
||||||
@@ -30,8 +30,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (this.apiKey) {
|
if (this.isUnlocked) {
|
||||||
this.unlock();
|
this.fetchProviders();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -39,14 +39,34 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!this.apiKey) return;
|
if (!this.apiKey) return;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("toknd_api_key", this.apiKey);
|
const res = await fetch("/app/unlock", {
|
||||||
await this.fetchProviders();
|
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;
|
this.isUnlocked = true;
|
||||||
|
await this.fetchProviders();
|
||||||
|
this.apiKey = ""; // Clear after success
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showNotification(`Failed to unlock: ${err.message}. Check your API Key`, "error");
|
this.showNotification(err.message, "error");
|
||||||
localStorage.removeItem("toknd_api_key");
|
|
||||||
this.isUnlocked = false;
|
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 {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -56,41 +76,53 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const [configRes, statusRes] = await Promise.all([
|
const [configRes, statusRes] = await Promise.all([
|
||||||
fetch("/api/config", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
|
fetch("/api/config"),
|
||||||
fetch("/api/status", { headers: { Authorization: `Bearer ${this.apiKey}` } }),
|
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();
|
if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
|
||||||
const status = await statusRes.json();
|
|
||||||
|
|
||||||
this.providers = Object.entries(config).map(([name, cfg]) => ({
|
const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
|
||||||
name,
|
this.providers = this.mapProviders(config, status);
|
||||||
config: cfg,
|
|
||||||
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showNotification(err.message, "error");
|
this.showNotification(err.message, "error");
|
||||||
throw err;
|
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveConfig(event) {
|
mapProviders(config, status) {
|
||||||
if (event) event.preventDefault();
|
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;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/config/${this.form.providerName}`, {
|
const res = await fetch(`/api/config/${this.form.providerName}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(this.form),
|
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");
|
if (!res.ok) throw new Error("Failed to save");
|
||||||
|
|
||||||
this.showNotification("Saved successfully");
|
this.showNotification("Saved successfully");
|
||||||
@@ -116,9 +148,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/refresh/${name}`, {
|
const res = await fetch(`/api/refresh/${name}`, {
|
||||||
method: "POST",
|
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");
|
if (!res.ok) throw new Error("Refresh failed");
|
||||||
|
|
||||||
this.showNotification(`Refreshed ${name}`);
|
this.showNotification(`Refreshed ${name}`);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("API Integration", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
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 () => {
|
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");
|
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 () => {
|
it("should return 404 for unknown provider token", async () => {
|
||||||
const res = await app.request("/api/token/unconfigured-provider", {
|
const res = await app.request("/api/token/unconfigured-provider", {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -34,4 +34,25 @@ describe("Dashboard & Common Integration", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toBe("Internal Server Error");
|
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=;");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user