2026-04-24 14:15:55 +00:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 13:10:44 +00:00
|
|
|
export default {
|
2026-04-24 14:15:55 +00:00
|
|
|
async fetch(request: Request, env: WorkerEnv) {
|
2026-04-24 13:10:44 +00:00
|
|
|
const url = new URL(request.url);
|
2026-04-24 14:15:55 +00:00
|
|
|
|
|
|
|
|
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);
|
2026-04-24 13:10:44 +00:00
|
|
|
}
|
2026-04-24 14:15:55 +00:00
|
|
|
|
|
|
|
|
return env.ASSETS.fetch(request);
|
2026-04-24 13:10:44 +00:00
|
|
|
},
|
|
|
|
|
};
|