- Implement iterative session cookie candidate resolution in getAuthenticatedUser - Eliminate Hono first-match limitation causing mobile login redirect loops - Use absolute UTC ISO strings for PostgreSQL session expiry queries - Opportunistically clear host-level cookies upon shadow detection - Ensure exhaustive server-side session revocation across all cookie candidates on logout - Add automated regression test for cookie shadowing in server/main.test.ts - Rename and standardize tasks/path.md with 5-template orchestrator standard
370 lines
10 KiB
TypeScript
370 lines
10 KiB
TypeScript
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;
|
|
}
|
|
|
|
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.
|
|
* Iterates through all session_id cookies to prevent Android Chrome cookie shadowing.
|
|
*/
|
|
|
|
/**
|
|
* Extracts all session_id tokens from the Cookie header.
|
|
* Necessary because Chromium Android can send both a host-only and a wildcard cookie simultaneously.
|
|
*/
|
|
export function extractAllSessionIds(c: Context): string[] {
|
|
const cookieHeader = c.req.header("cookie") || "";
|
|
if (!cookieHeader) return [];
|
|
return [...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g)]
|
|
.map((m) => decodeURIComponent(m[1].trim()))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
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) {
|
|
// A stale ghost cookie was ahead of this valid one.
|
|
// Attempt to purge the host-only cookie to heal the browser jar.
|
|
deleteCookie(c, "session_id", { path: "/" });
|
|
}
|
|
return {
|
|
userId: sessionData.uuid,
|
|
sessionId: candidateId,
|
|
username: sessionData.username || "",
|
|
};
|
|
}
|
|
}
|
|
} 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, 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 }),
|
|
);
|
|
} catch (_e) {}
|
|
|
|
if (i > 0) {
|
|
deleteCookie(c, "session_id", { path: "/" });
|
|
}
|
|
return { userId: session.user_id, sessionId: candidateId, username };
|
|
}
|
|
} 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;
|
|
}
|