xtablo-source/apps/admin/worker/index.ts
2026-04-24 16:15:55 +02:00

280 lines
8.1 KiB
TypeScript

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 {
async fetch(request: Request, env: WorkerEnv) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === ADMIN_ACCESS_PATH) {
return handleAccessRequest(request, env);
}
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);
},
};