515 lines
14 KiB
TypeScript
515 lines
14 KiB
TypeScript
import { Hono } from "jsr:@hono/hono@4";
|
|
import { serveStatic } from "jsr:@hono/hono@4/deno";
|
|
import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie";
|
|
import { sql } from "../server/db.ts";
|
|
import { valkey } from "../server/valkey.ts";
|
|
import {
|
|
extractAllSessionIds,
|
|
getAuthenticatedUser,
|
|
getCookieDomain,
|
|
isGlobalAdmin,
|
|
isSafeRedirectUrl,
|
|
} from "../server/auth-session.ts";
|
|
import { auditWrapper } from "../server/audit.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 auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
|
|
let apps = [];
|
|
if (isAdmin) {
|
|
apps = await sql`
|
|
SELECT id, name, description, domain, 'Admin' as role
|
|
FROM apps
|
|
WHERE domain IS NOT NULL
|
|
ORDER BY name ASC
|
|
` as any[];
|
|
} else {
|
|
apps = await sql`
|
|
SELECT a.id, a.name, a.description, a.domain, g.role
|
|
FROM apps a
|
|
JOIN grants g ON a.id = g.app_id
|
|
WHERE g.user_id = ${auth.userId} AND a.domain IS NOT NULL
|
|
ORDER BY a.name ASC
|
|
` as any[];
|
|
}
|
|
|
|
return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin }));
|
|
});
|
|
|
|
uiApp.get("/dashboard/sessions", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
|
|
const apps = await sql`
|
|
SELECT id, name, domain, spiffe_id
|
|
FROM apps
|
|
ORDER BY name ASC
|
|
`;
|
|
|
|
const sessions = await sql`
|
|
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
|
|
FROM sessions
|
|
WHERE user_id = ${auth.userId} AND expires_at > NOW()
|
|
ORDER BY created_at DESC
|
|
`;
|
|
|
|
return c.html(
|
|
SessionsPage({
|
|
sessions,
|
|
currentSessionId: auth.sessionId,
|
|
apps: apps as any,
|
|
isAdmin,
|
|
}),
|
|
);
|
|
});
|
|
|
|
uiApp.get("/dashboard/passkeys", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
|
|
const passkeys = await sql`
|
|
SELECT id, credential_id, counter
|
|
FROM passkeys
|
|
WHERE user_id = ${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 auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
if (!isAdmin) {
|
|
return c.redirect("/dashboard");
|
|
}
|
|
|
|
const users = await sql`
|
|
SELECT id, username, display_name, account_status
|
|
FROM users
|
|
ORDER BY username ASC
|
|
`;
|
|
|
|
return c.html(AdminUsersPage({ users }));
|
|
});
|
|
|
|
uiApp.get("/admin/apps", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
if (!isAdmin) {
|
|
return c.redirect("/dashboard");
|
|
}
|
|
|
|
const apps = await sql`
|
|
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
|
|
COUNT(g.id) AS active_grants_count
|
|
FROM apps a
|
|
LEFT JOIN grants g ON a.id = g.app_id
|
|
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
|
|
ORDER BY a.created_at ASC
|
|
`;
|
|
|
|
return c.html(AdminAppsPage({ apps }));
|
|
});
|
|
|
|
uiApp.get("/admin/roles", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
if (!isAdmin) {
|
|
return c.redirect("/dashboard");
|
|
}
|
|
|
|
const roles = await sql`
|
|
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
|
a.name AS app_name
|
|
FROM roles r
|
|
LEFT JOIN apps a ON r.app_id = a.id
|
|
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
|
`;
|
|
|
|
const apps = await sql`
|
|
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
|
`;
|
|
|
|
return c.html(AdminRolesPage({ roles, apps }));
|
|
});
|
|
|
|
uiApp.get("/admin/invites", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
if (!isAdmin) {
|
|
return c.redirect("/dashboard");
|
|
}
|
|
|
|
const invites = await sql`
|
|
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
|
|
a.name AS app_name, a.id AS app_id,
|
|
u.username AS used_by_username
|
|
FROM invites i
|
|
LEFT JOIN apps a ON i.app_id = a.id
|
|
LEFT JOIN users u ON i.used_by = u.id
|
|
ORDER BY i.created_at DESC
|
|
`;
|
|
|
|
const apps = await sql`
|
|
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
|
`;
|
|
|
|
const allRoles = await sql`
|
|
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
|
|
`;
|
|
|
|
return c.html(AdminInvitesPage({ invites, apps, allRoles }));
|
|
});
|
|
|
|
uiApp.get("/admin/aaguid", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
if (!isAdmin) {
|
|
return c.redirect("/dashboard");
|
|
}
|
|
|
|
const allowlist = await sql`
|
|
SELECT id, aaguid, description, created_at
|
|
FROM aaguid_allowlist
|
|
ORDER BY created_at DESC
|
|
`;
|
|
|
|
return c.html(AAGUIDPage({ allowlist }));
|
|
});
|
|
|
|
uiApp.get("/admin/users/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
if (!isAdmin) {
|
|
return c.redirect("/admin/users");
|
|
}
|
|
|
|
const targetUserId = c.req.param("id");
|
|
const user = await sql`
|
|
SELECT id, username, display_name, account_status
|
|
FROM users
|
|
WHERE id = ${targetUserId}
|
|
`.then((res) => res[0]);
|
|
|
|
if (!user) {
|
|
return c.redirect("/admin/users");
|
|
}
|
|
|
|
const sessions = await sql`
|
|
SELECT id, created_at, expires_at
|
|
FROM sessions
|
|
WHERE user_id = ${targetUserId} AND expires_at > NOW()
|
|
ORDER BY created_at DESC
|
|
`;
|
|
|
|
const passkeys = await sql`
|
|
SELECT id, credential_id, counter
|
|
FROM passkeys
|
|
WHERE user_id = ${targetUserId}
|
|
`;
|
|
|
|
const grants = await sql`
|
|
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
|
|
FROM grants g
|
|
JOIN apps a ON g.app_id = a.id
|
|
WHERE g.user_id = ${targetUserId}
|
|
ORDER BY a.name ASC
|
|
`;
|
|
|
|
const allApps = await sql`
|
|
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
|
`;
|
|
|
|
const allRoles = await sql`
|
|
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
|
|
`;
|
|
|
|
return c.html(
|
|
AdminUserDetailsPage({
|
|
user,
|
|
sessions,
|
|
passkeys,
|
|
grants,
|
|
allApps,
|
|
allRoles,
|
|
}),
|
|
);
|
|
});
|
|
|
|
uiApp.get("/admin/audit-logs", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
if (getCookie(c, "session_id")) {
|
|
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
|
deleteCookie(c, "session_id", {
|
|
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
|
path: "/",
|
|
}); // clear domain cookie
|
|
}
|
|
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
|
return c.redirect("/login");
|
|
}
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
if (!isAdmin) {
|
|
return c.redirect("/dashboard");
|
|
}
|
|
|
|
const logs = await sql`
|
|
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
|
FROM audit_records a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
ORDER BY a.created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
|
|
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 };
|