274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { test, expect, vi } from "vitest";
|
|
import Fastify from "fastify";
|
|
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";
|
|
import { expandLocalHome, localPrincipal, upstreamPrincipal } from "../src/auth/principal.js";
|
|
import { buildApp } from "../src/app.js";
|
|
import { loadConfig } from "../src/config.js";
|
|
|
|
test("server smoke rejects retired trusted claims under OIDC authentication", () => {
|
|
const smoke = readFileSync("../scripts/unified-deployment-smoke.sh", "utf8");
|
|
for (const header of [
|
|
"x-thoth-trusted-principal-issuer",
|
|
"x-thoth-trusted-principal-subject",
|
|
"x-thoth-trusted-principal-display-name",
|
|
"x-thoth-trusted-is-admin",
|
|
]) {
|
|
expect(smoke).toContain(`-H '${header}:`);
|
|
}
|
|
expect(smoke).toContain('[[ "$trusted_header_status" == 401 ]]');
|
|
expect(smoke).toContain("server accepted retired trusted identity headers");
|
|
});
|
|
|
|
test("local mode resolves a stable local principal", async () => {
|
|
const app = Fastify();
|
|
app.addHook("preHandler", authPreHandler("none"));
|
|
app.get("/me", async (req) => getPrincipal(req));
|
|
expect((await app.inject({ method: "GET", url: "/me" })).json()).toMatchObject({
|
|
issuer: "local",
|
|
subject: expect.any(String),
|
|
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,
|
|
});
|
|
});
|
|
|
|
test("mock mode makes a principal from the test header", async () => {
|
|
const app = Fastify();
|
|
app.addHook("preHandler", authPreHandler("mock"));
|
|
app.get("/me", async (req) => getPrincipal(req));
|
|
const res = await app.inject({
|
|
method: "GET",
|
|
url: "/me",
|
|
headers: { "x-mock-user": "alice" },
|
|
});
|
|
expect(res.json()).toEqual({
|
|
issuer: "mock", subject: "alice", displayName: "alice",
|
|
roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
|
});
|
|
});
|
|
|
|
test("upstream mode accepts only normalized proxy principal headers", async () => {
|
|
const app = Fastify();
|
|
app.addHook("preHandler", authPreHandler("upstream"));
|
|
app.get("/me", async (req) => getPrincipal(req));
|
|
|
|
expect((await app.inject({ method: "GET", url: "/me" })).statusCode).toBe(401);
|
|
const authenticated = await app.inject({
|
|
method: "GET",
|
|
url: "/me",
|
|
headers: {
|
|
"x-thoth-principal-issuer": "portal",
|
|
"x-thoth-principal-subject": "42",
|
|
"x-thoth-principal-display-name": "Alice",
|
|
"x-thoth-is-admin": "1",
|
|
"x-authenticated-user": "must-not-be-used",
|
|
},
|
|
});
|
|
expect(authenticated.json()).toEqual({
|
|
issuer: "portal", subject: "42", displayName: "Alice", roles: ["user", "admin"],
|
|
permissions: [
|
|
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
|
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
|
],
|
|
isAdmin: true,
|
|
});
|
|
});
|
|
|
|
test("upstream mode rejects legacy client identity headers without proxy principal fields", async () => {
|
|
const app = Fastify();
|
|
app.addHook("preHandler", authPreHandler("upstream"));
|
|
app.get("/me", async (req) => getPrincipal(req));
|
|
|
|
for (const headers of [
|
|
{ "x-authenticated-user": "mallory" },
|
|
{ "x-mock-user": "mallory" },
|
|
{ "x-authenticated-user": "mallory", "x-mock-user": "mallory" },
|
|
]) {
|
|
expect((await app.inject({ method: "GET", url: "/me", headers })).statusCode).toBe(401);
|
|
}
|
|
});
|
|
|
|
test("buildApp exposes safe nullable session metadata for authenticated legacy modes", async () => {
|
|
const configurations = [
|
|
{
|
|
name: "none",
|
|
config: loadConfig({ NODE_ENV: "test", AUTH_MODE: "none", THT_HARNESS_DIR: "/tmp/h" }),
|
|
headers: {},
|
|
expected: { issuer: "local", roles: ["admin"] },
|
|
},
|
|
{
|
|
name: "mock",
|
|
config: loadConfig({ NODE_ENV: "test", AUTH_MODE: "mock", THT_HARNESS_DIR: "/tmp/h" }),
|
|
headers: { "x-mock-user": "legacy-mock" },
|
|
expected: { issuer: "mock", subject: "legacy-mock", roles: ["user"] },
|
|
},
|
|
{
|
|
name: "upstream",
|
|
config: loadConfig({ NODE_ENV: "production", AUTH_MODE: "upstream", THT_HARNESS_DIR: "/tmp/h" }),
|
|
headers: {
|
|
"x-thoth-principal-issuer": "portal",
|
|
"x-thoth-principal-subject": "legacy-upstream",
|
|
"x-thoth-is-admin": "0",
|
|
},
|
|
expected: { issuer: "portal", subject: "legacy-upstream", roles: ["user"] },
|
|
},
|
|
];
|
|
|
|
for (const legacy of configurations) {
|
|
const app = buildApp(legacy.config, { thtRunner: {} as any, listModels: async () => [] });
|
|
try {
|
|
const response = await app.inject({ method: "GET", url: "/me", headers: legacy.headers });
|
|
expect(response.statusCode, legacy.name).toBe(200);
|
|
expect(response.json()).toMatchObject({
|
|
...legacy.expected,
|
|
csrfToken: null,
|
|
session: null,
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
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 retains the exact loopback tht maintenance identity in configured auth modes", async () => {
|
|
const app = Fastify();
|
|
app.addHook("preHandler", authenticateSession({ mode: "local" }));
|
|
app.get("/private", async (request) => getPrincipal(request));
|
|
app.post("/private", async (request) => getPrincipal(request));
|
|
const headers = {
|
|
"x-thoth-principal-issuer": "tht",
|
|
"x-thoth-principal-subject": "tht-maintenance",
|
|
"x-thoth-principal-display-name": "Tht maintenance",
|
|
"x-thoth-is-admin": "1",
|
|
};
|
|
|
|
for (const method of ["GET", "POST"] as const) {
|
|
const response = await app.inject({ method, url: "/private", headers, remoteAddress: "127.0.0.1" });
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toMatchObject({ issuer: "tht", subject: "tht-maintenance", isAdmin: true });
|
|
}
|
|
});
|
|
|
|
test("the session boundary rejects tht maintenance headers outside exact loopback provenance", async () => {
|
|
const app = Fastify();
|
|
app.addHook("preHandler", authenticateSession({ mode: "local" }));
|
|
app.get("/private", async (request) => getPrincipal(request));
|
|
const exact = {
|
|
"x-thoth-principal-issuer": "tht",
|
|
"x-thoth-principal-subject": "tht-maintenance",
|
|
"x-thoth-principal-display-name": "Tht maintenance",
|
|
"x-thoth-is-admin": "1",
|
|
};
|
|
|
|
expect((await app.inject({ method: "GET", url: "/private", headers: exact, remoteAddress: "172.30.0.9" })).statusCode).toBe(503);
|
|
expect((await app.inject({
|
|
method: "GET", url: "/private", remoteAddress: "127.0.0.1",
|
|
headers: { ...exact, "x-thoth-principal-subject": "not-maintenance" },
|
|
})).statusCode).toBe(503);
|
|
});
|
|
|
|
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");
|
|
const home = mkdtempSync(join(tmpdir(), "thoth-principal-"));
|
|
chmodSync(home, 0o755);
|
|
const previous = process.env.THT_HOME;
|
|
process.env.THT_HOME = home;
|
|
try {
|
|
localPrincipal();
|
|
if (process.platform !== "win32") {
|
|
expect(statSync(home).mode & 0o777).toBe(0o700);
|
|
expect(statSync(join(home, "identity.json")).mode & 0o777).toBe(0o600);
|
|
}
|
|
} finally {
|
|
if (previous === undefined) delete process.env.THT_HOME; else process.env.THT_HOME = previous;
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|