feat(admin): harden app access at the worker edge
This commit is contained in:
parent
85d44af57e
commit
9aff7e1bed
7 changed files with 409 additions and 13 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,6 @@
|
|||
"@xtablo/shared-types/*": ["../../packages/shared-types/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"include": ["src", "worker"],
|
||||
"references": []
|
||||
}
|
||||
|
|
|
|||
88
apps/admin/worker/index.test.ts
Normal file
88
apps/admin/worker/index.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue