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,
|
||||
|
||||
+162
-2
@@ -1,9 +1,30 @@
|
||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||
import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastify";
|
||||
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
||||
import { rolesToPermissions } from "./config.js";
|
||||
import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord } from "./types.js";
|
||||
import type { AuthSessionStore } from "./session-store.js";
|
||||
import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js";
|
||||
import { requireSameOriginOrNonBrowser } from "./authorization.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest { principal?: PrincipalContext }
|
||||
interface FastifyRequest {
|
||||
principal?: PrincipalContext;
|
||||
authSession?: AuthSessionRecord;
|
||||
/** Internal only: never serialize or write this opaque cookie token to logs. */
|
||||
authSessionToken?: string;
|
||||
authPublicOrigin?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_COOKIE = "thothii_session";
|
||||
const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
export interface AuthDependencies {
|
||||
mode: AuthMode;
|
||||
publicExposure?: boolean;
|
||||
authentication?: AuthenticationConfigProvider;
|
||||
sessionStore?: AuthSessionStore;
|
||||
}
|
||||
|
||||
export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) {
|
||||
@@ -28,6 +49,145 @@ export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposur
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The one application boundary for principal resolution. Auth protocol endpoints are the only
|
||||
* public exceptions; all other routes get either a resolved principal or a sanitized denial.
|
||||
*/
|
||||
export function authenticateSession(deps: AuthDependencies): preHandlerHookHandler {
|
||||
const legacy = deps.mode === "none" || deps.mode === "mock" || deps.mode === "upstream"
|
||||
? authPreHandler(deps.mode, deps.publicExposure)
|
||||
: undefined;
|
||||
|
||||
const handle = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
if (isPublicRoute(request)) return;
|
||||
|
||||
if (legacy) {
|
||||
await legacy(request, reply);
|
||||
if (reply.sent || !STATE_CHANGING_METHODS.has(request.method)) return;
|
||||
return requireSameOriginOrNonBrowser(request, reply);
|
||||
}
|
||||
|
||||
const origin = configuredOrigin(deps.authentication);
|
||||
if (!origin || !deps.sessionStore) {
|
||||
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||
}
|
||||
const token = readSessionCookie(request);
|
||||
if (token === undefined || token === false) return authenticationRequired(reply);
|
||||
|
||||
let session: AuthSessionRecord | undefined;
|
||||
try {
|
||||
session = await deps.sessionStore.resolve(token);
|
||||
if (session) await deps.sessionStore.touch(token);
|
||||
} catch {
|
||||
return authenticationRequired(reply);
|
||||
}
|
||||
if (!session) return authenticationRequired(reply);
|
||||
|
||||
request.authSession = session;
|
||||
request.authSessionToken = token;
|
||||
request.authPublicOrigin = origin;
|
||||
request.principal = {
|
||||
issuer: session.issuer,
|
||||
subject: session.subject,
|
||||
...(session.displayName === undefined ? {} : { displayName: session.displayName }),
|
||||
roles: session.roles,
|
||||
permissions: session.permissions,
|
||||
isAdmin: session.roles.includes("admin"),
|
||||
};
|
||||
if (STATE_CHANGING_METHODS.has(request.method)) {
|
||||
requireCsrf(request, reply);
|
||||
return;
|
||||
}
|
||||
};
|
||||
return (request, reply, done) => {
|
||||
void handle(request, reply).then(
|
||||
() => done(),
|
||||
() => {
|
||||
if (!reply.sent) reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||
done();
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function requireCsrf(request: FastifyRequest, reply: FastifyReply): true | FastifyReply {
|
||||
const expectedOrigin = request.authPublicOrigin;
|
||||
const token = request.authSessionToken;
|
||||
if (!expectedOrigin || !token) return authenticationRequired(reply);
|
||||
if (!matchesOrigin(request, expectedOrigin)) return csrfFailed(reply);
|
||||
|
||||
const header = singleHeader(request.headers["x-thothii-csrf"]);
|
||||
const supplied = header === false || header === undefined || !SESSION_TOKEN.test(header) ? undefined : header;
|
||||
let expected = "";
|
||||
try {
|
||||
expected = deriveCsrfToken(token);
|
||||
} catch {
|
||||
return authenticationRequired(reply);
|
||||
}
|
||||
if (!csrfTokensEqual(expected, supplied)) return csrfFailed(reply);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Require an exact configured public origin and browser Fetch Metadata when supplied. */
|
||||
export function requireExactOrigin(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
expectedOrigin: string,
|
||||
): true | FastifyReply {
|
||||
return matchesOrigin(request, expectedOrigin) ? true : csrfFailed(reply);
|
||||
}
|
||||
|
||||
export function sessionCookieName(): string { return SESSION_COOKIE; }
|
||||
|
||||
function authenticationRequired(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(401).send({ code: "authentication_required", error: "Authentication is required" });
|
||||
}
|
||||
|
||||
function csrfFailed(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(403).send({ code: "csrf_failed", error: "Request origin validation failed" });
|
||||
}
|
||||
|
||||
function configuredOrigin(authentication: AuthenticationConfigProvider | undefined): string | undefined {
|
||||
try {
|
||||
const publicUrl = authentication?.current().value.publicUrl;
|
||||
return publicUrl ? new URL(publicUrl).origin : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionCookie(request: FastifyRequest): string | false | undefined {
|
||||
const raw = request.headers.cookie;
|
||||
if (raw === undefined) return undefined;
|
||||
if (Array.isArray(raw) || typeof raw !== "string" || raw.length > 4096) return false;
|
||||
const values = raw.split(";").filter((part) => /^\s*thothii_session(?:=|\s*$)/.test(part));
|
||||
if (values.length !== 1) return values.length === 0 ? undefined : false;
|
||||
const match = /^\s*thothii_session=([A-Za-z0-9_-]{43})\s*$/.exec(values[0]);
|
||||
return match?.[1] ?? false;
|
||||
}
|
||||
|
||||
function singleHeader(value: string | string[] | undefined): string | false | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (Array.isArray(value) || typeof value !== "string" || value.includes(",")) return false;
|
||||
return value;
|
||||
}
|
||||
|
||||
function matchesOrigin(request: FastifyRequest, expectedOrigin: string): boolean {
|
||||
const origin = singleHeader(request.headers.origin);
|
||||
if (origin !== expectedOrigin) return false;
|
||||
const fetchSite = singleHeader(request.headers["sec-fetch-site"]);
|
||||
return fetchSite === undefined || fetchSite === "same-origin";
|
||||
}
|
||||
|
||||
function isPublicRoute(request: FastifyRequest): boolean {
|
||||
const rawUrl = request.raw.url ?? request.url;
|
||||
const query = rawUrl.indexOf("?");
|
||||
const pathname = query === -1 ? rawUrl : rawUrl.slice(0, query);
|
||||
return (request.method === "GET" && (pathname === "/health" || pathname === "/auth/config"
|
||||
|| pathname === "/auth/oidc/login" || pathname === "/auth/oidc/callback"))
|
||||
|| (request.method === "POST" && pathname === "/auth/local/login");
|
||||
}
|
||||
|
||||
export function getPrincipal(req: FastifyRequest): PrincipalContext {
|
||||
if (!req.principal) throw new Error("principal missing after authentication");
|
||||
return req.principal;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { deriveCsrfToken as deriveStoredCsrfToken } from "./session-store.js";
|
||||
|
||||
export { deriveStoredCsrfToken as deriveCsrfToken };
|
||||
|
||||
/** Compare a client-supplied CSRF value without exposing a useful length timing oracle. */
|
||||
export function csrfTokensEqual(expectedToken: string, suppliedToken: string | undefined): boolean {
|
||||
const expected = Buffer.from(expectedToken, "utf8");
|
||||
const supplied = Buffer.from(suppliedToken ?? "", "utf8");
|
||||
const padded = Buffer.alloc(expected.length);
|
||||
supplied.copy(padded, 0, 0, expected.length);
|
||||
return timingSafeEqual(expected, padded) && supplied.length === expected.length;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { AuthenticationConfigProvider } from "./types.js";
|
||||
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
|
||||
import type { AuthSessionStore } from "./session-store.js";
|
||||
import { rolesToPermissions } from "./config.js";
|
||||
import { getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
|
||||
import { requirePermission, isPrincipalContext } from "./authorization.js";
|
||||
import { deriveCsrfToken } from "./csrf.js";
|
||||
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||
const REMEMBER_COOKIE_SECONDS = 2_592_000;
|
||||
const MAX_USERNAME_LENGTH = 64;
|
||||
const MAX_PASSWORD_LENGTH = 1024;
|
||||
const MAX_LIMIT_ENTRIES = 10_000;
|
||||
|
||||
export interface AuthRouteDependencies {
|
||||
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
|
||||
authentication?: AuthenticationConfigProvider;
|
||||
sessionStore?: AuthSessionStore;
|
||||
localUserRegistry?: LocalUserRegistry;
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
class LoginFailureLimiter {
|
||||
private readonly usernames = new Map<string, number[]>();
|
||||
private readonly addresses = new Map<string, number[]>();
|
||||
|
||||
isLimited(username: string, address: string, now = Date.now()): boolean {
|
||||
return this.active(this.usernames, username, now).length >= 10
|
||||
|| this.active(this.addresses, address, now).length >= 20;
|
||||
}
|
||||
|
||||
recordFailure(username: string, address: string, now = Date.now()): void {
|
||||
this.active(this.usernames, username, now).push(now);
|
||||
this.active(this.addresses, address, now).push(now);
|
||||
}
|
||||
|
||||
private active(bucket: Map<string, number[]>, key: string, now: number): number[] {
|
||||
const prior = bucket.get(key) ?? [];
|
||||
const current = prior.filter((timestamp) => timestamp > now - TEN_MINUTES_MS);
|
||||
if (current.length === 0) bucket.delete(key); else bucket.set(key, current);
|
||||
if (!bucket.has(key) && bucket.size >= MAX_LIMIT_ENTRIES) {
|
||||
const oldest = bucket.keys().next().value;
|
||||
if (typeof oldest === "string") bucket.delete(oldest);
|
||||
}
|
||||
if (!bucket.has(key)) bucket.set(key, current);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
class VerificationGate {
|
||||
private active = 0;
|
||||
|
||||
async run(operation: () => Promise<boolean>): Promise<boolean | undefined> {
|
||||
if (this.active >= 2) return undefined;
|
||||
this.active += 1;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.active -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void {
|
||||
const limiter = new LoginFailureLimiter();
|
||||
const verificationGate = new VerificationGate();
|
||||
|
||||
app.get("/auth/config", async (_request, reply) => {
|
||||
try {
|
||||
const mode = deps.authentication?.current().value.mode ?? deps.authMode;
|
||||
return reply.send({ mode, localLogin: mode === "local", oidcLogin: false });
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/auth/local/login", async (request, reply) => {
|
||||
const configured = currentLocalConfig(deps);
|
||||
if (!configured || !deps.localUserRegistry || !deps.sessionStore) return unavailable(reply);
|
||||
const originCheck = requireExactOrigin(request, reply, configured.origin);
|
||||
if (originCheck !== true) return originCheck;
|
||||
|
||||
const payload = loginPayload(request);
|
||||
const normalizedUsername = payload.username.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
||||
const sourceAddress = boundedAddress(request.ip);
|
||||
if (limiter.isLimited(normalizedUsername, sourceAddress)) return loginLimited(reply);
|
||||
|
||||
let user: LocalUserRecord | undefined;
|
||||
try {
|
||||
if (payload.username.length > 0) user = await deps.localUserRegistry.findByUsername(payload.username);
|
||||
} catch {
|
||||
user = undefined;
|
||||
}
|
||||
let verified: boolean | undefined;
|
||||
try {
|
||||
verified = await verificationGate.run(async () =>
|
||||
deps.localUserRegistry!.verify(user, argon2SafePassword(payload.password)));
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
if (verified === undefined) return loginLimited(reply);
|
||||
if (!verified || !user || !user.enabled) {
|
||||
limiter.recordFailure(normalizedUsername, sourceAddress);
|
||||
return invalidCredentials(reply);
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await deps.sessionStore.create({
|
||||
principal: {
|
||||
issuer: "local",
|
||||
subject: user.id,
|
||||
displayName: user.displayName ?? user.username,
|
||||
roles: user.roles,
|
||||
permissions: rolesToPermissions(user.roles),
|
||||
isAdmin: user.roles.includes("admin"),
|
||||
},
|
||||
method: "local",
|
||||
remembered: payload.remember,
|
||||
userAuthRevision: user.authRevision,
|
||||
authConfigRevision: configured.revision,
|
||||
idleTtlMs: (payload.remember ? configured.session.rememberIdleSeconds : configured.session.regularIdleSeconds) * 1000,
|
||||
absoluteTtlMs: (payload.remember ? configured.session.rememberTtlSeconds : configured.session.regularTtlSeconds) * 1000,
|
||||
});
|
||||
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.secure, payload.remember));
|
||||
return reply.send({});
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/auth/oidc/login", async (_request, reply) => notImplemented(reply));
|
||||
app.get("/auth/oidc/callback", async (_request, reply) => notImplemented(reply));
|
||||
|
||||
app.post("/auth/logout", async (request, reply) => {
|
||||
const token = request.authSessionToken;
|
||||
if (!token || !deps.sessionStore) return unavailable(reply);
|
||||
try {
|
||||
await deps.sessionStore.revoke(token);
|
||||
reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(deps), false));
|
||||
return reply.code(204).send();
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/me", async (request, reply) => {
|
||||
const principal = requirePermission(request, reply, "session.use");
|
||||
if (!isPrincipalContext(principal)) return principal;
|
||||
const session = request.authSession;
|
||||
const token = request.authSessionToken;
|
||||
if (!session || !token) return unavailable(reply);
|
||||
try {
|
||||
return {
|
||||
issuer: principal.issuer,
|
||||
subject: principal.subject,
|
||||
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
|
||||
roles: principal.roles,
|
||||
permissions: principal.permissions,
|
||||
isAdmin: principal.isAdmin,
|
||||
csrfToken: deriveCsrfToken(token),
|
||||
session: {
|
||||
method: session.method,
|
||||
remembered: session.remembered,
|
||||
idleExpiresAt: session.idleExpiresAt,
|
||||
absoluteExpiresAt: session.absoluteExpiresAt,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function currentLocalConfig(deps: AuthRouteDependencies): {
|
||||
revision: string;
|
||||
origin: string;
|
||||
secure: boolean;
|
||||
session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number };
|
||||
} | undefined {
|
||||
try {
|
||||
const loaded = deps.authentication?.current();
|
||||
if (!loaded || loaded.value.mode !== "local") return undefined;
|
||||
const url = new URL(loaded.value.publicUrl);
|
||||
return {
|
||||
revision: loaded.revision,
|
||||
origin: url.origin,
|
||||
secure: url.protocol === "https:",
|
||||
session: loaded.value.session,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function currentSecure(deps: AuthRouteDependencies): boolean {
|
||||
try { return new URL(deps.authentication?.current().value.publicUrl ?? "").protocol === "https:"; } catch { return false; }
|
||||
}
|
||||
|
||||
function cookieOptions(secure: boolean, remembered: boolean) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure,
|
||||
...(remembered ? { maxAge: REMEMBER_COOKIE_SECONDS } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function loginPayload(request: FastifyRequest): LoginPayload {
|
||||
const body = request.body;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false };
|
||||
const input = body as Record<string, unknown>;
|
||||
return {
|
||||
username: typeof input.username === "string" && input.username.length <= MAX_USERNAME_LENGTH ? input.username : "",
|
||||
password: typeof input.password === "string" && input.password.length <= MAX_PASSWORD_LENGTH ? input.password : "",
|
||||
remember: input.remember === true,
|
||||
};
|
||||
}
|
||||
|
||||
function boundedAddress(address: string): string {
|
||||
return typeof address === "string" && address.length > 0 && address.length <= 128 ? address : "unknown";
|
||||
}
|
||||
|
||||
function argon2SafePassword(value: string): string {
|
||||
const typed = value as string & { isWellFormed?: () => boolean };
|
||||
const wellFormed = typeof typed.isWellFormed === "function"
|
||||
? typed.isWellFormed()
|
||||
: !/[\uD800-\uDFFF]/.test(value);
|
||||
const bytes = Buffer.byteLength(value, "utf8");
|
||||
if (wellFormed && bytes >= 12 && bytes <= MAX_PASSWORD_LENGTH) return value;
|
||||
// A per-attempt random value preserves the Argon2 work without turning an invalid input into
|
||||
// a reusable password that could happen to match a user's configured secret.
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function invalidCredentials(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(401).send({ code: "invalid_credentials", error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
function loginLimited(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(429).send({ code: "login_rate_limited", error: "Too many login attempts" });
|
||||
}
|
||||
|
||||
function unavailable(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||
}
|
||||
|
||||
function notImplemented(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(501).send({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
|
||||
}
|
||||
+21
-4
@@ -1,6 +1,23 @@
|
||||
import { buildApp } from "./app.js";
|
||||
import { buildApp, type AppWithAuthSessionStore } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
const config = loadConfig(process.env);
|
||||
const app = buildApp(config);
|
||||
app.listen({ port: config.port, host: config.host })
|
||||
.then((addr) => console.log(`backend listening on ${addr}`));
|
||||
const app = buildApp(config) as AppWithAuthSessionStore;
|
||||
|
||||
async function start(): Promise<void> {
|
||||
const sessions = app.thothiiAuthSessionStore;
|
||||
if (sessions) {
|
||||
await sessions.prune();
|
||||
const interval = setInterval(() => {
|
||||
void sessions.prune().catch(() => app.log.warn({ component: "auth-session-prune" }, "auth session pruning failed"));
|
||||
}, 15 * 60 * 1000);
|
||||
interval.unref();
|
||||
app.addHook("onClose", async () => clearInterval(interval));
|
||||
}
|
||||
const address = await app.listen({ port: config.port, host: config.host });
|
||||
console.log(`backend listening on ${address}`);
|
||||
}
|
||||
|
||||
void start().catch(() => {
|
||||
console.error("backend startup failed");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { stringify } from "yaml";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
test("configured OIDC fails app startup until an OIDC handler is installed", () => {
|
||||
test("configured OIDC starts with provider-neutral protocol placeholders that fail closed", async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-app-oidc-mode-"));
|
||||
const file = join(directory, "auth.yaml");
|
||||
writeFileSync(file, stringify({
|
||||
@@ -19,8 +19,16 @@ test("configured OIDC fails app startup until an OIDC handler is installed", ()
|
||||
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
|
||||
}), "utf8");
|
||||
try {
|
||||
expect(() => buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file })))
|
||||
.toThrow("configured authentication mode is not implemented");
|
||||
const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") }));
|
||||
try {
|
||||
expect((await app.inject({ method: "GET", url: "/auth/config" })).json())
|
||||
.toEqual({ mode: "oidc", localLogin: false, oidcLogin: false });
|
||||
const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
expect(placeholder.statusCode).toBe(501);
|
||||
expect(placeholder.json()).toEqual({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { deriveCsrfToken } from "../src/auth/csrf.js";
|
||||
|
||||
const password = "correct horse battery staple";
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
||||
const publicUrl = "http://127.0.0.1:8787";
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
||||
});
|
||||
|
||||
function sessionCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
|
||||
const setCookie = response.headers["set-cookie"];
|
||||
const first = Array.isArray(setCookie) ? setCookie[0] : setCookie;
|
||||
return first?.split(";", 1)[0] ?? "";
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-csrf-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const authConfigFile = join(directory, "auth.yaml");
|
||||
const usersFile = join(directory, "users.yaml");
|
||||
writeFileSync(authConfigFile, stringify({
|
||||
version: 1, mode: "local", publicUrl, local: { usersFile: "users.yaml" },
|
||||
}), { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(usersFile, [
|
||||
"version: 1", "users:", ` - id: ${adminId}`, " username: Admin",
|
||||
" displayName: Local administrator", ` passwordHash: ${passwordHash}`,
|
||||
" roles:", " - admin", " enabled: true", " authRevision: 1", "",
|
||||
].join("\n"), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authConfigFile, 0o600);
|
||||
chmodSync(usersFile, 0o600);
|
||||
const app = buildApp(loadConfig({
|
||||
THT_AUTH_CONFIG_FILE: authConfigFile,
|
||||
THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
|
||||
THT_HARNESS_DIR: "/tmp/h",
|
||||
}));
|
||||
cleanups.push(async () => {
|
||||
await app.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const signedIn = await app.inject({
|
||||
method: "POST", url: "/auth/local/login",
|
||||
headers: { origin: publicUrl, "sec-fetch-site": "same-origin" },
|
||||
payload: { username: "Admin", password },
|
||||
});
|
||||
const cookie = sessionCookie(signedIn);
|
||||
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie } });
|
||||
return { app, cookie, csrfToken: me.json().csrfToken as string };
|
||||
}
|
||||
|
||||
test("derived CSRF tokens are deterministic per opaque cookie and are never the cookie token", async () => {
|
||||
const { cookie, csrfToken } = await createApp();
|
||||
const token = cookie.split("=", 2)[1] ?? "";
|
||||
expect(deriveCsrfToken(token)).toBe(csrfToken);
|
||||
expect(csrfToken).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(csrfToken).not.toBe(token);
|
||||
});
|
||||
|
||||
test("cookie-authenticated state changes require an exact Origin, Fetch Metadata when present, and CSRF token", async () => {
|
||||
const { app, cookie, csrfToken } = await createApp();
|
||||
const cases = [
|
||||
{ headers: { cookie, origin: publicUrl, "sec-fetch-site": "same-origin" } },
|
||||
{ headers: { cookie, origin: "http://127.0.0.1:8788", "sec-fetch-site": "same-origin", "x-thothii-csrf": csrfToken } },
|
||||
{ headers: { cookie, origin: publicUrl, "sec-fetch-site": "cross-site", "x-thothii-csrf": csrfToken } },
|
||||
{ headers: { cookie, origin: publicUrl, "x-thothii-csrf": csrfToken.slice(0, -1) } },
|
||||
];
|
||||
|
||||
for (const request of cases) {
|
||||
const response = await app.inject({ method: "POST", url: "/auth/logout", ...request });
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" });
|
||||
}
|
||||
});
|
||||
|
||||
test("duplicate or malformed CSRF and session-cookie headers fail closed", async () => {
|
||||
const { app, cookie, csrfToken } = await createApp();
|
||||
const duplicateCsrf = await app.inject({
|
||||
method: "POST", url: "/auth/logout",
|
||||
headers: { cookie, origin: publicUrl, "x-thothii-csrf": `${csrfToken}, ${csrfToken}` },
|
||||
});
|
||||
const duplicateCookie = await app.inject({
|
||||
method: "POST", url: "/auth/logout",
|
||||
headers: { cookie: `${cookie}; ${cookie}`, origin: publicUrl, "x-thothii-csrf": csrfToken },
|
||||
});
|
||||
const malformedCookie = await app.inject({
|
||||
method: "POST", url: "/auth/logout",
|
||||
headers: { cookie: "thothii_session=not-a-token", origin: publicUrl, "x-thothii-csrf": csrfToken },
|
||||
});
|
||||
|
||||
expect(duplicateCsrf.statusCode).toBe(403);
|
||||
expect(duplicateCookie.statusCode).toBe(401);
|
||||
expect(malformedCookie.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test("an unauthenticated state-changing application route cannot bypass the central boundary", async () => {
|
||||
const { app } = await createApp();
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/sessions", headers: { origin: publicUrl, "x-thothii-csrf": "x".repeat(43) },
|
||||
payload: { question: "must not reach a session handler" },
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json()).toEqual({ code: "authentication_required", error: "Authentication is required" });
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
const password = "correct horse battery staple";
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
|
||||
const publicUrl = "http://127.0.0.1:8787";
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
||||
});
|
||||
|
||||
function localConfig(url = publicUrl) {
|
||||
return {
|
||||
version: 1,
|
||||
mode: "local",
|
||||
publicUrl: url,
|
||||
local: { usersFile: "users.yaml" },
|
||||
};
|
||||
}
|
||||
|
||||
function usersYaml(options: { enabled?: boolean; username?: string } = {}): string {
|
||||
return [
|
||||
"version: 1",
|
||||
"users:",
|
||||
` - id: ${adminId}`,
|
||||
` username: ${options.username ?? "Admin"}`,
|
||||
" displayName: Local administrator",
|
||||
` passwordHash: ${passwordHash}`,
|
||||
" roles:",
|
||||
" - admin",
|
||||
` enabled: ${options.enabled ?? true}`,
|
||||
" authRevision: 1",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
|
||||
const header = response.headers["set-cookie"];
|
||||
if (Array.isArray(header)) return header[0] ?? "";
|
||||
return header ?? "";
|
||||
}
|
||||
|
||||
function cookiePair(setCookie: string): string {
|
||||
return setCookie.split(";", 1)[0] ?? "";
|
||||
}
|
||||
|
||||
async function createLocalApp(options: {
|
||||
publicUrl?: string;
|
||||
enabled?: boolean;
|
||||
stateRoot?: string;
|
||||
registry?: {
|
||||
findByUsername(username: string): Promise<unknown>;
|
||||
findBySubject(subject: string): Promise<unknown>;
|
||||
verify(user: unknown, suppliedPassword: string): Promise<boolean>;
|
||||
};
|
||||
} = {}) {
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-routes-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const authConfigFile = join(directory, "auth.yaml");
|
||||
const usersFile = join(directory, "users.yaml");
|
||||
const authStateRoot = options.stateRoot ?? join(directory, "auth-state");
|
||||
writeFileSync(authConfigFile, stringify(localConfig(options.publicUrl)), { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(usersFile, usersYaml({ enabled: options.enabled }), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authConfigFile, 0o600);
|
||||
chmodSync(usersFile, 0o600);
|
||||
const app = buildApp(loadConfig({
|
||||
THT_AUTH_CONFIG_FILE: authConfigFile,
|
||||
THT_AUTH_STATE_ROOT: authStateRoot,
|
||||
THT_HARNESS_DIR: "/tmp/h",
|
||||
}), options.registry === undefined ? undefined : { localUserRegistry: options.registry } as any);
|
||||
cleanups.push(async () => {
|
||||
await app.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
return { app, authConfigFile, usersFile, authStateRoot, directory, publicUrl: options.publicUrl ?? publicUrl };
|
||||
}
|
||||
|
||||
async function login(app: Awaited<ReturnType<typeof createLocalApp>>["app"], body: Record<string, unknown> = {}) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/local/login",
|
||||
headers: { origin: publicUrl, "sec-fetch-site": "same-origin" },
|
||||
payload: { username: "Admin", password, ...body },
|
||||
});
|
||||
}
|
||||
|
||||
test("local login sets a non-persistent opaque session cookie and exposes only a safe /me DTO", async () => {
|
||||
const { app } = await createLocalApp();
|
||||
|
||||
const signedIn = await login(app);
|
||||
expect(signedIn.statusCode).toBe(200);
|
||||
const setCookie = firstSetCookie(signedIn);
|
||||
expect(setCookie).toMatch(/^thothii_session=[A-Za-z0-9_-]{43}; /);
|
||||
expect(setCookie).toContain("HttpOnly");
|
||||
expect(setCookie).toContain("SameSite=Lax");
|
||||
expect(setCookie).toContain("Path=/");
|
||||
expect(setCookie).not.toMatch(/Max-Age=/i);
|
||||
expect(setCookie).not.toContain("Secure");
|
||||
|
||||
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
|
||||
expect(me.statusCode).toBe(200);
|
||||
expect(me.json()).toEqual({
|
||||
issuer: "local",
|
||||
subject: adminId,
|
||||
displayName: "Local administrator",
|
||||
roles: ["admin"],
|
||||
permissions: [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||
],
|
||||
isAdmin: true,
|
||||
csrfToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
|
||||
session: {
|
||||
method: "local",
|
||||
remembered: false,
|
||||
idleExpiresAt: expect.any(String),
|
||||
absoluteExpiresAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(me.json())).not.toContain("authConfigRevision");
|
||||
expect(JSON.stringify(me.json())).not.toContain("authRevision");
|
||||
expect(JSON.stringify(me.json())).not.toContain(cookiePair(setCookie).split("=", 2)[1] ?? "");
|
||||
});
|
||||
|
||||
test("remembered login uses a persistent secure cookie under an HTTPS public URL and survives app recreation", async () => {
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-remembered-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const authConfigFile = join(directory, "auth.yaml");
|
||||
const usersFile = join(directory, "users.yaml");
|
||||
const authStateRoot = join(directory, "auth-state");
|
||||
writeFileSync(authConfigFile, stringify(localConfig("https://thothii.example.test")), { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(usersFile, usersYaml(), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authConfigFile, 0o600);
|
||||
chmodSync(usersFile, 0o600);
|
||||
const config = () => loadConfig({ THT_AUTH_CONFIG_FILE: authConfigFile, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h" });
|
||||
const first = buildApp(config());
|
||||
try {
|
||||
const signedIn = await first.inject({
|
||||
method: "POST",
|
||||
url: "/auth/local/login",
|
||||
headers: { origin: "https://thothii.example.test", "sec-fetch-site": "same-origin" },
|
||||
payload: { username: "Admin", password, remember: true },
|
||||
});
|
||||
const setCookie = firstSetCookie(signedIn);
|
||||
expect(signedIn.statusCode).toBe(200);
|
||||
expect(setCookie).toContain("Max-Age=2592000");
|
||||
expect(setCookie).toContain("Secure");
|
||||
await first.close();
|
||||
|
||||
const restarted = buildApp(config());
|
||||
cleanups.push(async () => {
|
||||
await restarted.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const me = await restarted.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
|
||||
expect(me.statusCode).toBe(200);
|
||||
expect(me.json()).toMatchObject({ subject: adminId, session: { remembered: true, method: "local" } });
|
||||
} catch (error) {
|
||||
await first.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown, disabled, and wrong-password logins share one generic failure contract", async () => {
|
||||
const enabled = await createLocalApp();
|
||||
const disabled = await createLocalApp({ enabled: false });
|
||||
const attempts = await Promise.all([
|
||||
login(enabled.app, { username: "Unknown" }),
|
||||
login(disabled.app),
|
||||
login(enabled.app, { password: `${password}!` }),
|
||||
]);
|
||||
|
||||
for (const response of attempts) {
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json()).toEqual({ code: "invalid_credentials", error: "Invalid username or password" });
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid password input still reaches the local verifier with a bounded Argon2-safe surrogate", async () => {
|
||||
const user = {
|
||||
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
||||
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
||||
};
|
||||
const verify = vi.fn(async () => false);
|
||||
const { app } = await createLocalApp({
|
||||
registry: { findByUsername: async () => user, findBySubject: async () => user, verify },
|
||||
});
|
||||
|
||||
const response = await login(app, { password: "short" });
|
||||
expect(response.statusCode).toBe(401);
|
||||
const verifierPassword = verify.mock.calls[0]?.[1];
|
||||
expect(verifierPassword).not.toBe("short");
|
||||
expect(Buffer.byteLength(verifierPassword ?? "", "utf8")).toBeGreaterThanOrEqual(12);
|
||||
});
|
||||
|
||||
test("local login requires the exact configured Origin and same-origin Fetch Metadata", async () => {
|
||||
const { app } = await createLocalApp();
|
||||
const missingOrigin = await app.inject({ method: "POST", url: "/auth/local/login", payload: { username: "Admin", password } });
|
||||
const wrongOrigin = await app.inject({
|
||||
method: "POST", url: "/auth/local/login", headers: { origin: "http://127.0.0.1:8788" }, payload: { username: "Admin", password },
|
||||
});
|
||||
const crossSite = await app.inject({
|
||||
method: "POST", url: "/auth/local/login", headers: { origin: publicUrl, "sec-fetch-site": "cross-site" }, payload: { username: "Admin", password },
|
||||
});
|
||||
|
||||
for (const response of [missingOrigin, wrongOrigin, crossSite]) {
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" });
|
||||
}
|
||||
});
|
||||
|
||||
test("logout revokes the session and clears the cookie with the production attributes", async () => {
|
||||
const { app } = await createLocalApp();
|
||||
const signedIn = await login(app);
|
||||
const setCookie = firstSetCookie(signedIn);
|
||||
const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } });
|
||||
|
||||
const loggedOut = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/logout",
|
||||
headers: {
|
||||
cookie: cookiePair(setCookie), origin: publicUrl, "sec-fetch-site": "same-origin",
|
||||
"x-thothii-csrf": me.json().csrfToken,
|
||||
},
|
||||
});
|
||||
expect(loggedOut.statusCode).toBe(204);
|
||||
const cleared = firstSetCookie(loggedOut);
|
||||
expect(cleared).toMatch(/^thothii_session=;/);
|
||||
expect(cleared).toContain("Max-Age=0");
|
||||
expect(cleared).toContain("HttpOnly");
|
||||
expect(cleared).toContain("SameSite=Lax");
|
||||
expect(cleared).toContain("Path=/");
|
||||
expect(cleared).toContain("Expires=");
|
||||
expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test("failed logins are limited by normalized username and source address", async () => {
|
||||
const user = {
|
||||
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
||||
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
||||
};
|
||||
const registry = {
|
||||
findByUsername: async () => user,
|
||||
findBySubject: async () => user,
|
||||
verify: async () => false,
|
||||
};
|
||||
const { app } = await createLocalApp({ registry });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
const response = await login(app, { username: "aDmIn" });
|
||||
expect(response.statusCode).toBe(401);
|
||||
}
|
||||
const limited = await login(app, { username: "ADMIN" });
|
||||
expect(limited.statusCode).toBe(429);
|
||||
expect(limited.json()).toEqual({ code: "login_rate_limited", error: "Too many login attempts" });
|
||||
|
||||
const addressLimited = await createLocalApp({ registry });
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const response = await login(addressLimited.app, { username: `User${attempt}` });
|
||||
expect(response.statusCode).toBe(401);
|
||||
}
|
||||
expect((await login(addressLimited.app, { username: "A-new-username" })).statusCode).toBe(429);
|
||||
});
|
||||
|
||||
test("only two Argon2 verifications run concurrently and excess login attempts fail immediately", async () => {
|
||||
let calls = 0;
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => { release = resolve; });
|
||||
let entered!: () => void;
|
||||
const twoEntered = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const user = {
|
||||
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
||||
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
||||
};
|
||||
const registry = {
|
||||
findByUsername: async () => user,
|
||||
findBySubject: async () => user,
|
||||
verify: async () => {
|
||||
calls += 1;
|
||||
if (calls === 2) entered();
|
||||
await blocked;
|
||||
return false;
|
||||
},
|
||||
};
|
||||
const { app } = await createLocalApp({ registry });
|
||||
const first = login(app);
|
||||
const second = login(app);
|
||||
await twoEntered;
|
||||
const excess = await login(app);
|
||||
expect(excess.statusCode).toBe(429);
|
||||
expect(calls).toBe(2);
|
||||
release();
|
||||
expect((await first).statusCode).toBe(401);
|
||||
expect((await second).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test("a verifier failure is sanitized and releases its concurrency permit", async () => {
|
||||
let attempts = 0;
|
||||
const user = {
|
||||
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
|
||||
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
|
||||
};
|
||||
const { app } = await createLocalApp({
|
||||
registry: {
|
||||
findByUsername: async () => user,
|
||||
findBySubject: async () => user,
|
||||
verify: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("fixture verifier failure");
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const failed = await login(app);
|
||||
expect(failed.statusCode).toBe(503);
|
||||
expect(failed.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||
expect((await login(app)).statusCode).toBe(401);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
test("public auth configuration is safe and OIDC protocol placeholders fail closed", async () => {
|
||||
const { app } = await createLocalApp();
|
||||
const configuration = await app.inject({ method: "GET", url: "/auth/config" });
|
||||
expect(configuration.statusCode).toBe(200);
|
||||
expect(configuration.json()).toEqual({ mode: "local", localLogin: true, oidcLogin: false });
|
||||
expect(JSON.stringify(configuration.json())).not.toContain("users.yaml");
|
||||
|
||||
const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
expect(placeholder.statusCode).toBe(501);
|
||||
expect(placeholder.json()).toEqual({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { test, expect, vi } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import { authPreHandler, getPrincipal } from "../src/auth/auth.js";
|
||||
import { authenticateSession, authPreHandler, getPrincipal } from "../src/auth/auth.js";
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -107,6 +107,86 @@ test("upstream mode rejects legacy client identity headers without proxy princip
|
||||
}
|
||||
});
|
||||
|
||||
test("the session boundary exposes only exact health and authentication protocol paths", async () => {
|
||||
const app = Fastify();
|
||||
app.addHook("preHandler", authenticateSession({
|
||||
mode: "local",
|
||||
authentication: {
|
||||
current: () => ({
|
||||
sourcePath: "/private/auth.yaml",
|
||||
revision: "a".repeat(64),
|
||||
value: {
|
||||
version: 1,
|
||||
mode: "local",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
session: {
|
||||
regularTtlSeconds: 43_200, regularIdleSeconds: 7_200,
|
||||
rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800,
|
||||
},
|
||||
local: { usersFile: "users.yaml" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
sessionStore: { resolve: async () => undefined } as any,
|
||||
}));
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
app.get("/auth/config", async () => ({ mode: "local" }));
|
||||
app.get("/healthz", async () => ({ ok: true }));
|
||||
app.get("/auth/configured", async () => ({ mode: "local" }));
|
||||
|
||||
expect((await app.inject({ method: "GET", url: "/health?probe=1" })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: "GET", url: "/auth/config?ui=1" })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: "GET", url: "/healthz" })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "GET", url: "/auth/configured" })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test("the session boundary touches a valid cookie session through the bounded Task 7 store operation", async () => {
|
||||
const sessions = {
|
||||
resolve: vi.fn(async () => ({
|
||||
version: 1,
|
||||
issuer: "local",
|
||||
subject: "user-1",
|
||||
method: "local",
|
||||
roles: ["user"],
|
||||
permissions: ["session.use"],
|
||||
userAuthRevision: 1,
|
||||
authConfigRevision: "b".repeat(64),
|
||||
remembered: false,
|
||||
createdAt: "2026-08-16T00:00:00.000Z",
|
||||
lastSeenAt: "2026-08-16T00:00:00.000Z",
|
||||
idleExpiresAt: "2026-08-16T02:00:00.000Z",
|
||||
absoluteExpiresAt: "2026-08-16T12:00:00.000Z",
|
||||
})),
|
||||
touch: vi.fn(async () => {}),
|
||||
};
|
||||
const app = Fastify();
|
||||
app.addHook("preHandler", authenticateSession({
|
||||
mode: "local",
|
||||
authentication: {
|
||||
current: () => ({
|
||||
sourcePath: "/private/auth.yaml",
|
||||
revision: "b".repeat(64),
|
||||
value: {
|
||||
version: 1,
|
||||
mode: "local",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
session: {
|
||||
regularTtlSeconds: 43_200, regularIdleSeconds: 7_200,
|
||||
rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800,
|
||||
},
|
||||
local: { usersFile: "users.yaml" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
sessionStore: sessions as any,
|
||||
}));
|
||||
app.get("/private", async (request) => getPrincipal(request));
|
||||
|
||||
const token = "z".repeat(43);
|
||||
expect((await app.inject({ method: "GET", url: "/private", headers: { cookie: `thothii_session=${token}` } })).statusCode).toBe(200);
|
||||
expect(sessions.touch).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
test("local identity expands tilde homes and restores private POSIX permissions", () => {
|
||||
expect(expandLocalHome("~/thoth-test", "/home/tester")).toBe("/home/tester/thoth-test");
|
||||
expect(expandLocalHome("~", "/home/tester")).toBe("/home/tester");
|
||||
|
||||
@@ -163,19 +163,20 @@ test("a durable maintenance marker initializes admission closed after backend re
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["none", "upstream"] as const)(
|
||||
"maintenance control is loopback-only and independent of %s authentication",
|
||||
async (authMode) => {
|
||||
test("maintenance control requires an upstream identity and remains loopback-only", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-control-"));
|
||||
const marker = path.join(dir, "maintenance.json");
|
||||
try {
|
||||
const app = buildApp(loadConfig({
|
||||
AUTH_MODE: authMode,
|
||||
AUTH_MODE: "upstream",
|
||||
THT_HARNESS_DIR: "../harness",
|
||||
THT_MAINTENANCE_FILE: marker,
|
||||
}), { thtRunner: {} as any });
|
||||
|
||||
const activated = await app.inject({ method: "POST", url: "/internal/maintenance/activate" });
|
||||
expect((await app.inject({ method: "POST", url: "/internal/maintenance/activate" })).statusCode).toBe(401);
|
||||
const activated = await app.inject({
|
||||
method: "POST", url: "/internal/maintenance/activate", headers: aliceHeaders,
|
||||
});
|
||||
expect(activated.statusCode).toBe(200);
|
||||
expect(activated.json()).toEqual({ active: true, admissions: 0 });
|
||||
|
||||
@@ -190,15 +191,16 @@ test.each(["none", "upstream"] as const)(
|
||||
});
|
||||
expect(spoofedProxy.statusCode).toBe(403);
|
||||
|
||||
const status = await app.inject({ method: "GET", url: "/internal/maintenance/status" });
|
||||
const status = await app.inject({ method: "GET", url: "/internal/maintenance/status", headers: aliceHeaders });
|
||||
expect(status.json()).toEqual({ active: true, admissions: 0 });
|
||||
const deactivated = await app.inject({ method: "POST", url: "/internal/maintenance/deactivate" });
|
||||
const deactivated = await app.inject({
|
||||
method: "POST", url: "/internal/maintenance/deactivate", headers: aliceHeaders,
|
||||
});
|
||||
expect(deactivated.json()).toEqual({ active: false, admissions: 0 });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("maintenance endpoints report marker-derived state after post-rename and post-remove fsync failures", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-endpoint-fsync-"));
|
||||
|
||||
Reference in New Issue
Block a user