import type { Context } from "jsr:@hono/hono@4"; import { getCookie } 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; } export interface AppRecord { id: string; name: string; domain?: string; is_public?: boolean; bypass_paths?: string[]; allowed_cidrs?: string[]; } /** * 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}`; } /** * Helper to get authenticated user from session cookie. * Checks Valkey cache first, with automatic PostgreSQL sessions table fallback. */ export async function getAuthenticatedUser( c: Context, ): Promise { const sessionId = getCookie(c, "session_id"); if (!sessionId) return null; // 1. Try Valkey cache try { const sessionDataStr = await valkey.get(sessionId); if (sessionDataStr) { const sessionData = JSON.parse(sessionDataStr); if (sessionData && sessionData.uuid) { return { userId: sessionData.uuid, sessionId, username: sessionData.username || "", }; } } } catch (_err) { // Valkey cache miss or connection hiccup - fallback to DB } // 2. Fallback to PostgreSQL sessions table try { const session = await sqlWrapper.sql` SELECT s.user_id, s.expires_at, u.username FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = ${sessionId} AND s.expires_at > NOW() `.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( sessionId, ttlSeconds, JSON.stringify({ uuid: session.user_id, username }), ); } catch (_e) {} return { userId: session.user_id, sessionId, username }; } } catch (_err) { return null; } 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 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; } } * 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; }