auth-yes/server/forward_auth.ts

153 lines
4.3 KiB
TypeScript

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";
}