diff --git a/apps/admin/src/components/AdminLayout.test.tsx b/apps/admin/src/components/AdminLayout.test.tsx index b9c5998..92fcc5c 100644 --- a/apps/admin/src/components/AdminLayout.test.tsx +++ b/apps/admin/src/components/AdminLayout.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AppRoutes from "../routes"; @@ -46,5 +46,19 @@ describe("AdminLayout", () => { expect(screen.getByRole("link", { name: /data explorer/i })).toBeInTheDocument(); expect(screen.getByRole("link", { name: /analytics studio/i })).toBeInTheDocument(); expect(screen.getByRole("link", { name: /action center/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /lock admin app/i })).toBeInTheDocument(); + }); + + it("clears the stored admin session when locking the app", async () => { + render( + + + + ); + + const button = await screen.findByRole("button", { name: /lock admin app/i }); + fireEvent.click(button); + + expect(localStorage.getItem("xtablo-admin-session")).toBeNull(); }); }); diff --git a/apps/admin/src/components/AdminLayout.tsx b/apps/admin/src/components/AdminLayout.tsx index 4133974..257aa04 100644 --- a/apps/admin/src/components/AdminLayout.tsx +++ b/apps/admin/src/components/AdminLayout.tsx @@ -4,7 +4,7 @@ import { AdminNavigation } from "./AdminNavigation"; import { ProductionBadge } from "./ProductionBadge"; export function AdminLayout() { - const { operatorEmail } = useAdminSession(); + const { logout, operatorEmail } = useAdminSession(); return (
@@ -17,6 +17,15 @@ export function AdminLayout() {

{operatorEmail ?? "Unknown operator"}

+
+ +
diff --git a/apps/admin/src/pages/AnalyticsStudioPage.test.tsx b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx index e6b710c..d9dc2c4 100644 --- a/apps/admin/src/pages/AnalyticsStudioPage.test.tsx +++ b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx @@ -75,7 +75,7 @@ describe("AnalyticsStudioPage", () => { render(); expect(await screen.findByText(/analytics studio/i)).toBeInTheDocument(); - expect(await screen.findByText(/user growth/i)).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /user growth/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /plan mix/i })); diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json index 586e2a9..4a98594 100644 --- a/apps/admin/tsconfig.json +++ b/apps/admin/tsconfig.json @@ -26,6 +26,6 @@ "@xtablo/shared-types/*": ["../../packages/shared-types/src/*"] } }, - "include": ["src"], + "include": ["src", "worker"], "references": [] } diff --git a/apps/admin/worker/index.test.ts b/apps/admin/worker/index.test.ts new file mode 100644 index 0000000..87124e8 --- /dev/null +++ b/apps/admin/worker/index.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import worker, { + ADMIN_APP_SESSION_COOKIE, + buildAccessDeniedHtml, + createSignedAdminAppSession, +} from "./index"; + +const env = { + ADMIN_APP_ACCESS_TOKEN: "super-secret-admin-app-token", + ADMIN_APP_SESSION_SECRET: "worker-session-secret", + ASSETS: { + fetch: vi.fn(async () => new Response("app", { status: 200 })), + }, +}; + +describe("admin worker firewall", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("serves the admin access gate when no session cookie is present", async () => { + const response = await worker.fetch( + new Request("https://admin.internal.xtablo.com/", { + headers: { + accept: "text/html", + }, + }), + env + ); + + expect(response.status).toBe(401); + await expect(response.text()).resolves.toContain("Internal Admin Access"); + }); + + it("creates a signed app session cookie from a valid access token", async () => { + const request = new Request("https://admin.internal.xtablo.com/__admin/access", { + body: new URLSearchParams({ accessToken: env.ADMIN_APP_ACCESS_TOKEN }), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }); + + const response = await worker.fetch(request, env); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("https://admin.internal.xtablo.com/"); + expect(response.headers.get("set-cookie")).toContain(`${ADMIN_APP_SESSION_COOKIE}=`); + }); + + it("allows authenticated requests through to static assets", async () => { + const session = await createSignedAdminAppSession(env.ADMIN_APP_SESSION_SECRET); + const request = new Request("https://admin.internal.xtablo.com/", { + headers: { + cookie: `${ADMIN_APP_SESSION_COOKIE}=${session}`, + }, + }); + + const response = await worker.fetch(request, env); + + expect(response.status).toBe(200); + expect(env.ASSETS.fetch).toHaveBeenCalledOnce(); + }); + + it("rejects invalid access tokens", async () => { + const request = new Request("https://admin.internal.xtablo.com/__admin/access", { + body: new URLSearchParams({ accessToken: "wrong-token" }), + headers: { + accept: "text/html", + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }); + + const response = await worker.fetch(request, env); + + expect(response.status).toBe(401); + await expect(response.text()).resolves.toContain("Invalid app access token"); + }); +}); + +describe("buildAccessDeniedHtml", () => { + it("renders the access error when provided", () => { + expect(buildAccessDeniedHtml("Bad token")).toContain("Bad token"); + }); +}); diff --git a/apps/admin/worker/index.ts b/apps/admin/worker/index.ts index bee9dbb..32af99f 100644 --- a/apps/admin/worker/index.ts +++ b/apps/admin/worker/index.ts @@ -1,9 +1,280 @@ +export const ADMIN_APP_SESSION_COOKIE = "xtablo-admin-app-session"; +const ADMIN_ACCESS_PATH = "/__admin/access"; +const ADMIN_LOGOUT_PATH = "/__admin/logout"; +const SESSION_TTL_SECONDS = 60 * 60 * 12; + +type WorkerEnv = { + ADMIN_APP_ACCESS_TOKEN: string; + ADMIN_APP_SESSION_SECRET: string; + ASSETS: { + fetch: (request: Request) => Promise; + }; +}; + +function base64UrlEncode(bytes: Uint8Array) { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function base64UrlDecode(value: string) { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)); + const binary = atob(`${normalized}${padding}`); + + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +async function importSigningKey(secret: string) { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); +} + +async function signValue(value: string, secret: string) { + const key = await importSigningKey(secret); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); + + return base64UrlEncode(new Uint8Array(signature)); +} + +async function verifyValueSignature(value: string, signature: string, secret: string) { + const key = await importSigningKey(secret); + + return crypto.subtle.verify( + "HMAC", + key, + base64UrlDecode(signature), + new TextEncoder().encode(value) + ); +} + +function parseCookie(cookieHeader: string | null, cookieName: string) { + if (!cookieHeader) { + return null; + } + + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`)); + return match?.[1] ?? null; +} + +export async function createSignedAdminAppSession(secret: string, now = Date.now()) { + const expiresAt = Math.floor(now / 1000) + SESSION_TTL_SECONDS; + const payload = `${expiresAt}`; + const signature = await signValue(payload, secret); + + return `${payload}.${signature}`; +} + +export async function hasValidAdminAppSession(request: Request, secret: string, now = Date.now()) { + const sessionCookie = parseCookie(request.headers.get("cookie"), ADMIN_APP_SESSION_COOKIE); + if (!sessionCookie) { + return false; + } + + const [expiresAtValue, signature] = sessionCookie.split("."); + if (!expiresAtValue || !signature) { + return false; + } + + const expiresAt = Number.parseInt(expiresAtValue, 10); + if (Number.isNaN(expiresAt) || expiresAt <= Math.floor(now / 1000)) { + return false; + } + + return verifyValueSignature(expiresAtValue, signature, secret); +} + +function isHtmlRequest(request: Request) { + const accept = request.headers.get("accept") ?? ""; + return accept.includes("text/html") || accept.includes("*/*"); +} + +function buildSessionCookie(session: string) { + return `${ADMIN_APP_SESSION_COOKIE}=${session}; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=${SESSION_TTL_SECONDS}`; +} + +function buildExpiredSessionCookie() { + return `${ADMIN_APP_SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=0`; +} + +export function buildAccessDeniedHtml(error?: string) { + return ` + + + + + XTablo Admin Access + + + +
+
Internal Only
+

Internal Admin Access

+

+ This app is firewalled behind a dedicated app-access token before any admin session + can be established. +

+
+ + + +
+ ${error ? `
${error}
` : ""} +

A second privileged token is still required inside the admin shell.

+
+ +`; +} + +function respondUnauthorized(request: Request, error?: string) { + if (isHtmlRequest(request)) { + return new Response(buildAccessDeniedHtml(error), { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + status: 401, + }); + } + + return new Response("Unauthorized", { status: 401 }); +} + +async function handleAccessRequest(request: Request, env: WorkerEnv) { + const formData = await request.formData().catch(() => null); + const accessToken = formData?.get("accessToken"); + + if (accessToken !== env.ADMIN_APP_ACCESS_TOKEN) { + return respondUnauthorized(request, "Invalid app access token"); + } + + const session = await createSignedAdminAppSession(env.ADMIN_APP_SESSION_SECRET); + const location = new URL("/", request.url).toString(); + + return new Response(null, { + headers: { + Location: location, + "Set-Cookie": buildSessionCookie(session), + "Cache-Control": "no-store", + }, + status: 302, + }); +} + +async function handleLogoutRequest(request: Request) { + return new Response(null, { + headers: { + Location: new URL("/", request.url).toString(), + "Set-Cookie": buildExpiredSessionCookie(), + "Cache-Control": "no-store", + }, + status: 302, + }); +} + export default { - fetch(request: Request) { + async fetch(request: Request, env: WorkerEnv) { const url = new URL(request.url); - if (url.pathname.startsWith("/api/")) { - return Response.json({ name: "XTablo Admin Worker" }); + + if (request.method === "POST" && url.pathname === ADMIN_ACCESS_PATH) { + return handleAccessRequest(request, env); } - return new Response(null, { status: 404 }); + + if (request.method === "POST" && url.pathname === ADMIN_LOGOUT_PATH) { + return handleLogoutRequest(request); + } + + const hasSession = await hasValidAdminAppSession(request, env.ADMIN_APP_SESSION_SECRET); + if (!hasSession) { + return respondUnauthorized(request); + } + + return env.ASSETS.fetch(request); }, }; diff --git a/docs/ADMIN_APP_ACCESS_SETUP.md b/docs/ADMIN_APP_ACCESS_SETUP.md index 0e27441..8734cca 100644 --- a/docs/ADMIN_APP_ACCESS_SETUP.md +++ b/docs/ADMIN_APP_ACCESS_SETUP.md @@ -2,6 +2,16 @@ The admin app is designed to be internal-only and requires a separate privileged token flow. +## Required admin worker configuration + +Set these values for `apps/admin`: + +- `ADMIN_APP_ACCESS_TOKEN` +- `ADMIN_APP_SESSION_SECRET` + +`ADMIN_APP_ACCESS_TOKEN` is the first-layer token required before the admin SPA will be served. +`ADMIN_APP_SESSION_SECRET` signs the worker-issued app session cookie after that token is accepted. + ## Required API configuration Set these values for `apps/api`: @@ -17,10 +27,13 @@ Set these values for `apps/api`: ## Access model 1. The operator reaches the private `apps/admin` deployment from the internal network boundary. -2. The operator pastes a privileged access token into the admin gate. -3. `POST /admin/auth/exchange` validates that token and returns a short-lived `admin_session`. -4. The admin app stores that session locally and attaches it as a bearer token for admin routes. -5. All privileged data and mutations go through `/admin/*` API routes guarded by admin middleware. +2. The admin worker presents a dedicated app-access gate before any SPA asset is served. +3. The operator submits the app access token, and the worker issues a signed session cookie. +4. Only then does the browser load the React admin shell. +5. Inside the shell, the operator pastes a separate privileged admin API token. +6. `POST /admin/auth/exchange` validates that token and returns a short-lived `admin_session`. +7. The admin app stores that session locally and attaches it as a bearer token for admin routes. +8. All privileged data and mutations go through `/admin/*` API routes guarded by admin middleware. Normal product auth is not sufficient for admin access. @@ -42,7 +55,8 @@ All write paths emit admin audit log entries. - Run the API and local Supabase stack. - Start the admin app with `pnpm dev:admin`. -- Use a valid privileged access token compatible with `ADMIN_TOKEN_SIGNING_SECRET` and `ADMIN_TOKEN_AUDIENCE`. +- Configure worker env for `ADMIN_APP_ACCESS_TOKEN` and `ADMIN_APP_SESSION_SECRET`. +- Use the app-access token at the worker gate, then use a valid privileged API token compatible with `ADMIN_TOKEN_SIGNING_SECRET` and `ADMIN_TOKEN_AUDIENCE`. ## Initial action coverage