diff --git a/server/auth-session.ts b/server/auth-session.ts index 6c05e53..fb9f5d7 100644 --- a/server/auth-session.ts +++ b/server/auth-session.ts @@ -1,410 +1,39 @@ 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[]; -} +import { + getClientIp, + getCookieDomain, + isIpAllowed, + isPathBypassed, + isSafeRedirectUrl, +} from "./forward_auth.ts"; -export interface AppRecord { - id: string; - name: string; - domain?: string; - is_public?: boolean; - bypass_paths?: string[]; - allowed_cidrs?: string[]; -} +import { + type AppRecord, + type AuthenticatedUser, + extractAllSessionIds, + getAppByHost, + getAuthenticatedUser, + getUserGrant, + isGlobalAdmin, +} from "./session_resolver.ts"; -/** - * 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}`; -} +// Re-export for backward compatibility +export { + type AppRecord, + type AuthenticatedUser, + extractAllSessionIds, + getAppByHost, + getAuthenticatedUser, + getClientIp, + getCookieDomain, + getUserGrant, + isGlobalAdmin, + isIpAllowed, + isPathBypassed, + isSafeRedirectUrl, +}; -/** - * 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 - 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 { - 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 { - 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 { - 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 { - 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. * If !auth.isAgent, primary sessions inherit full capabilities (returns true). @@ -473,21 +102,3 @@ export async function requireAdmin(c: Context, next: () => Promise) { } 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"; -} diff --git a/server/forward_auth.ts b/server/forward_auth.ts new file mode 100644 index 0000000..091dc39 --- /dev/null +++ b/server/forward_auth.ts @@ -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"; +} diff --git a/server/session_resolver.ts b/server/session_resolver.ts new file mode 100644 index 0000000..c82ac7e --- /dev/null +++ b/server/session_resolver.ts @@ -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 + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/ui/auth_checks.ts b/ui/auth_checks.ts new file mode 100644 index 0000000..d5b9c3b --- /dev/null +++ b/ui/auth_checks.ts @@ -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 { + 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 }; +} diff --git a/ui/db_queries.ts b/ui/db_queries.ts new file mode 100644 index 0000000..8c71321 --- /dev/null +++ b/ui/db_queries.ts @@ -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 + `; +} diff --git a/ui/mod.ts b/ui/mod.ts index 6a758ea..f0c3a0b 100644 --- a/ui/mod.ts +++ b/ui/mod.ts @@ -1,17 +1,33 @@ 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 { deleteCookie } from "jsr:@hono/hono@4/cookie"; import { sql } from "../server/db.ts"; import { valkey } from "../server/valkey.ts"; import { extractAllSessionIds, getAuthenticatedUser, getCookieDomain, - isGlobalAdmin, isSafeRedirectUrl, - isSessionAdmin, } 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"; @@ -131,77 +147,23 @@ uiApp.get("/register", (c) => { }); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { auth, isAdmin } = authRes; - 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[]; - } + const apps = await getDashboardApps(auth.userId, isAdmin); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { auth, isAdmin } = authRes; - 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 - `; - - 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 - `; + const apps = await getSessionApps(); + const sessions = await getUserSessions(auth.userId); + const eventPasses = await getUserEventPasses(auth.userId); return c.html( SessionsPage({ @@ -215,26 +177,11 @@ uiApp.get("/dashboard/sessions", async (c) => { }); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { auth, isAdmin } = authRes; - const isAdmin = await isGlobalAdmin(auth.userId); - - const passkeys = await sql` - SELECT id, credential_id, counter - FROM passkeys - WHERE user_id = ${auth.userId} - `; + const passkeys = await getUserPasskeys(auth.userId); return c.html(PasskeysPage({ passkeys, isAdmin })); }); @@ -245,229 +192,95 @@ uiApp.get("/admin", (c) => { }); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { isAdmin, isSessionAdminRole } = authRes; - const isAdmin = await isGlobalAdmin(auth.userId); - const isSessionAdminRole = await isSessionAdmin(auth); if (!isAdmin || !isSessionAdminRole) { return c.redirect("/dashboard", 302); } - const users = await sql` - SELECT id, username, display_name, account_status - FROM users - ORDER BY username ASC - `; - + const users = await getAdminUsers(); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { isAdmin, isSessionAdminRole } = authRes; - const isAdmin = await isGlobalAdmin(auth.userId); - const isSessionAdminRole = await isSessionAdmin(auth); if (!isAdmin || !isSessionAdminRole) { return c.redirect("/dashboard", 302); } - 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 - `; - + const apps = await getAdminApps(); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { isAdmin, isSessionAdminRole } = authRes; - const isAdmin = await isGlobalAdmin(auth.userId); - const isSessionAdminRole = await isSessionAdmin(auth); if (!isAdmin || !isSessionAdminRole) { return c.redirect("/dashboard", 302); } - 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 - `; - + const roles = await getAdminRoles(); + const apps = await getSessionApps(); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { isAdmin, isSessionAdminRole } = authRes; - const isAdmin = await isGlobalAdmin(auth.userId); - const isSessionAdminRole = await isSessionAdmin(auth); if (!isAdmin || !isSessionAdminRole) { return c.redirect("/dashboard", 302); } - 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 - `; + 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 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { isAdmin } = authRes; - 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 - `; - + const allowlist = await getAaguidAllowlist(); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { isAdmin, isSessionAdminRole } = authRes; - const isAdmin = await isGlobalAdmin(auth.userId); - const isSessionAdminRole = await isSessionAdmin(auth); if (!isAdmin || !isSessionAdminRole) { return c.redirect("/admin/users", 302); } 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]); + const user = await getAdminUserDetails(targetUserId); 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 - `; + 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({ @@ -482,33 +295,15 @@ uiApp.get("/admin/users/:id", async (c) => { }); 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 authRes = await requireUiAuth(c); + if (authRes instanceof Response) return authRes; + const { isAdmin, isSessionAdminRole } = authRes; - const isAdmin = await isGlobalAdmin(auth.userId); - const isSessionAdminRole = await isSessionAdmin(auth); if (!isAdmin || !isSessionAdminRole) { return c.redirect("/dashboard", 302); } - 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 - `; - + const logs = await getAdminAuditLogs(); return c.html(AuditLogPage({ logs })); });