feat(admin): harden app access at the worker edge

This commit is contained in:
Arthur Belleville 2026-04-24 16:15:55 +02:00
parent 85d44af57e
commit 9aff7e1bed
No known key found for this signature in database
7 changed files with 409 additions and 13 deletions

View file

@ -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(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
const button = await screen.findByRole("button", { name: /lock admin app/i });
fireEvent.click(button);
expect(localStorage.getItem("xtablo-admin-session")).toBeNull();
});
});

View file

@ -4,7 +4,7 @@ import { AdminNavigation } from "./AdminNavigation";
import { ProductionBadge } from "./ProductionBadge";
export function AdminLayout() {
const { operatorEmail } = useAdminSession();
const { logout, operatorEmail } = useAdminSession();
return (
<div className="min-h-screen bg-background text-foreground">
@ -17,6 +17,15 @@ export function AdminLayout() {
<p className="mt-2 text-sm font-medium">{operatorEmail ?? "Unknown operator"}</p>
</div>
<AdminNavigation />
<form action="/__admin/logout" method="post">
<button
className="w-full rounded-2xl border border-border px-3 py-2 text-left text-sm font-medium text-foreground/75 hover:bg-black/5"
onClick={() => logout()}
type="submit"
>
Lock Admin App
</button>
</form>
</div>
</aside>

View file

@ -75,7 +75,7 @@ describe("AnalyticsStudioPage", () => {
render(<AnalyticsStudioPage />);
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 }));

View file

@ -26,6 +26,6 @@
"@xtablo/shared-types/*": ["../../packages/shared-types/src/*"]
}
},
"include": ["src"],
"include": ["src", "worker"],
"references": []
}

View file

@ -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("<html>app</html>", { 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");
});
});

View file

@ -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<Response>;
};
};
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 `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XTablo Admin Access</title>
<style>
:root {
color-scheme: light;
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
background: #f7f3ea;
color: #162033;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.15), transparent 32%),
linear-gradient(135deg, #f9f4ea 0%, #f1ede3 48%, #efe4d1 100%);
}
main {
width: min(92vw, 440px);
background: rgba(255, 252, 247, 0.92);
border: 1px solid rgba(22, 32, 51, 0.12);
border-radius: 28px;
padding: 32px;
box-shadow: 0 24px 80px rgba(22, 32, 51, 0.12);
}
.eyebrow {
font: 600 11px/1.2 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #6a7280;
}
h1 {
margin: 14px 0 0;
font-size: 2rem;
line-height: 1.05;
}
p {
margin: 14px 0 0;
font: 400 0.98rem/1.55 ui-sans-serif, system-ui, sans-serif;
color: #465062;
}
form {
margin-top: 24px;
}
label {
display: block;
font: 600 0.88rem/1.4 ui-sans-serif, system-ui, sans-serif;
}
input {
width: 100%;
margin-top: 10px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(22, 32, 51, 0.16);
background: white;
font: 400 0.95rem/1.4 ui-monospace, SFMono-Regular, monospace;
box-sizing: border-box;
}
button {
width: 100%;
margin-top: 16px;
border: 0;
border-radius: 18px;
padding: 14px 16px;
background: #172554;
color: white;
font: 600 0.95rem/1.2 ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
.error {
margin-top: 16px;
padding: 12px 14px;
border-radius: 16px;
background: #fef2f2;
color: #b91c1c;
font: 600 0.85rem/1.4 ui-sans-serif, system-ui, sans-serif;
}
.footnote {
margin-top: 18px;
font-size: 0.82rem;
}
</style>
</head>
<body>
<main>
<div class="eyebrow">Internal Only</div>
<h1>Internal Admin Access</h1>
<p>
This app is firewalled behind a dedicated app-access token before any admin session
can be established.
</p>
<form action="${ADMIN_ACCESS_PATH}" method="post">
<label for="accessToken">App Access Token</label>
<input id="accessToken" name="accessToken" type="password" autocomplete="off" required />
<button type="submit">Enter Admin App</button>
</form>
${error ? `<div class="error">${error}</div>` : ""}
<p class="footnote">A second privileged token is still required inside the admin shell.</p>
</main>
</body>
</html>`;
}
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);
},
};

View file

@ -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