feat(backend): enforce user-owned sessions

This commit is contained in:
User
2026-07-16 18:32:52 +02:00
parent 72db6b823d
commit 458eb13c89
15 changed files with 626 additions and 105 deletions
+14 -9
View File
@@ -1,23 +1,28 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
declare module "fastify" {
interface FastifyRequest { principal?: PrincipalContext }
}
export function authPreHandler(mode: "none" | "mock" | "upstream") {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (mode === "none") {
(req as any).user = { id: "dev@local" };
req.principal = localPrincipal();
} else if (mode === "mock") {
(req as any).user = {
id: (req.headers["x-mock-user"] as string) ?? "mock",
};
const subject = typeof req.headers["x-mock-user"] === "string" ? req.headers["x-mock-user"].trim() : "mock";
req.principal = { issuer: "mock", subject: subject || "mock", displayName: subject || "mock", isAdmin: false };
} else {
const id = req.headers["x-authenticated-user"];
if (typeof id !== "string" || id.trim() === "") {
const principal = upstreamPrincipal(req.headers);
if (!principal) {
return reply.code(401).send({ error: "authenticated upstream identity required" });
}
(req as any).user = { id };
req.principal = principal;
}
};
}
export function getUser(req: FastifyRequest): { id: string } {
return (req as any).user ?? { id: "dev@local" };
export function getPrincipal(req: FastifyRequest): PrincipalContext {
if (!req.principal) throw new Error("principal missing after authentication");
return req.principal;
}
+68
View File
@@ -0,0 +1,68 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
export interface PrincipalContext {
issuer: string;
subject: string;
displayName?: string;
isAdmin: boolean;
}
const invalid = (value: string) => value.length === 0 || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value);
function required(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
return invalid(normalized) ? undefined : normalized;
}
function optional(value: unknown): string | undefined {
if (value === undefined) return undefined;
return required(value);
}
export function upstreamPrincipal(headers: Record<string, unknown>): PrincipalContext | undefined {
const issuer = required(headers["x-thoth-principal-issuer"]);
const subject = required(headers["x-thoth-principal-subject"]);
const displayName = optional(headers["x-thoth-principal-display-name"]);
const adminHeader = headers["x-thoth-is-admin"];
if (!issuer || !subject || (headers["x-thoth-principal-display-name"] !== undefined && !displayName)) return undefined;
if (adminHeader !== "0" && adminHeader !== "1" && adminHeader !== "true" && adminHeader !== "false") return undefined;
return { issuer, subject, displayName, isAdmin: adminHeader === "1" || adminHeader === "true" };
}
export function localPrincipal(): PrincipalContext {
const home = process.env.THT_HOME ?? join(homedir(), ".thothii");
const identityPath = join(home, "identity.json");
mkdirSync(home, { recursive: true, mode: 0o700 });
try {
const stored = JSON.parse(readFileSync(identityPath, "utf8"));
if (stored?.issuer === "local" && typeof stored.subject === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(stored.subject)) {
return { issuer: "local", subject: stored.subject, isAdmin: false };
}
throw new Error("invalid local identity");
} catch (error: any) {
if (error?.code !== "ENOENT") throw error;
const principal = { issuer: "local", subject: randomUUID() };
try {
writeFileSync(identityPath, JSON.stringify(principal) + "\n", { mode: 0o600, flag: "wx" });
return { ...principal, isAdmin: false };
} catch (writeError: any) {
// Another local request won the identity creation race; always converge on its UUID.
if (writeError?.code === "EEXIST") return localPrincipal();
throw writeError;
}
}
}
export function principalEnvironment(principal: PrincipalContext): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
THT_PRINCIPAL_ISSUER: principal.issuer,
THT_PRINCIPAL_SUBJECT: principal.subject,
THT_PRINCIPAL_IS_ADMIN: principal.isAdmin ? "true" : "false",
};
if (principal.displayName) env.THT_PRINCIPAL_DISPLAY_NAME = principal.displayName;
return env;
}