Refactor server/auth-session.ts and ui/mod.ts to decouple domain utilities, database queries, and admin authorization checks into separate files (server/forward_auth.ts, server/session_resolver.ts, ui/db_queries.ts, ui/auth_checks.ts).

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-08-26 05:31:25 +00:00
parent f2310503f4
commit 99bb8d794f
6 changed files with 698 additions and 694 deletions

View File

@ -1,410 +1,39 @@
import type { Context } from "jsr:@hono/hono@4"; import type { Context } from "jsr:@hono/hono@4";
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
import { sqlWrapper } from "./db.ts";
import { valkey } from "./valkey.ts";
export interface AuthenticatedUser { import {
userId: string; getClientIp,
sessionId: string; getCookieDomain,
username: string; isIpAllowed,
label?: string; isPathBypassed,
isAgent?: boolean; isSafeRedirectUrl,
customScopes?: string[]; } from "./forward_auth.ts";
}
export interface AppRecord { import {
id: string; type AppRecord,
name: string; type AuthenticatedUser,
domain?: string; extractAllSessionIds,
is_public?: boolean; getAppByHost,
bypass_paths?: string[]; getAuthenticatedUser,
allowed_cidrs?: string[]; getUserGrant,
} isGlobalAdmin,
} from "./session_resolver.ts";
/** // Re-export for backward compatibility
* Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org) export {
* to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.). type AppRecord,
*/ type AuthenticatedUser,
export function getCookieDomain(customRpId?: string): string | undefined { extractAllSessionIds,
const envDomain = Deno.env.get("COOKIE_DOMAIN"); getAppByHost,
if (envDomain) { getAuthenticatedUser,
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`; getClientIp,
} getCookieDomain,
const targetId = customRpId || Deno.env.get("RP_ID") || ""; getUserGrant,
if (!targetId || !targetId.includes(".") || targetId === "localhost") { isGlobalAdmin,
return undefined; isIpAllowed,
} isPathBypassed,
const parts = targetId.split(".").filter(Boolean); isSafeRedirectUrl,
if (parts.length >= 2) { };
return `.${parts.slice(-2).join(".")}`;
}
return `.${targetId}`;
}
/**
* Helper to get authenticated user from session cookie or Authorization header.
* Checks Valkey cache first, with automatic PostgreSQL sessions table fallback.
* Iterates through all session_id cookies to prevent Android Chrome cookie shadowing.
*/
/**
* Extracts all session_id tokens from the Cookie and Authorization headers.
*/
export function extractAllSessionIds(c: Context): string[] {
const candidates: string[] = [];
// 1. Check Authorization: Bearer <token>
const authHeader = c.req.header("authorization") || "";
if (authHeader.startsWith("Bearer ")) {
const bearerToken = authHeader.substring(7).trim();
if (bearerToken) candidates.push(bearerToken);
}
// 2. Check Cookie header
const cookieHeader = c.req.header("cookie") || "";
if (cookieHeader) {
const cookieMatches = [
...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g),
]
.map((m) => decodeURIComponent(m[1].trim()))
.filter(Boolean);
candidates.push(...cookieMatches);
}
return candidates;
}
export async function getAuthenticatedUser(
c: Context,
): Promise<AuthenticatedUser | null> {
const sessionMatches = extractAllSessionIds(c);
if (sessionMatches.length === 0) return null;
// Iterate over each candidate session ID
for (let i = 0; i < sessionMatches.length; i++) {
const candidateId = sessionMatches[i];
// 1. Try Valkey cache
try {
const sessionDataStr = await valkey.get(candidateId);
if (sessionDataStr) {
const sessionData = JSON.parse(sessionDataStr);
if (sessionData && sessionData.uuid) {
if (i > 0) {
deleteCookie(c, "session_id", { path: "/" });
}
return {
userId: sessionData.uuid,
sessionId: candidateId,
username: sessionData.username || "",
label: sessionData.label,
isAgent: sessionData.isAgent,
customScopes: sessionData.customScopes,
};
}
}
} catch (_err) {
// Valkey cache miss or connection hiccup - fallback to DB
}
// 2. Fallback to PostgreSQL sessions table
try {
const nowIso = new Date().toISOString();
const session = await sqlWrapper.sql`
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
`.then((res: any) => res[0]);
if (session) {
const username = session.username || "";
// Repopulate Valkey in background
try {
const ttlSeconds = Math.max(
1,
Math.floor(
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
),
);
await valkey.setex(
candidateId,
ttlSeconds,
JSON.stringify({
uuid: session.user_id,
username,
label: session.label,
isAgent: session.is_agent,
customScopes: session.custom_scopes,
}),
);
} catch (_e) {}
if (i > 0) {
deleteCookie(c, "session_id", { path: "/" });
}
return {
userId: session.user_id,
sessionId: candidateId,
username,
label: session.label,
isAgent: session.is_agent,
customScopes: session.custom_scopes,
};
}
} catch (_err) {
// Continue to next candidate
}
}
return null;
}
/**
* Helper to get an application by host.
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
*/
export async function getAppByHost(
host: string,
): Promise<AppRecord | null> {
const cacheKey = `auth:app_by_host:${host}`;
// 1. Try Valkey cache
try {
const cachedStr = await valkey.get(cacheKey);
if (cachedStr) {
return JSON.parse(cachedStr);
}
} catch (_err) {
// Valkey cache miss or connection error
}
// 2. Fallback to PostgreSQL
try {
// Search by domain first
let app = await sqlWrapper.sql`
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
FROM apps WHERE domain = ${host}
`.then((res: any) => res[0]);
// Fallback: match name against the first subdomain segment
if (!app) {
const subdomain = host.split(".")[0];
if (subdomain) {
app = await sqlWrapper.sql`
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
FROM apps WHERE name = ${subdomain}
`.then((res: any) => res[0]);
}
}
if (app) {
// Repopulate Valkey
try {
await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour
} catch (_e) {}
return app as AppRecord;
}
} catch (_err) {
return null;
}
return null;
}
/**
* Helper to get a user's role grant for a specific app.
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
*/
export async function getUserGrant(
userId: string,
appId: string,
): Promise<string | null> {
const cacheKey = `auth:grants:${userId}:${appId}`;
// 1. Try Valkey cache
try {
const cachedRole = await valkey.get(cacheKey);
if (cachedRole) {
return cachedRole;
}
} catch (_err) {
// Valkey cache miss or connection error
}
// 2. Fallback to PostgreSQL
try {
const grant = await sqlWrapper.sql`
SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appId}
`.then((res: any) => res[0]);
if (grant && grant.role) {
// Repopulate Valkey
try {
await valkey.setex(cacheKey, 3600, grant.role); // Cache for 1 hour
} catch (_e) {}
return grant.role;
}
} catch (_err) {
return null;
}
return null;
}
/**
* Helper to check if user has global admin privileges.
* Strict check: Requires an explicit 'admin' grant on the Management Console
* or global role, or is the bootstrap root user.
*/
export async function isGlobalAdmin(userId: string): Promise<boolean> {
try {
// Check 1: User has an explicit 'admin' grant for the Auth-Yes Management Console or global app
const adminGrant = await sqlWrapper.sql`
SELECT g.id
FROM grants g
LEFT JOIN apps a ON g.app_id = a.id
WHERE g.user_id = ${userId}
AND g.role = 'admin'
AND (
a.spiffe_id = 'spiffe://system.local/auth-yes-management'
OR a.name = 'Auth-Yes Management Console'
OR g.app_id IS NULL
)
`.then((res: any) => res[0]);
if (adminGrant) return true;
// Check 2: First registered user in system fallback
const firstUser = await sqlWrapper.sql`
SELECT id FROM users ORDER BY created_at ASC NULLS LAST, username ASC LIMIT 1
`.then((res: any) => res[0]);
if (firstUser && firstUser.id === userId) {
return true;
}
} catch (err) {
console.error("[Auth API] isGlobalAdmin error:", err);
}
return false;
}
/**
* Deterministic fast path prefix matcher for dynamic bypasses
*/
export function isPathBypassed(
requestPath: string,
bypassPaths?: string[],
): boolean {
if (!bypassPaths || bypassPaths.length === 0) return false;
for (const pattern of bypassPaths) {
if (pattern === requestPath) return true;
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -2); // remove /*
if (requestPath === prefix || requestPath.startsWith(prefix + "/")) {
return true;
}
}
}
return false;
}
/**
* Pure native Deno bitwise CIDR matcher
*/
export function isIpAllowed(
clientIp: string,
allowedCidrs?: string[],
): boolean {
if (!allowedCidrs || allowedCidrs.length === 0) return false;
// Basic IP parsing (v4 only for simplicity and speed, or basic v6 check)
const parseIp4 = (ip: string) => {
const parts = ip.split(".");
if (parts.length !== 4) return null;
return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>>
0;
};
// X-Forwarded-For can contain multiple IPs if chained (e.g., "client, proxy1, proxy2")
// We extract the first IP (the original client)
const primaryIp = clientIp.split(",")[0].trim();
const ipNum = parseIp4(primaryIp);
for (const cidr of allowedCidrs) {
const [subnet, maskStr] = cidr.split("/");
if (!maskStr) {
if (subnet === primaryIp) return true;
continue;
}
// IPv4 CIDR matching
if (ipNum !== null && subnet.includes(".")) {
const subnetNum = parseIp4(subnet);
if (subnetNum !== null) {
const maskBits = parseInt(maskStr, 10);
// Fix for /0 masks to avoid JS bitwise shift 32 overflow masking
const mask = maskBits === 0
? 0
: ((0xffffffff << (32 - maskBits)) >>> 0);
if ((ipNum & mask) === (subnetNum & mask)) {
return true;
}
}
} // Note: To remain zero-dependency and ultra-fast, we are supporting IPv4 CIDR.
// Full IPv6 CIDR math would be added here if needed, but string equality
// works for exact IPv6 matches.
else if (subnet === clientIp) {
return true;
}
}
return false;
}
/**
* Validates a given URL to ensure it is safe to redirect to.
* Allows relative paths, localhost, and *.atyg.org (or custom RP_ID)
*/
export function isSafeRedirectUrl(
rawUrl: string,
customDomain?: string,
): boolean {
if (!rawUrl) return false;
// 1. Relative paths within the same origin are safe
if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) {
return true;
}
try {
const parsed = new URL(rawUrl);
const host = parsed.hostname;
const root = customDomain || Deno.env.get("RP_ID") || "atyg.org";
const cleanRoot = root.replace(/^\./, "");
// 2. Allow localhost, exact root match, or subdomains (*.atyg.org)
if (
host === "localhost" ||
host === "atyg.org" ||
host.endsWith(".atyg.org") ||
host === cleanRoot ||
host.endsWith(`.${cleanRoot}`)
) {
return true;
}
} catch (_e) {
return false;
}
return false;
}
/**
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
*/
/** /**
* Evaluates if the current user's capabilities satisfy the required scope. * Evaluates if the current user's capabilities satisfy the required scope.
* If !auth.isAgent, primary sessions inherit full capabilities (returns true). * If !auth.isAgent, primary sessions inherit full capabilities (returns true).
@ -473,21 +102,3 @@ export async function requireAdmin(c: Context, next: () => Promise<void>) {
} }
await next(); await next();
} }
export function getClientIp(c: Context): string {
const realIp = c.req.header("x-real-ip");
if (realIp) {
return realIp.trim();
}
let forwardedFor = c.req.header("x-forwarded-for");
if (forwardedFor) {
if (forwardedFor.length > 256) {
forwardedFor = forwardedFor.substring(0, 256);
}
const parts = forwardedFor.split(",");
return parts[parts.length - 1].trim();
}
return "127.0.0.1";
}

152
server/forward_auth.ts Normal file
View File

@ -0,0 +1,152 @@
import type { Context } from "jsr:@hono/hono@4";
/**
* Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org)
* to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.).
*/
export function getCookieDomain(customRpId?: string): string | undefined {
const envDomain = Deno.env.get("COOKIE_DOMAIN");
if (envDomain) {
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
}
const targetId = customRpId || Deno.env.get("RP_ID") || "";
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
return undefined;
}
const parts = targetId.split(".").filter(Boolean);
if (parts.length >= 2) {
return `.${parts.slice(-2).join(".")}`;
}
return `.${targetId}`;
}
/**
* Deterministic fast path prefix matcher for dynamic bypasses
*/
export function isPathBypassed(
requestPath: string,
bypassPaths?: string[],
): boolean {
if (!bypassPaths || bypassPaths.length === 0) return false;
for (const pattern of bypassPaths) {
if (pattern === requestPath) return true;
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -2); // remove /*
if (requestPath === prefix || requestPath.startsWith(prefix + "/")) {
return true;
}
}
}
return false;
}
/**
* Pure native Deno bitwise CIDR matcher
*/
export function isIpAllowed(
clientIp: string,
allowedCidrs?: string[],
): boolean {
if (!allowedCidrs || allowedCidrs.length === 0) return false;
// Basic IP parsing (v4 only for simplicity and speed, or basic v6 check)
const parseIp4 = (ip: string) => {
const parts = ip.split(".");
if (parts.length !== 4) return null;
return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>>
0;
};
// X-Forwarded-For can contain multiple IPs if chained (e.g., "client, proxy1, proxy2")
// We extract the first IP (the original client)
const primaryIp = clientIp.split(",")[0].trim();
const ipNum = parseIp4(primaryIp);
for (const cidr of allowedCidrs) {
const [subnet, maskStr] = cidr.split("/");
if (!maskStr) {
if (subnet === primaryIp) return true;
continue;
}
// IPv4 CIDR matching
if (ipNum !== null && subnet.includes(".")) {
const subnetNum = parseIp4(subnet);
if (subnetNum !== null) {
const maskBits = parseInt(maskStr, 10);
// Fix for /0 masks to avoid JS bitwise shift 32 overflow masking
const mask = maskBits === 0
? 0
: ((0xffffffff << (32 - maskBits)) >>> 0);
if ((ipNum & mask) === (subnetNum & mask)) {
return true;
}
}
} // Note: To remain zero-dependency and ultra-fast, we are supporting IPv4 CIDR.
// Full IPv6 CIDR math would be added here if needed, but string equality
// works for exact IPv6 matches.
else if (subnet === clientIp) {
return true;
}
}
return false;
}
/**
* Validates a given URL to ensure it is safe to redirect to.
* Allows relative paths, localhost, and *.atyg.org (or custom RP_ID)
*/
export function isSafeRedirectUrl(
rawUrl: string,
customDomain?: string,
): boolean {
if (!rawUrl) return false;
// 1. Relative paths within the same origin are safe
if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) {
return true;
}
try {
const parsed = new URL(rawUrl);
const host = parsed.hostname;
const root = customDomain || Deno.env.get("RP_ID") || "atyg.org";
const cleanRoot = root.replace(/^\./, "");
// 2. Allow localhost, exact root match, or subdomains (*.atyg.org)
if (
host === "localhost" ||
host === "atyg.org" ||
host.endsWith(".atyg.org") ||
host === cleanRoot ||
host.endsWith(`.${cleanRoot}`)
) {
return true;
}
} catch (_e) {
return false;
}
return false;
}
/**
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
*/
export function getClientIp(c: Context): string {
const realIp = c.req.header("x-real-ip");
if (realIp) {
return realIp.trim();
}
let forwardedFor = c.req.header("x-forwarded-for");
if (forwardedFor) {
if (forwardedFor.length > 256) {
forwardedFor = forwardedFor.substring(0, 256);
}
const parts = forwardedFor.split(",");
return parts[parts.length - 1].trim();
}
return "127.0.0.1";
}

267
server/session_resolver.ts Normal file
View File

@ -0,0 +1,267 @@
import type { Context } from "jsr:@hono/hono@4";
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
import { sqlWrapper } from "./db.ts";
import { valkey } from "./valkey.ts";
export interface AuthenticatedUser {
userId: string;
sessionId: string;
username: string;
label?: string;
isAgent?: boolean;
customScopes?: string[];
}
export interface AppRecord {
id: string;
name: string;
domain?: string;
is_public?: boolean;
bypass_paths?: string[];
allowed_cidrs?: string[];
}
/**
* Extracts all session_id tokens from the Cookie and Authorization headers.
*/
export function extractAllSessionIds(c: Context): string[] {
const candidates: string[] = [];
// 1. Check Authorization: Bearer <token>
const authHeader = c.req.header("authorization") || "";
if (authHeader.startsWith("Bearer ")) {
const bearerToken = authHeader.substring(7).trim();
if (bearerToken) candidates.push(bearerToken);
}
// 2. Check Cookie header
const cookieHeader = c.req.header("cookie") || "";
if (cookieHeader) {
const cookieMatches = [
...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g),
]
.map((m) => decodeURIComponent(m[1].trim()))
.filter(Boolean);
candidates.push(...cookieMatches);
}
return candidates;
}
export async function getAuthenticatedUser(
c: Context,
): Promise<AuthenticatedUser | null> {
const sessionMatches = extractAllSessionIds(c);
if (sessionMatches.length === 0) return null;
// Iterate over each candidate session ID
for (let i = 0; i < sessionMatches.length; i++) {
const candidateId = sessionMatches[i];
// 1. Try Valkey cache
try {
const sessionDataStr = await valkey.get(candidateId);
if (sessionDataStr) {
const sessionData = JSON.parse(sessionDataStr);
if (sessionData && sessionData.uuid) {
if (i > 0) {
deleteCookie(c, "session_id", { path: "/" });
}
return {
userId: sessionData.uuid,
sessionId: candidateId,
username: sessionData.username || "",
label: sessionData.label,
isAgent: sessionData.isAgent,
customScopes: sessionData.customScopes,
};
}
}
} catch (_err) {
// Valkey cache miss or connection hiccup - fallback to DB
}
// 2. Fallback to PostgreSQL sessions table
try {
const nowIso = new Date().toISOString();
const session = await sqlWrapper.sql`
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
`.then((res: any) => res[0]);
if (session) {
const username = session.username || "";
// Repopulate Valkey in background
try {
const ttlSeconds = Math.max(
1,
Math.floor(
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
),
);
await valkey.setex(
candidateId,
ttlSeconds,
JSON.stringify({
uuid: session.user_id,
username,
label: session.label,
isAgent: session.is_agent,
customScopes: session.custom_scopes,
}),
);
} catch (_e) {}
if (i > 0) {
deleteCookie(c, "session_id", { path: "/" });
}
return {
userId: session.user_id,
sessionId: candidateId,
username,
label: session.label,
isAgent: session.is_agent,
customScopes: session.custom_scopes,
};
}
} catch (_err) {
// Continue to next candidate
}
}
return null;
}
/**
* Helper to get an application by host.
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
*/
export async function getAppByHost(
host: string,
): Promise<AppRecord | null> {
const cacheKey = `auth:app_by_host:${host}`;
// 1. Try Valkey cache
try {
const cachedStr = await valkey.get(cacheKey);
if (cachedStr) {
return JSON.parse(cachedStr);
}
} catch (_err) {
// Valkey cache miss or connection error
}
// 2. Fallback to PostgreSQL
try {
// Search by domain first
let app = await sqlWrapper.sql`
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
FROM apps WHERE domain = ${host}
`.then((res: any) => res[0]);
// Fallback: match name against the first subdomain segment
if (!app) {
const subdomain = host.split(".")[0];
if (subdomain) {
app = await sqlWrapper.sql`
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
FROM apps WHERE name = ${subdomain}
`.then((res: any) => res[0]);
}
}
if (app) {
// Repopulate Valkey
try {
await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour
} catch (_e) {}
return app as AppRecord;
}
} catch (_err) {
return null;
}
return null;
}
/**
* Helper to get a user's role grant for a specific app.
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
*/
export async function getUserGrant(
userId: string,
appId: string,
): Promise<string | null> {
const cacheKey = `auth:grants:${userId}:${appId}`;
// 1. Try Valkey cache
try {
const cachedRole = await valkey.get(cacheKey);
if (cachedRole) {
return cachedRole;
}
} catch (_err) {
// Valkey cache miss or connection error
}
// 2. Fallback to PostgreSQL
try {
const grant = await sqlWrapper.sql`
SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appId}
`.then((res: any) => res[0]);
if (grant && grant.role) {
// Repopulate Valkey
try {
await valkey.setex(cacheKey, 3600, grant.role); // Cache for 1 hour
} catch (_e) {}
return grant.role;
}
} catch (_err) {
return null;
}
return null;
}
/**
* Helper to check if user has global admin privileges.
* Strict check: Requires an explicit 'admin' grant on the Management Console
* or global role, or is the bootstrap root user.
*/
export async function isGlobalAdmin(userId: string): Promise<boolean> {
try {
// Check 1: User has an explicit 'admin' grant for the Auth-Yes Management Console or global app
const adminGrant = await sqlWrapper.sql`
SELECT g.id
FROM grants g
LEFT JOIN apps a ON g.app_id = a.id
WHERE g.user_id = ${userId}
AND g.role = 'admin'
AND (
a.spiffe_id = 'spiffe://system.local/auth-yes-management'
OR a.name = 'Auth-Yes Management Console'
OR g.app_id IS NULL
)
`.then((res: any) => res[0]);
if (adminGrant) return true;
// Check 2: First registered user in system fallback
const firstUser = await sqlWrapper.sql`
SELECT id FROM users ORDER BY created_at ASC NULLS LAST, username ASC LIMIT 1
`.then((res: any) => res[0]);
if (firstUser && firstUser.id === userId) {
return true;
}
} catch (err) {
console.error("[Auth API] isGlobalAdmin error:", err);
}
return false;
}

42
ui/auth_checks.ts Normal file
View File

@ -0,0 +1,42 @@
import type { Context } from "jsr:@hono/hono@4";
import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie";
import {
type AuthenticatedUser,
getAuthenticatedUser,
getCookieDomain,
isGlobalAdmin,
isSessionAdmin,
} from "../server/auth-session.ts";
export interface UiAuthResult {
auth: AuthenticatedUser;
isAdmin: boolean;
isSessionAdminRole: boolean;
}
/**
* Helper to ensure a user is authenticated for UI routes.
* If not authenticated, clears cookies and redirects to /login.
* Returns the AuthenticatedUser and admin status if successful, or a Hono Response (redirect).
*/
export async function requireUiAuth(
c: Context,
): Promise<UiAuthResult | Response> {
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 isSessionAdminRole = await isSessionAdmin(auth);
return { auth, isAdmin, isSessionAdminRole };
}

137
ui/db_queries.ts Normal file
View File

@ -0,0 +1,137 @@
import { sql } from "../server/db.ts";
export async function getDashboardApps(userId: string, isAdmin: boolean) {
if (isAdmin) {
return await sql`
SELECT id, name, description, domain, 'Admin' as role
FROM apps
WHERE domain IS NOT NULL
ORDER BY name ASC
` as any[];
} else {
return 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 = ${userId} AND a.domain IS NOT NULL
ORDER BY a.name ASC
` as any[];
}
}
export async function getSessionApps() {
return await sql`
SELECT id, name, domain, spiffe_id
FROM apps
ORDER BY name ASC
`;
}
export async function getUserSessions(userId: string) {
return await sql`
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
FROM sessions
WHERE user_id = ${userId} AND expires_at > NOW()
ORDER BY created_at DESC
`;
}
export async function getUserEventPasses(userId: string) {
return await sql`
SELECT id, slug, pin_code, name, max_seats, seats_claimed, is_active, expires_at
FROM event_passes
WHERE created_by = ${userId} AND is_active = TRUE
ORDER BY created_at DESC
`;
}
export async function getUserPasskeys(userId: string) {
return await sql`
SELECT id, credential_id, counter
FROM passkeys
WHERE user_id = ${userId}
`;
}
export async function getAdminUsers() {
return await sql`
SELECT id, username, display_name, account_status
FROM users
ORDER BY username ASC
`;
}
export async function getAdminApps() {
return 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
`;
}
export async function getAdminRoles() {
return 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
`;
}
export async function getAdminInvites() {
return 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
`;
}
export async function getAllRoles() {
return await sql`
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
`;
}
export async function getAaguidAllowlist() {
return await sql`
SELECT id, aaguid, description, created_at
FROM aaguid_allowlist
ORDER BY created_at DESC
`;
}
export async function getAdminUserDetails(targetUserId: string) {
return await sql`
SELECT id, username, display_name, account_status
FROM users
WHERE id = ${targetUserId}
`.then((res) => res[0]);
}
export async function getUserGrants(targetUserId: string) {
return 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
`;
}
export async function getAdminAuditLogs() {
return 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
`;
}

343
ui/mod.ts
View File

@ -1,17 +1,33 @@
import { Hono } from "jsr:@hono/hono@4"; import { Hono } from "jsr:@hono/hono@4";
import { serveStatic } from "jsr:@hono/hono@4/deno"; import { serveStatic } from "jsr:@hono/hono@4/deno";
import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie"; import { deleteCookie } from "jsr:@hono/hono@4/cookie";
import { sql } from "../server/db.ts"; import { sql } from "../server/db.ts";
import { valkey } from "../server/valkey.ts"; import { valkey } from "../server/valkey.ts";
import { import {
extractAllSessionIds, extractAllSessionIds,
getAuthenticatedUser, getAuthenticatedUser,
getCookieDomain, getCookieDomain,
isGlobalAdmin,
isSafeRedirectUrl, isSafeRedirectUrl,
isSessionAdmin,
} from "../server/auth-session.ts"; } from "../server/auth-session.ts";
import { auditWrapper } from "../server/audit.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 { LoginPage } from "./components/LoginPage.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx"; import { RegisterPage } from "./components/RegisterPage.tsx";
import { SessionsPage } from "./components/SessionsPage.tsx"; import { SessionsPage } from "./components/SessionsPage.tsx";
@ -131,77 +147,23 @@ uiApp.get("/register", (c) => {
}); });
uiApp.get("/dashboard", async (c) => { uiApp.get("/dashboard", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { auth, isAdmin } = authRes;
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 getDashboardApps(auth.userId, isAdmin);
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 })); return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin }));
}); });
uiApp.get("/dashboard/sessions", async (c) => { uiApp.get("/dashboard/sessions", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { auth, isAdmin } = authRes;
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 getSessionApps();
const sessions = await getUserSessions(auth.userId);
const apps = await sql` const eventPasses = await getUserEventPasses(auth.userId);
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
`;
const eventPasses = await sql`
SELECT id, slug, pin_code, name, max_seats, seats_claimed, is_active, expires_at
FROM event_passes
WHERE created_by = ${auth.userId} AND is_active = TRUE
ORDER BY created_at DESC
`;
return c.html( return c.html(
SessionsPage({ SessionsPage({
@ -215,26 +177,11 @@ uiApp.get("/dashboard/sessions", async (c) => {
}); });
uiApp.get("/dashboard/passkeys", async (c) => { uiApp.get("/dashboard/passkeys", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { auth, isAdmin } = authRes;
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 getUserPasskeys(auth.userId);
const passkeys = await sql`
SELECT id, credential_id, counter
FROM passkeys
WHERE user_id = ${auth.userId}
`;
return c.html(PasskeysPage({ passkeys, isAdmin })); return c.html(PasskeysPage({ passkeys, isAdmin }));
}); });
@ -245,229 +192,95 @@ uiApp.get("/admin", (c) => {
}); });
uiApp.get("/admin/users", async (c) => { uiApp.get("/admin/users", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { isAdmin, isSessionAdminRole } = authRes;
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 isSessionAdminRole = await isSessionAdmin(auth);
if (!isAdmin || !isSessionAdminRole) { if (!isAdmin || !isSessionAdminRole) {
return c.redirect("/dashboard", 302); return c.redirect("/dashboard", 302);
} }
const users = await sql` const users = await getAdminUsers();
SELECT id, username, display_name, account_status
FROM users
ORDER BY username ASC
`;
return c.html(AdminUsersPage({ users })); return c.html(AdminUsersPage({ users }));
}); });
uiApp.get("/admin/apps", async (c) => { uiApp.get("/admin/apps", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { isAdmin, isSessionAdminRole } = authRes;
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 isSessionAdminRole = await isSessionAdmin(auth);
if (!isAdmin || !isSessionAdminRole) { if (!isAdmin || !isSessionAdminRole) {
return c.redirect("/dashboard", 302); return c.redirect("/dashboard", 302);
} }
const apps = await sql` const apps = await getAdminApps();
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 })); return c.html(AdminAppsPage({ apps }));
}); });
uiApp.get("/admin/roles", async (c) => { uiApp.get("/admin/roles", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { isAdmin, isSessionAdminRole } = authRes;
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 isSessionAdminRole = await isSessionAdmin(auth);
if (!isAdmin || !isSessionAdminRole) { if (!isAdmin || !isSessionAdminRole) {
return c.redirect("/dashboard", 302); return c.redirect("/dashboard", 302);
} }
const roles = await sql` const roles = await getAdminRoles();
SELECT r.id, r.name, r.description, r.app_id, r.created_at, const apps = await getSessionApps();
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 })); return c.html(AdminRolesPage({ roles, apps }));
}); });
uiApp.get("/admin/invites", async (c) => { uiApp.get("/admin/invites", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { isAdmin, isSessionAdminRole } = authRes;
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 isSessionAdminRole = await isSessionAdmin(auth);
if (!isAdmin || !isSessionAdminRole) { if (!isAdmin || !isSessionAdminRole) {
return c.redirect("/dashboard", 302); return c.redirect("/dashboard", 302);
} }
const invites = await sql` const invites = await getAdminInvites();
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, const apps = await getSessionApps();
a.name AS app_name, a.id AS app_id, const allRoles = await getAllRoles();
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 })); return c.html(AdminInvitesPage({ invites, apps, allRoles }));
}); });
uiApp.get("/admin/aaguid", async (c) => { uiApp.get("/admin/aaguid", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { isAdmin } = authRes;
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) { if (!isAdmin) {
return c.redirect("/dashboard"); return c.redirect("/dashboard");
} }
const allowlist = await sql` const allowlist = await getAaguidAllowlist();
SELECT id, aaguid, description, created_at
FROM aaguid_allowlist
ORDER BY created_at DESC
`;
return c.html(AAGUIDPage({ allowlist })); return c.html(AAGUIDPage({ allowlist }));
}); });
uiApp.get("/admin/users/:id", async (c) => { uiApp.get("/admin/users/:id", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { isAdmin, isSessionAdminRole } = authRes;
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 isSessionAdminRole = await isSessionAdmin(auth);
if (!isAdmin || !isSessionAdminRole) { if (!isAdmin || !isSessionAdminRole) {
return c.redirect("/admin/users", 302); return c.redirect("/admin/users", 302);
} }
const targetUserId = c.req.param("id"); const targetUserId = c.req.param("id");
const user = await sql` const user = await getAdminUserDetails(targetUserId);
SELECT id, username, display_name, account_status
FROM users
WHERE id = ${targetUserId}
`.then((res) => res[0]);
if (!user) { if (!user) {
return c.redirect("/admin/users"); return c.redirect("/admin/users");
} }
const sessions = await sql` const sessions = await getUserSessions(targetUserId);
SELECT id, created_at, expires_at const passkeys = await getUserPasskeys(targetUserId);
FROM sessions const grants = await getUserGrants(targetUserId);
WHERE user_id = ${targetUserId} AND expires_at > NOW() const allApps = await getSessionApps();
ORDER BY created_at DESC const allRoles = await getAllRoles();
`;
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( return c.html(
AdminUserDetailsPage({ AdminUserDetailsPage({
@ -482,33 +295,15 @@ uiApp.get("/admin/users/:id", async (c) => {
}); });
uiApp.get("/admin/audit-logs", async (c) => { uiApp.get("/admin/audit-logs", async (c) => {
const auth = await getAuthenticatedUser(c); const authRes = await requireUiAuth(c);
if (!auth) { if (authRes instanceof Response) return authRes;
if (getCookie(c, "session_id")) { const { isAdmin, isSessionAdminRole } = authRes;
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 isSessionAdminRole = await isSessionAdmin(auth);
if (!isAdmin || !isSessionAdminRole) { if (!isAdmin || !isSessionAdminRole) {
return c.redirect("/dashboard", 302); return c.redirect("/dashboard", 302);
} }
const logs = await sql` const logs = await getAdminAuditLogs();
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 })); return c.html(AuditLogPage({ logs }));
}); });