feat(auth): add local login and CSRF-protected sessions
This commit is contained in:
+53
-15
@@ -1,14 +1,18 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import { join } from "node:path";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { ThtRunner } from "./tht/tht-runner.js";
|
||||
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
||||
import { SseHub } from "./sse/sse-hub.js";
|
||||
import { authPreHandler } from "./auth/auth.js";
|
||||
import { requirePermission } from "./auth/authorization.js";
|
||||
import { authenticateSession } from "./auth/auth.js";
|
||||
import type { PrincipalContext } from "./auth/principal.js";
|
||||
import { createLocalUserRegistry, type LocalUserRegistry } from "./auth/local-registry.js";
|
||||
import { createFileAuthSessionStore, type AuthSessionStore } from "./auth/session-store.js";
|
||||
import { registerAuthRoutes } from "./auth/routes.js";
|
||||
import { sessionRoutes } from "./routes/sessions.js";
|
||||
import { sqlRoutes } from "./routes/sql.js";
|
||||
import { metaRoutes, type ListModelsFn } from "./routes/meta.js";
|
||||
@@ -41,6 +45,12 @@ export interface BuildAppDeps {
|
||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||
maintenanceBarrier?: MaintenanceBarrier;
|
||||
piManagement?: PiManagementService;
|
||||
localUserRegistry?: LocalUserRegistry;
|
||||
authSessionStore?: AuthSessionStore;
|
||||
}
|
||||
|
||||
export interface AppWithAuthSessionStore extends FastifyInstance {
|
||||
thothiiAuthSessionStore?: AuthSessionStore;
|
||||
}
|
||||
|
||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||
@@ -60,8 +70,11 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
});
|
||||
// Cookie parsing and the rate-limit plugin must precede every auth/application route.
|
||||
app.register(cookie);
|
||||
app.register(rateLimit, { global: false });
|
||||
|
||||
const tht = deps?.thtRunner ?? new ThtRunner({
|
||||
thtBin: config.thtBin,
|
||||
@@ -137,21 +150,41 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
|
||||
|
||||
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
|
||||
if (config.authMode === "local" || config.authMode === "oidc") {
|
||||
throw new Error("configured authentication mode is not implemented");
|
||||
}
|
||||
const authenticate = authPreHandler(config.authMode, config.publicExposure);
|
||||
app.addHook("preHandler", async (req, reply) => {
|
||||
// Process readiness is intentionally unauthenticated for local container/proxy probes.
|
||||
if (req.url === "/health" || req.url === "/health/dwh") return;
|
||||
const loadedAuthentication = config.authentication?.current();
|
||||
const configuredLocalRegistry = loadedAuthentication?.value.mode === "local"
|
||||
? createLocalUserRegistry(join(dirname(loadedAuthentication.sourcePath), loadedAuthentication.value.local.usersFile))
|
||||
: undefined;
|
||||
const localUserRegistry = deps?.localUserRegistry ?? configuredLocalRegistry;
|
||||
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
|
||||
? createFileAuthSessionStore(config.authStateRoot, {
|
||||
currentAuthConfigRevision: () => config.authentication?.current().revision ?? "",
|
||||
findLocalUser: async (subject) => {
|
||||
if (config.authentication?.current().value.mode !== "local") return undefined;
|
||||
const user = await localUserRegistry?.findBySubject(subject);
|
||||
return user === undefined ? undefined : {
|
||||
enabled: user.enabled,
|
||||
authRevision: user.authRevision,
|
||||
roles: user.roles,
|
||||
};
|
||||
},
|
||||
})
|
||||
: undefined);
|
||||
(app as AppWithAuthSessionStore).thothiiAuthSessionStore = authSessionStore;
|
||||
const authenticate = authenticateSession({
|
||||
mode: config.authMode,
|
||||
publicExposure: config.publicExposure,
|
||||
authentication: config.authentication,
|
||||
sessionStore: authSessionStore,
|
||||
});
|
||||
app.addHook("preHandler", (req, reply, done) => {
|
||||
if (isMaintenanceControl(req.url)) {
|
||||
if (!isLoopback(req.ip)) {
|
||||
return reply.code(403).send({ error: "loopback maintenance control required" });
|
||||
reply.code(403).send({ error: "loopback maintenance control required" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
return authenticate(req, reply);
|
||||
done();
|
||||
});
|
||||
app.addHook("preHandler", authenticate);
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
app.get("/health/dwh", async () => {
|
||||
// In the registry system there is no single legacy DWH config: ping the first active
|
||||
@@ -167,7 +200,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
}
|
||||
return { ok: true, detail: "workspace diagnostics own DWH reachability" };
|
||||
});
|
||||
app.get("/me", async (req, reply) => requirePermission(req, reply, "session.use"));
|
||||
registerAuthRoutes(app, {
|
||||
authMode: config.authMode,
|
||||
authentication: config.authentication,
|
||||
sessionStore: authSessionStore,
|
||||
localUserRegistry,
|
||||
});
|
||||
sessionRoutes(app, {
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
|
||||
dwhPrecheck: config.dwhPrecheck,
|
||||
|
||||
Reference in New Issue
Block a user