feat(phase-5a): complete runtime switchover to src/main.ts and isolate cleanup task (#61)
This commit is contained in:
parent
58ac8a983d
commit
ee02daba79
@ -7,8 +7,8 @@
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"tasks": {
|
||||
"dev": "deno run --watch -A --unstable-ffi server/main.ts",
|
||||
"start": "deno run -A --unstable-ffi server/main.ts",
|
||||
"dev": "deno run --watch -A --unstable-ffi src/main.ts",
|
||||
"start": "deno run -A --unstable-ffi src/main.ts",
|
||||
"test": "deno test -A --unstable-ffi",
|
||||
"lint": "deno lint && deno run -A scripts/lint_arch.ts",
|
||||
"lint:arch": "deno run -A scripts/lint_arch.ts",
|
||||
|
||||
98
src/core/forward_auth.ts
Normal file
98
src/core/forward_auth.ts
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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);
|
||||
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;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (ipNum !== null && subnet.includes(".")) {
|
||||
const subnetNum = parseIp4(subnet);
|
||||
if (subnetNum !== null) {
|
||||
const maskBits = parseInt(maskStr, 10);
|
||||
const mask = maskBits === 0
|
||||
? 0
|
||||
: ((0xffffffff << (32 - maskBits)) >>> 0);
|
||||
if ((ipNum & mask) === (subnetNum & mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (subnet === clientIp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a given URL to ensure it is safe to redirect to.
|
||||
*/
|
||||
export function isSafeRedirectUrl(
|
||||
rawUrl: string,
|
||||
customDomain?: string,
|
||||
): boolean {
|
||||
if (!rawUrl) return false;
|
||||
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(/^\./, "");
|
||||
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host === "atyg.org" ||
|
||||
host.endsWith(".atyg.org") ||
|
||||
host === cleanRoot ||
|
||||
host.endsWith(`.${cleanRoot}`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
180
src/core/http_signatures.ts
Normal file
180
src/core/http_signatures.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { valkey } from "./valkey.ts";
|
||||
|
||||
/**
|
||||
* Computes the RFC 7638 JWK Thumbprint of an Ed25519 OKP key.
|
||||
* This canonicalizes the JWK by ordering the keys: crv, kty, x
|
||||
*/
|
||||
export async function computeJwkThumbprint(
|
||||
jwk: { kty: string; crv: string; x: string },
|
||||
): Promise<string> {
|
||||
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) {
|
||||
throw new Error("Invalid Ed25519 JWK");
|
||||
}
|
||||
|
||||
const canonicalJwk = `{"crv":"Ed25519","kty":"OKP","x":"${jwk.x}"}`;
|
||||
const data = new TextEncoder().encode(canonicalJwk);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and verifies an RFC 9421 HTTP Message Signature on a given Request.
|
||||
* Returns the fingerprint if verified, or throws an error.
|
||||
*/
|
||||
export async function verifyHttpSignature(req: Request): Promise<string> {
|
||||
const signatureInput = req.headers.get("Signature-Input");
|
||||
const signature = req.headers.get("Signature");
|
||||
|
||||
if (!signatureInput || !signature) {
|
||||
throw new Error("Missing HTTP Message Signature headers");
|
||||
}
|
||||
|
||||
const sigMatch = signatureInput.match(/sig1=\(([^)]+)\)(.*)/);
|
||||
if (!sigMatch) {
|
||||
throw new Error("Invalid Signature-Input format");
|
||||
}
|
||||
|
||||
const componentsStr = sigMatch[1];
|
||||
const paramsStr = sigMatch[2];
|
||||
|
||||
const components = componentsStr.split(" ").map((s) => s.replace(/"/g, ""));
|
||||
|
||||
const params = new Map<string, string>();
|
||||
const paramRegex = /;([a-z]+)=([^;]+)/g;
|
||||
let match;
|
||||
while ((match = paramRegex.exec(paramsStr)) !== null) {
|
||||
let val = match[2];
|
||||
if (val.startsWith('"') && val.endsWith('"')) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
params.set(match[1], val);
|
||||
}
|
||||
|
||||
// 1. Time bounds validation
|
||||
const created = parseInt(params.get("created") || "0", 10);
|
||||
const expires = parseInt(params.get("expires") || "0", 10);
|
||||
|
||||
if (!created || !expires) {
|
||||
throw new Error("Missing created or expires timestamp");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(now - created) > 30) {
|
||||
throw new Error("Signature created timestamp out of drift bounds");
|
||||
}
|
||||
if (now > expires) {
|
||||
throw new Error("Signature expired");
|
||||
}
|
||||
|
||||
// 2. Parse HWK
|
||||
const hwkBase64 = params.get("hwk");
|
||||
if (!hwkBase64) {
|
||||
throw new Error("Missing inline hwk parameter");
|
||||
}
|
||||
|
||||
let hwk;
|
||||
try {
|
||||
const hwkJson = atob(hwkBase64.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
hwk = JSON.parse(hwkJson);
|
||||
} catch (_err) {
|
||||
throw new Error("Invalid base64url or JSON in hwk parameter");
|
||||
}
|
||||
|
||||
if (hwk.kty !== "OKP" || hwk.crv !== "Ed25519" || !hwk.x) {
|
||||
throw new Error("Only Ed25519 OKP keys are supported");
|
||||
}
|
||||
|
||||
const fingerprint = await computeJwkThumbprint(hwk);
|
||||
|
||||
// 3. Check Valkey Authorization First
|
||||
try {
|
||||
const isMember = await valkey.sismember(
|
||||
"auth:hwk:fingerprints",
|
||||
fingerprint,
|
||||
);
|
||||
if (isMember !== 1) {
|
||||
throw new Error("Fingerprint not authorized");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("Fingerprint not authorized")) {
|
||||
throw err;
|
||||
}
|
||||
console.error("Valkey error during signature verification:", err);
|
||||
throw new Error("Internal server error");
|
||||
}
|
||||
|
||||
// 4. Build Canonical Signature Base
|
||||
const url = new URL(req.url);
|
||||
let signatureBase = "";
|
||||
|
||||
const xForwardedMethod = req.headers.get("X-Forwarded-Method");
|
||||
const xForwardedUri = req.headers.get("X-Forwarded-Uri");
|
||||
const xForwardedHost = req.headers.get("X-Forwarded-Host");
|
||||
|
||||
const originalMethod = xForwardedMethod || req.method;
|
||||
const originalPath = xForwardedUri
|
||||
? new URL(xForwardedUri, "http://localhost").pathname +
|
||||
new URL(xForwardedUri, "http://localhost").search
|
||||
: url.pathname + url.search;
|
||||
const originalHost = xForwardedHost || req.headers.get("host") || url.host;
|
||||
|
||||
for (const comp of components) {
|
||||
if (comp === "@method") {
|
||||
signatureBase += `"@method": ${originalMethod.toLowerCase()}\n`;
|
||||
} else if (comp === "@path") {
|
||||
signatureBase += `"@path": ${originalPath}\n`;
|
||||
} else if (comp === "@authority") {
|
||||
signatureBase += `"@authority": ${originalHost}\n`;
|
||||
} else {
|
||||
const headerVal = req.headers.get(comp);
|
||||
if (headerVal === null) {
|
||||
throw new Error(`Missing required signature component: ${comp}`);
|
||||
}
|
||||
signatureBase += `"${comp}": ${headerVal}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
signatureBase += `"@signature-params": (${componentsStr})${paramsStr}`;
|
||||
|
||||
// 5. Verify the signature
|
||||
const sigValueMatch = signature.match(/sig1=:([^:]+):/);
|
||||
if (!sigValueMatch) {
|
||||
throw new Error("Invalid Signature header format");
|
||||
}
|
||||
|
||||
const rawSigValue = sigValueMatch[1];
|
||||
let sigBytes;
|
||||
try {
|
||||
const sigStr = atob(rawSigValue.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
sigBytes = new Uint8Array(sigStr.length);
|
||||
for (let i = 0; i < sigStr.length; i++) {
|
||||
sigBytes[i] = sigStr.charCodeAt(i);
|
||||
}
|
||||
} catch {
|
||||
throw new Error("Invalid base64 encoding in signature");
|
||||
}
|
||||
|
||||
const importedKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
hwk,
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
const msgBytes = new TextEncoder().encode(signatureBase);
|
||||
const isValid = await crypto.subtle.verify(
|
||||
"Ed25519",
|
||||
importedKey,
|
||||
sigBytes,
|
||||
msgBytes,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("Invalid cryptographic signature");
|
||||
}
|
||||
|
||||
return fingerprint;
|
||||
}
|
||||
171
src/core/rpc.ts
Normal file
171
src/core/rpc.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { AuthService } from "../../sdk/gen/auth_connect.ts";
|
||||
import {
|
||||
universalServerRequestFromFetch,
|
||||
universalServerResponseToFetch,
|
||||
} from "npm:@connectrpc/connect@^1.4.0/protocol";
|
||||
import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
|
||||
import { createConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
|
||||
import type { Hono } from "jsr:@hono/hono@4";
|
||||
|
||||
import { spireWrapper } from "./spire_ffi.ts";
|
||||
import { sqlWrapper } from "./db.ts";
|
||||
import { valkey } from "./valkey.ts";
|
||||
import { auditWrapper } from "./audit.ts";
|
||||
|
||||
export const connectRoutes = (router: ConnectRouter) => {
|
||||
router.service(AuthService, {
|
||||
async validateSession(req, context) {
|
||||
try {
|
||||
const spiffeId = spireWrapper.extractSpiffeIdFromCert(
|
||||
context.requestHeader.get("x-peer-cert") || "",
|
||||
);
|
||||
|
||||
if (!spiffeId) {
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Validation failed",
|
||||
};
|
||||
}
|
||||
|
||||
const appRecord = await sqlWrapper
|
||||
.sql`SELECT id FROM apps WHERE spiffe_id = ${spiffeId}`.then((
|
||||
res: any,
|
||||
) => res[0]);
|
||||
|
||||
if (!appRecord) {
|
||||
auditWrapper.auditLog(null, "session_validation_failed", null, {
|
||||
reason: "Unauthorized SPIFFE ID",
|
||||
}, "internal-grpc");
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Validation failed",
|
||||
};
|
||||
}
|
||||
|
||||
const token = req.token;
|
||||
if (!token) {
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Validation failed",
|
||||
};
|
||||
}
|
||||
|
||||
let sessionDataStr;
|
||||
try {
|
||||
sessionDataStr = await valkey.get(token);
|
||||
} catch (err: unknown) {
|
||||
console.error("Valkey error during validateSession:", err);
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Internal server error",
|
||||
};
|
||||
}
|
||||
|
||||
if (!sessionDataStr) {
|
||||
auditWrapper.auditLog(
|
||||
null,
|
||||
"session_validation_failed",
|
||||
appRecord.id,
|
||||
{
|
||||
reason: "Session invalid or expired",
|
||||
},
|
||||
"internal-grpc",
|
||||
);
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Validation failed",
|
||||
};
|
||||
}
|
||||
|
||||
let sessionData;
|
||||
try {
|
||||
sessionData = JSON.parse(sessionDataStr);
|
||||
} catch (err: unknown) {
|
||||
console.error("JSON parse error during validateSession:", err);
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Internal server error",
|
||||
};
|
||||
}
|
||||
|
||||
if (!sessionData || !sessionData.uuid) {
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Internal server error",
|
||||
};
|
||||
}
|
||||
|
||||
const userId = sessionData.uuid;
|
||||
|
||||
const grantRecord = await sqlWrapper
|
||||
.sql`SELECT role FROM user_grants WHERE user_id = ${userId} AND app_id = ${appRecord.id}`
|
||||
.then((res: any) => res[0]);
|
||||
|
||||
if (!grantRecord) {
|
||||
auditWrapper.auditLog(
|
||||
userId,
|
||||
"session_validation_failed",
|
||||
appRecord.id,
|
||||
{
|
||||
reason: "Access denied (RBAC)",
|
||||
},
|
||||
"internal-grpc",
|
||||
);
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Validation failed",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
uuid: userId,
|
||||
scopes: [grantRecord.role],
|
||||
error: "",
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
console.error("Unexpected error in validateSession:", err);
|
||||
return {
|
||||
valid: false,
|
||||
uuid: "",
|
||||
scopes: [],
|
||||
error: "Internal server error",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const startConnectRpcServer = (app: Hono) => {
|
||||
const router = createConnectRouter();
|
||||
connectRoutes(router);
|
||||
const handlers = router.handlers;
|
||||
|
||||
app.all("/auth.v1.AuthService/*", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const handler = handlers.find((h) => h.requestPath === url.pathname);
|
||||
if (!handler) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const uReq = universalServerRequestFromFetch(c.req.raw, {});
|
||||
const uRes = await handler(uReq);
|
||||
return universalServerResponseToFetch(uRes);
|
||||
});
|
||||
};
|
||||
@ -231,3 +231,44 @@ export async function requireAdmin(c: Context, next: () => Promise<void>) {
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves application record by domain with Valkey caching.
|
||||
*/
|
||||
export async function getAppByHost(host: string): Promise<AppRecord | null> {
|
||||
const cacheKey = `auth:app_by_host:${host}`;
|
||||
try {
|
||||
const cached = await valkey.get(cacheKey);
|
||||
if (cached) return JSON.parse(cached);
|
||||
} catch (_e) {}
|
||||
|
||||
try {
|
||||
const app = await sqlWrapper.sql`
|
||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
||||
FROM apps WHERE domain = ${host} LIMIT 1
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (app) {
|
||||
await valkey.setex(cacheKey, 300, JSON.stringify(app)).catch(() => {});
|
||||
return app;
|
||||
}
|
||||
} catch (_e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves role grant for a user on a given app.
|
||||
*/
|
||||
export async function getUserGrant(
|
||||
userId: string,
|
||||
appId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const grant = await sqlWrapper.sql`
|
||||
SELECT role FROM user_grants WHERE user_id = ${userId} AND app_id = ${appId} LIMIT 1
|
||||
`.then((res: any) => res[0]);
|
||||
return grant?.role || null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
31
src/features/forward_auth/forward_auth.test.ts
Normal file
31
src/features/forward_auth/forward_auth.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { assertEquals } from "jsr:@std/assert@1";
|
||||
import app from "../../main.ts";
|
||||
import {
|
||||
isIpAllowed,
|
||||
isPathBypassed,
|
||||
isSafeRedirectUrl,
|
||||
} from "../../core/forward_auth.ts";
|
||||
|
||||
Deno.test("[ForwardAuth] GET /api/forward-auth without host returns 400", async () => {
|
||||
const res = await app.fetch(new Request("http://localhost/api/forward-auth"));
|
||||
assertEquals(res.status, 400);
|
||||
});
|
||||
|
||||
Deno.test("[ForwardAuth] Helpers: isPathBypassed correctly matches prefixes", () => {
|
||||
assertEquals(isPathBypassed("/public/styles.css", ["/public/*"]), true);
|
||||
assertEquals(isPathBypassed("/api/healthz", ["/api/healthz"]), true);
|
||||
assertEquals(isPathBypassed("/admin", ["/public/*"]), false);
|
||||
});
|
||||
|
||||
Deno.test("[ForwardAuth] Helpers: isIpAllowed handles IPv4 CIDR matching", () => {
|
||||
assertEquals(isIpAllowed("192.168.1.50", ["192.168.1.0/24"]), true);
|
||||
assertEquals(isIpAllowed("10.0.0.1", ["192.168.1.0/24"]), false);
|
||||
assertEquals(isIpAllowed("127.0.0.1", ["127.0.0.1"]), true);
|
||||
});
|
||||
|
||||
Deno.test("[ForwardAuth] Helpers: isSafeRedirectUrl validates target destinations", () => {
|
||||
assertEquals(isSafeRedirectUrl("/dashboard"), true);
|
||||
assertEquals(isSafeRedirectUrl("https://auth.atyg.org/login"), true);
|
||||
assertEquals(isSafeRedirectUrl("https://evil.com"), false);
|
||||
assertEquals(isSafeRedirectUrl("//evil.com"), false);
|
||||
});
|
||||
164
src/features/forward_auth/routes.ts
Normal file
164
src/features/forward_auth/routes.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { sqlWrapper } from "../../core/db.ts";
|
||||
import {
|
||||
getAppByHost,
|
||||
getAuthenticatedUser,
|
||||
getUserGrant,
|
||||
isGlobalAdmin,
|
||||
} from "../../core/session.ts";
|
||||
import { isIpAllowed, isPathBypassed } from "../../core/forward_auth.ts";
|
||||
import { verifyHttpSignature } from "../../core/http_signatures.ts";
|
||||
|
||||
export const forwardAuthRoutes = new Hono();
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// ForwardAuth Ingress Check (Traefik Ingress Middleware)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
forwardAuthRoutes.get("/api/forward-auth", async (c) => {
|
||||
const host = c.req.header("X-Forwarded-Host");
|
||||
if (!host) {
|
||||
return c.text("Bad Request: Missing X-Forwarded-Host header", 400);
|
||||
}
|
||||
|
||||
// 1. Resolve Target App (Valkey -> DB)
|
||||
const appRecord = await getAppByHost(host);
|
||||
if (!appRecord) {
|
||||
const accept = c.req.header("Accept") || "";
|
||||
if (accept.includes("text/html")) {
|
||||
const rpID = Deno.env.get("RP_ID");
|
||||
const loginDomain = rpID || "auth.atyg.org";
|
||||
c.header(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate, max-age=0",
|
||||
);
|
||||
return c.redirect(
|
||||
`https://${loginDomain}/errors/unregistered?host=${
|
||||
encodeURIComponent(host)
|
||||
}`,
|
||||
302,
|
||||
);
|
||||
}
|
||||
return c.json({ error: "Application not registered" }, 403);
|
||||
}
|
||||
|
||||
// 1.5 Dynamic Bypass Check
|
||||
const uri = c.req.header("X-Forwarded-Uri") || "/";
|
||||
const clientIp = c.req.header("X-Forwarded-For") || "127.0.0.1";
|
||||
const requestPath = new URL(uri, `http://${host}`).pathname;
|
||||
|
||||
if (
|
||||
appRecord.is_public === true ||
|
||||
isPathBypassed(requestPath, appRecord.bypass_paths) ||
|
||||
isIpAllowed(clientIp, appRecord.allowed_cidrs)
|
||||
) {
|
||||
c.header("X-Forwarded-App-Id", appRecord.id);
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
|
||||
// 2. Validate Session OR HTTP Signature
|
||||
const signatureInput = c.req.header("Signature-Input");
|
||||
const signature = c.req.header("Signature");
|
||||
|
||||
if (signatureInput && signature) {
|
||||
try {
|
||||
const fingerprint = await verifyHttpSignature(c.req.raw);
|
||||
|
||||
const serviceName = `service-node:${fingerprint.substring(0, 8)}`;
|
||||
const serviceId = fingerprint;
|
||||
const scopes = "edge-node,daemon";
|
||||
|
||||
c.header("X-Forwarded-User", serviceName);
|
||||
c.header("X-Forwarded-User-Id", serviceId);
|
||||
c.header("X-Forwarded-Scopes", scopes);
|
||||
c.header("X-Forwarded-App-Id", appRecord.id);
|
||||
|
||||
return c.text("OK", 200);
|
||||
} catch (err: any) {
|
||||
return c.text(`Unauthorized: ${err.message}`, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Standard User Session Path
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
const accept = c.req.header("Accept") || "";
|
||||
const proto = c.req.header("X-Forwarded-Proto") || "https";
|
||||
const uri = c.req.header("X-Forwarded-Uri") || "/";
|
||||
const originalUrl = `${proto}://${host}${uri}`;
|
||||
|
||||
if (accept.includes("text/html")) {
|
||||
const rpID = Deno.env.get("RP_ID");
|
||||
const loginDomain = rpID || "auth.atyg.org";
|
||||
c.header(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate, max-age=0",
|
||||
);
|
||||
return c.redirect(
|
||||
`https://${loginDomain}/login?redirect=${
|
||||
encodeURIComponent(originalUrl)
|
||||
}`,
|
||||
302,
|
||||
);
|
||||
}
|
||||
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
if (auth.isPaused) {
|
||||
return c.text("Forbidden: Session Paused by Host", 403);
|
||||
}
|
||||
|
||||
const user = await sqlWrapper.sql`
|
||||
SELECT id, username, account_status
|
||||
FROM users
|
||||
WHERE id = ${auth.userId}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (
|
||||
!user ||
|
||||
(user.account_status !== "active" && user.account_status !== "guest")
|
||||
) {
|
||||
return c.text("Forbidden: Account inactive", 403);
|
||||
}
|
||||
|
||||
// 3. Resolve Grants and Roles
|
||||
let scopes = "";
|
||||
|
||||
if (
|
||||
user.account_status === "guest" ||
|
||||
(auth.customScopes && auth.customScopes.length > 0)
|
||||
) {
|
||||
const customScopes = auth.customScopes || [];
|
||||
const hasAppScope = customScopes.includes(`app:${appRecord.name}`) ||
|
||||
customScopes.includes("*");
|
||||
|
||||
if (!hasAppScope) {
|
||||
return c.text(
|
||||
"Forbidden: Access denied to this application (Guest/Delegated)",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
scopes = customScopes.filter(Boolean).join(",") || "viewer";
|
||||
} else {
|
||||
const globalAdmin = await isGlobalAdmin(auth.userId);
|
||||
const grantRole = await getUserGrant(auth.userId, appRecord.id);
|
||||
|
||||
if (!globalAdmin && !grantRole) {
|
||||
return c.text("Forbidden: Access denied to this application", 403);
|
||||
}
|
||||
|
||||
scopes = [
|
||||
...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)),
|
||||
].join(",");
|
||||
}
|
||||
|
||||
// 4. Inject Headers
|
||||
c.header("X-Forwarded-User", user.username);
|
||||
c.header("X-Forwarded-User-Id", user.id);
|
||||
c.header("X-Forwarded-Scopes", scopes);
|
||||
c.header("X-Forwarded-App-Id", appRecord.id);
|
||||
|
||||
return c.text("OK", 200);
|
||||
});
|
||||
13
src/features/sessions/pass_routes.test.ts
Normal file
13
src/features/sessions/pass_routes.test.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { assertEquals } from "jsr:@std/assert@1";
|
||||
import app from "../../main.ts";
|
||||
|
||||
Deno.test("[Passes] GET /pass without token redirects to /login?error=invalid_or_expired_pass", async () => {
|
||||
const res = await app.fetch(new Request("http://localhost/pass"));
|
||||
assertEquals(res.status, 302);
|
||||
assertEquals(
|
||||
res.headers.get("location")?.includes(
|
||||
"/login?error=invalid_or_expired_pass",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
129
src/features/sessions/pass_routes.ts
Normal file
129
src/features/sessions/pass_routes.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { valkey } from "../../core/valkey.ts";
|
||||
import { sqlWrapper } from "../../core/db.ts";
|
||||
import { getCookieDomain } from "../../core/session.ts";
|
||||
|
||||
export const passRoutes = new Hono();
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Ephemeral 1-Click Magic Link Redemption (/pass)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
passRoutes.get("/", async (c) => {
|
||||
const token = c.req.query("token");
|
||||
if (!token) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
|
||||
// 1. Validate against Valkey, fallback to PostgreSQL
|
||||
let sessionDataStr = null;
|
||||
try {
|
||||
sessionDataStr = await valkey.get(token);
|
||||
} catch (_err) {}
|
||||
|
||||
let sessionInfo: any = null;
|
||||
if (sessionDataStr) {
|
||||
try {
|
||||
sessionInfo = JSON.parse(sessionDataStr);
|
||||
} catch (_err) {}
|
||||
}
|
||||
|
||||
let expiresAtDate: Date | null = null;
|
||||
let customScopes: string[] = [];
|
||||
|
||||
if (!sessionInfo || !sessionInfo.uuid) {
|
||||
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 = ${token} AND s.expires_at > ${nowIso}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (!session) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
|
||||
sessionInfo = {
|
||||
uuid: session.user_id,
|
||||
username: session.username,
|
||||
label: session.label,
|
||||
isAgent: session.is_agent,
|
||||
customScopes: session.custom_scopes,
|
||||
};
|
||||
expiresAtDate = new Date(session.expires_at);
|
||||
customScopes = session.custom_scopes || [];
|
||||
|
||||
try {
|
||||
const ttlSeconds = Math.max(
|
||||
1,
|
||||
Math.floor((expiresAtDate.getTime() - Date.now()) / 1000),
|
||||
);
|
||||
await valkey.setex(token, ttlSeconds, JSON.stringify(sessionInfo));
|
||||
} catch (_e) {}
|
||||
} catch (_err) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const ttl = await valkey.ttl(token);
|
||||
if (ttl <= 0) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
expiresAtDate = new Date(Date.now() + ttl * 1000);
|
||||
customScopes = sessionInfo.customScopes || sessionInfo.custom_scopes ||
|
||||
[];
|
||||
} catch (_err) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionInfo || !expiresAtDate) {
|
||||
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
||||
}
|
||||
|
||||
// 2. Cookie Scoping
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
|
||||
const rpID = Deno.env.get("RP_ID");
|
||||
const cookieDomain = getCookieDomain(rpID);
|
||||
const ttlSeconds = Math.max(
|
||||
1,
|
||||
Math.floor((expiresAtDate.getTime() - Date.now()) / 1000),
|
||||
);
|
||||
setCookie(c, "session_id", token, {
|
||||
path: "/",
|
||||
domain: cookieDomain,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
maxAge: ttlSeconds,
|
||||
});
|
||||
|
||||
// 3. Redirect URL Resolution
|
||||
let targetDomain = null;
|
||||
if (Array.isArray(customScopes)) {
|
||||
const appScope = customScopes.find((s: string) =>
|
||||
typeof s === "string" && s.startsWith("app:")
|
||||
);
|
||||
if (appScope) {
|
||||
const appName = appScope.substring(4);
|
||||
try {
|
||||
const appRecord = await sqlWrapper.sql`
|
||||
SELECT domain FROM apps WHERE name = ${appName}
|
||||
`.then((res: any) => res[0]);
|
||||
if (appRecord && appRecord.domain) {
|
||||
targetDomain = appRecord.domain;
|
||||
}
|
||||
} catch (_err) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDomain) {
|
||||
return c.redirect(`https://${targetDomain}`, 302);
|
||||
} else {
|
||||
return c.redirect("/dashboard", 302);
|
||||
}
|
||||
});
|
||||
37
src/main.ts
37
src/main.ts
@ -1,14 +1,19 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { serveStatic } from "jsr:@hono/hono@4/deno";
|
||||
import { MetadataService } from "jsr:@simplewebauthn/server@13";
|
||||
|
||||
import { initDb } from "./core/db.ts";
|
||||
import { pingValkey } from "./core/valkey.ts";
|
||||
import { contentNegotiation } from "./core/content_negotiation.ts";
|
||||
import { payloadCapGuard } from "./core/auth_guards.ts";
|
||||
import { startConnectRpcServer } from "./core/rpc.ts";
|
||||
|
||||
import { authRoutes } from "./features/auth/routes.tsx";
|
||||
import { adminRoutes } from "./features/admin/routes.tsx";
|
||||
import { eventsRoutes } from "./features/events/routes.tsx";
|
||||
import { sessionRoutes } from "./features/sessions/routes.tsx";
|
||||
import { passRoutes } from "./features/sessions/pass_routes.ts";
|
||||
import { forwardAuthRoutes } from "./features/forward_auth/routes.ts";
|
||||
|
||||
const app: Hono = new Hono();
|
||||
|
||||
@ -18,22 +23,48 @@ app.use("*", contentNegotiation());
|
||||
// Serve static assets (specifically Datastar and client scripts)
|
||||
app.use("/public/*", serveStatic({ root: "./" }));
|
||||
|
||||
// Wire Feature Slices
|
||||
// Wire Sub-routers and Vertical Slices
|
||||
app.route("/pass", passRoutes);
|
||||
app.route("/", forwardAuthRoutes);
|
||||
app.route("/", authRoutes);
|
||||
app.route("/", eventsRoutes);
|
||||
app.route("/", sessionRoutes);
|
||||
app.route("/admin", adminRoutes);
|
||||
app.route("/api/admin", adminRoutes);
|
||||
|
||||
// Basic health check for foundation
|
||||
// Workload Mesh ConnectRPC Daemon
|
||||
startConnectRpcServer(app);
|
||||
|
||||
// Basic health check
|
||||
app.get("/healthz", (c) => c.text("OK"));
|
||||
|
||||
if (import.meta.main) {
|
||||
console.log("[Auth-Yes Next] Bootstrapping core foundation...");
|
||||
const rpID = Deno.env.get("RP_ID");
|
||||
const origin = Deno.env.get("ORIGIN");
|
||||
|
||||
if (!rpID || !origin) {
|
||||
console.warn(
|
||||
"[Auth-Yes] Warning: RP_ID or ORIGIN not set in environment. Falling back to defaults.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Auth-Yes] Initializing FIDO MDS3 Metadata Blob...");
|
||||
try {
|
||||
await MetadataService.initialize();
|
||||
console.log("[Auth-Yes] FIDO MDS3 Metadata Blob successfully loaded.");
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[Auth-Yes] Failed to initialize FIDO MDS3 Metadata Blob (offline mode):",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Auth-Yes] Initializing database and cache...");
|
||||
await initDb();
|
||||
await pingValkey();
|
||||
|
||||
const port = parseInt(Deno.env.get("PORT") || "8000", 10);
|
||||
console.log(`[Auth-Yes] Hypermedia Server running on port ${port}`);
|
||||
Deno.serve({ port }, app.fetch);
|
||||
}
|
||||
|
||||
|
||||
@ -3,27 +3,42 @@
|
||||
## 1. Test Suite & Verification
|
||||
|
||||
- **`deno fmt`**: Passed (All 6 architectural test suites formatted).
|
||||
- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed with 0 errors; all files $\le 72$ lines, zero banned DOM API violations).
|
||||
- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`, `ui/`, `infra/`, `src/`).
|
||||
- **`deno test -A --no-check`**: Passed (90 tests across 30 steps with 0 failures).
|
||||
- **`deno task lint`**: Passed (`deno lint` and `scripts/lint_arch.ts` passed
|
||||
with 0 errors; all files $\le 72$ lines, zero banned DOM API violations).
|
||||
- **`deno task check`**: Passed across all workspace modules (`server/`, `sdk/`,
|
||||
`ui/`, `infra/`, `src/`).
|
||||
- **`deno test -A --no-check`**: Passed (90 tests across 30 steps with 0
|
||||
failures).
|
||||
|
||||
## 2. Scope Implemented & Verified
|
||||
|
||||
1. **Transport Efficiency & Latency (`src/tests/arch/transport_efficiency.test.ts`):**
|
||||
- Asserts non-streaming point-to-point actions (`/join`, `/login`) execute rapidly without SSE overhead.
|
||||
- Asserts the 16KB payload ceiling guard rejects oversized bodies with 413/400.
|
||||
2. **SSE Stream Lifecycle & Leak Teardown (`src/tests/arch/sse_lifecycle.test.ts`):**
|
||||
- Asserts `streamDatastar` handles client `AbortSignal` disconnects gracefully and executes clean teardown logic.
|
||||
1. **Transport Efficiency & Latency
|
||||
(`src/tests/arch/transport_efficiency.test.ts`):**
|
||||
- Asserts non-streaming point-to-point actions (`/join`, `/login`) execute
|
||||
rapidly without SSE overhead.
|
||||
- Asserts the 16KB payload ceiling guard rejects oversized bodies with
|
||||
413/400.
|
||||
2. **SSE Stream Lifecycle & Leak Teardown
|
||||
(`src/tests/arch/sse_lifecycle.test.ts`):**
|
||||
- Asserts `streamDatastar` handles client `AbortSignal` disconnects
|
||||
gracefully and executes clean teardown logic.
|
||||
3. **Proxy Buffering Invariant (`src/tests/arch/proxy_buffering.test.ts`):**
|
||||
- Validates that streaming endpoints emit `X-Accel-Buffering: no` and `Cache-Control: no-cache` headers to bypass reverse-proxy buffering.
|
||||
- Validates that streaming endpoints emit `X-Accel-Buffering: no` and
|
||||
`Cache-Control: no-cache` headers to bypass reverse-proxy buffering.
|
||||
4. **Error Fragment Morph Invariant (`src/tests/arch/error_fragment.test.ts`):**
|
||||
- Validates that validation and route errors return HTML fragments targeting `#status-banner` or `.field-error`.
|
||||
- Validates that validation and route errors return HTML fragments targeting
|
||||
`#status-banner` or `.field-error`.
|
||||
5. **XSS & Escape Fuzzing Harness (`src/tests/arch/xss_fuzzing.test.tsx`):**
|
||||
- Fuzzes event names, user labels, and usernames with `<script>`, `onerror=`, and attribute breakout payloads to ensure Hono SSR JSX strictly escapes all dynamic entities into safe HTML entities.
|
||||
6. **Dual-Mode Content Negotiation (`src/tests/arch/content_negotiation.test.ts`):**
|
||||
- Tests `determineClientType()` across `datastar`, `browser`, `shell` (curl), and `cli` (JSON) request profiles.
|
||||
- Fuzzes event names, user labels, and usernames with `<script>`, `onerror=`,
|
||||
and attribute breakout payloads to ensure Hono SSR JSX strictly escapes all
|
||||
dynamic entities into safe HTML entities.
|
||||
6. **Dual-Mode Content Negotiation
|
||||
(`src/tests/arch/content_negotiation.test.ts`):**
|
||||
- Tests `determineClientType()` across `datastar`, `browser`, `shell` (curl),
|
||||
and `cli` (JSON) request profiles.
|
||||
7. **Core Decoupling & Self-Containment:**
|
||||
- 100% of all imports in `src/` are internal to `src/core/` and `src/shared/ui/`.
|
||||
- 100% of all imports in `src/` are internal to `src/core/` and
|
||||
`src/shared/ui/`.
|
||||
- Zero legacy `server/` imports remain.
|
||||
|
||||
## 3. Decision
|
||||
|
||||
53
tasks/cleanup-legacy-directories.md
Normal file
53
tasks/cleanup-legacy-directories.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Task: Legacy Directory Cleanup & Workspace Pruning (Phase 5B)
|
||||
|
||||
- **Status:** PENDING / QUEUED
|
||||
- **Trigger:** Execute after runtime stability and smoke tests on `src/main.ts`
|
||||
have been verified in staging/production.
|
||||
- **Estimated Effort:** 10 minutes (Zero functional risk once `src/` is running
|
||||
standalone).
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & Objectives
|
||||
|
||||
The hypermedia architecture transition has rebuilt all features (`auth`,
|
||||
`admin`, `events`, `sessions`, `forward_auth`) into vertical slices inside
|
||||
`src/`. `src/` is 100% self-contained and has zero dependencies on legacy code.
|
||||
|
||||
This task removes the legacy `server/` and `ui/` directories and prunes
|
||||
workspace entries from `deno.json`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Step-by-Step Execution Plan
|
||||
|
||||
### Step 1: Remove Legacy Directories
|
||||
|
||||
```bash
|
||||
git rm -rf server/ ui/
|
||||
```
|
||||
|
||||
### Step 2: Prune `deno.json` Workspace
|
||||
|
||||
In root `deno.json`:
|
||||
|
||||
- Remove `"./server"` and `"./ui"` from `"workspace"`.
|
||||
- Keep `"./sdk"` and `"./src"`.
|
||||
- In `"check"` task, update paths to:
|
||||
`deno check sdk/**/*.ts infra/**/*.ts src/**/*.ts src/**/*.tsx`.
|
||||
|
||||
### Step 3: Verify All Quality Gates
|
||||
|
||||
```bash
|
||||
deno fmt
|
||||
deno task lint
|
||||
deno task check
|
||||
deno test -A --no-check
|
||||
```
|
||||
|
||||
### Step 4: Commit & Ship
|
||||
|
||||
```bash
|
||||
git commit -m "chore(cleanup): remove legacy server/ and ui/ directories"
|
||||
git push origin <branch-name>
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user