325 lines
8.7 KiB
TypeScript
325 lines
8.7 KiB
TypeScript
import { Hono } from "jsr:@hono/hono@4";
|
|
import { serveStatic } from "jsr:@hono/hono@4/deno";
|
|
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
|
import { sql } from "../server/db.ts";
|
|
import { valkey } from "../server/valkey.ts";
|
|
import {
|
|
extractAllSessionIds,
|
|
getAuthenticatedUser,
|
|
getCookieDomain,
|
|
isSafeRedirectUrl,
|
|
} from "../server/auth-session.ts";
|
|
import { auditWrapper } from "../server/audit.ts";
|
|
import { requireUiAuth } from "./auth_checks.ts";
|
|
import {
|
|
getAaguidAllowlist,
|
|
getAdminApps,
|
|
getAdminAuditLogs,
|
|
getAdminInvites,
|
|
getAdminRoles,
|
|
getAdminUserDetails,
|
|
getAdminUsers,
|
|
getAllRoles,
|
|
getDashboardApps,
|
|
getSessionApps,
|
|
getUserEventPasses,
|
|
getUserGrants,
|
|
getUserPasskeys,
|
|
getUserSessions,
|
|
} from "./db_queries.ts";
|
|
|
|
import { LoginPage } from "./components/LoginPage.tsx";
|
|
import { RegisterPage } from "./components/RegisterPage.tsx";
|
|
import { SessionsPage } from "./components/SessionsPage.tsx";
|
|
import { PasskeysPage } from "./components/PasskeysPage.tsx";
|
|
import { AuditLogPage } from "./components/AuditLogPage.tsx";
|
|
import { AdminUsersPage } from "./components/AdminUsersPage.tsx";
|
|
import { AdminUserDetailsPage } from "./components/AdminUserDetailsPage.tsx";
|
|
import { AAGUIDPage } from "./components/AAGUIDPage.tsx";
|
|
import { RecoveryPage } from "./components/RecoveryPage.tsx";
|
|
|
|
import { AdminAppsPage } from "./components/AdminAppsPage.tsx";
|
|
import { AdminRolesPage } from "./components/AdminRolesPage.tsx";
|
|
import { AdminInvitesPage } from "./components/AdminInvitesPage.tsx";
|
|
import { UnregisteredAppPage } from "./components/UnregisteredAppPage.tsx";
|
|
import { AppLaunchpadPage } from "./components/AppLaunchpadPage.tsx";
|
|
|
|
const uiApp: Hono = new Hono();
|
|
|
|
// Explicit Side Effect: Route rendering
|
|
uiApp.get("/", (c) => {
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
});
|
|
|
|
uiApp.get("/logout", async (c) => {
|
|
const sessionIds = extractAllSessionIds(c);
|
|
const rawRedirect = c.req.query("redirect");
|
|
let safeRedirect = null;
|
|
const userIp = c.req.header("x-forwarded-for") || "127.0.0.1";
|
|
let userId = null;
|
|
|
|
if (sessionIds.length > 0) {
|
|
try {
|
|
// Get user ID for auditing before we delete the session
|
|
const authUser = await getAuthenticatedUser(c);
|
|
if (authUser) {
|
|
userId = authUser.userId;
|
|
}
|
|
|
|
// Purge all candidate cookies sent by the browser to ensure ghosts are eradicated
|
|
for (const sId of sessionIds) {
|
|
try {
|
|
await valkey.del(sId);
|
|
await sql`DELETE FROM sessions WHERE id = ${sId}`;
|
|
} catch (_e) {}
|
|
}
|
|
} catch (_e) {
|
|
// Best effort cleanup
|
|
}
|
|
}
|
|
|
|
if (rawRedirect) {
|
|
if (isSafeRedirectUrl(rawRedirect)) {
|
|
safeRedirect = rawRedirect;
|
|
} else {
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"open_redirect_intercepted",
|
|
"logout_redirect",
|
|
{ raw_url: rawRedirect },
|
|
userIp,
|
|
);
|
|
}
|
|
}
|
|
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"logout_success",
|
|
"session",
|
|
null,
|
|
userIp,
|
|
);
|
|
|
|
const rpID = Deno.env.get("RP_ID") || "";
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
|
|
if (cookieDomain) {
|
|
deleteCookie(c, "session_id", {
|
|
domain: cookieDomain,
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
});
|
|
}
|
|
deleteCookie(c, "session_id", {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
});
|
|
|
|
if (safeRedirect) {
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect(safeRedirect);
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
});
|
|
|
|
uiApp.get("/login", (c) => {
|
|
return c.html(LoginPage());
|
|
});
|
|
|
|
uiApp.get("/errors/unregistered", (c) => {
|
|
const host = c.req.query("host") || "unknown";
|
|
return c.html(UnregisteredAppPage({ host }));
|
|
});
|
|
|
|
uiApp.get("/recovery", (c) => {
|
|
return c.html(RecoveryPage());
|
|
});
|
|
|
|
uiApp.get("/register", (c) => {
|
|
const initialCode = c.req.query("code") || "";
|
|
return c.html(RegisterPage({ initialCode }));
|
|
});
|
|
|
|
uiApp.get("/dashboard", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { auth, isAdmin } = authRes;
|
|
|
|
const apps = await getDashboardApps(auth.userId, isAdmin);
|
|
|
|
return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin }));
|
|
});
|
|
|
|
uiApp.get("/dashboard/sessions", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { auth, isAdmin } = authRes;
|
|
|
|
const apps = await getSessionApps();
|
|
const sessions = await getUserSessions(auth.userId);
|
|
const eventPasses = await getUserEventPasses(auth.userId);
|
|
|
|
return c.html(
|
|
SessionsPage({
|
|
sessions,
|
|
currentSessionId: auth.sessionId,
|
|
apps: apps as any,
|
|
isAdmin,
|
|
eventPasses: eventPasses as any,
|
|
}),
|
|
);
|
|
});
|
|
|
|
uiApp.get("/dashboard/passkeys", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { auth, isAdmin } = authRes;
|
|
|
|
const passkeys = await getUserPasskeys(auth.userId);
|
|
|
|
return c.html(PasskeysPage({ passkeys, isAdmin }));
|
|
});
|
|
|
|
// Admin Routes
|
|
uiApp.get("/admin", (c) => {
|
|
return c.redirect("/admin/users");
|
|
});
|
|
|
|
uiApp.get("/admin/users", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { isAdmin, isSessionAdminRole } = authRes;
|
|
|
|
if (!isAdmin || !isSessionAdminRole) {
|
|
return c.redirect("/dashboard", 302);
|
|
}
|
|
|
|
const users = await getAdminUsers();
|
|
return c.html(AdminUsersPage({ users }));
|
|
});
|
|
|
|
uiApp.get("/admin/apps", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { isAdmin, isSessionAdminRole } = authRes;
|
|
|
|
if (!isAdmin || !isSessionAdminRole) {
|
|
return c.redirect("/dashboard", 302);
|
|
}
|
|
|
|
const apps = await getAdminApps();
|
|
return c.html(AdminAppsPage({ apps }));
|
|
});
|
|
|
|
uiApp.get("/admin/roles", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { isAdmin, isSessionAdminRole } = authRes;
|
|
|
|
if (!isAdmin || !isSessionAdminRole) {
|
|
return c.redirect("/dashboard", 302);
|
|
}
|
|
|
|
const roles = await getAdminRoles();
|
|
const apps = await getSessionApps();
|
|
return c.html(AdminRolesPage({ roles, apps }));
|
|
});
|
|
|
|
uiApp.get("/admin/invites", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { isAdmin, isSessionAdminRole } = authRes;
|
|
|
|
if (!isAdmin || !isSessionAdminRole) {
|
|
return c.redirect("/dashboard", 302);
|
|
}
|
|
|
|
const invites = await getAdminInvites();
|
|
const apps = await getSessionApps();
|
|
const allRoles = await getAllRoles();
|
|
|
|
return c.html(AdminInvitesPage({ invites, apps, allRoles }));
|
|
});
|
|
|
|
uiApp.get("/admin/aaguid", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { isAdmin } = authRes;
|
|
|
|
if (!isAdmin) {
|
|
return c.redirect("/dashboard");
|
|
}
|
|
|
|
const allowlist = await getAaguidAllowlist();
|
|
return c.html(AAGUIDPage({ allowlist }));
|
|
});
|
|
|
|
uiApp.get("/admin/users/:id", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { isAdmin, isSessionAdminRole } = authRes;
|
|
|
|
if (!isAdmin || !isSessionAdminRole) {
|
|
return c.redirect("/admin/users", 302);
|
|
}
|
|
|
|
const targetUserId = c.req.param("id");
|
|
const user = await getAdminUserDetails(targetUserId);
|
|
|
|
if (!user) {
|
|
return c.redirect("/admin/users");
|
|
}
|
|
|
|
const sessions = await getUserSessions(targetUserId);
|
|
const passkeys = await getUserPasskeys(targetUserId);
|
|
const grants = await getUserGrants(targetUserId);
|
|
const allApps = await getSessionApps();
|
|
const allRoles = await getAllRoles();
|
|
|
|
return c.html(
|
|
AdminUserDetailsPage({
|
|
user,
|
|
sessions,
|
|
passkeys,
|
|
grants,
|
|
allApps,
|
|
allRoles,
|
|
}),
|
|
);
|
|
});
|
|
|
|
uiApp.get("/admin/audit-logs", async (c) => {
|
|
const authRes = await requireUiAuth(c);
|
|
if (authRes instanceof Response) return authRes;
|
|
const { isAdmin, isSessionAdminRole } = authRes;
|
|
|
|
if (!isAdmin || !isSessionAdminRole) {
|
|
return c.redirect("/dashboard", 302);
|
|
}
|
|
|
|
const logs = await getAdminAuditLogs();
|
|
return c.html(AuditLogPage({ logs }));
|
|
});
|
|
|
|
// Explicit Side Effect: Serving static assets (client-side JS) with no-cache headers to prevent stale browser caches
|
|
uiApp.use("/public/*", async (c, next) => {
|
|
await next();
|
|
c.header("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
});
|
|
|
|
uiApp.get(
|
|
"/public/*",
|
|
serveStatic({
|
|
root: "./ui",
|
|
rewriteRequestPath: (path) => path.replace(/^\/public/, "/public"),
|
|
}),
|
|
);
|
|
|
|
export { uiApp };
|