fix(auth): bind current local registry and CORS
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { afterAll, beforeAll, expect, test } from "vitest";
|
||||
import { createLocalAuthFixture, type LocalAuthFixture, localPublicUrl } from "./auth-test-fixtures.js";
|
||||
|
||||
let fixture: LocalAuthFixture;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = await createLocalAuthFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fixture.close();
|
||||
});
|
||||
|
||||
test("credentialed CORS permits only the current configured public origin", async () => {
|
||||
const allowedPreflight = await fixture.app.inject({
|
||||
method: "OPTIONS", url: "/me",
|
||||
headers: { origin: localPublicUrl, "access-control-request-method": "GET" },
|
||||
});
|
||||
expect(allowedPreflight.statusCode).toBe(204);
|
||||
expect(allowedPreflight.headers["access-control-allow-origin"]).toBe(localPublicUrl);
|
||||
expect(allowedPreflight.headers["access-control-allow-credentials"]).toBe("true");
|
||||
|
||||
const canonicalPreflight = await fixture.app.inject({
|
||||
method: "OPTIONS", url: "/me",
|
||||
headers: { origin: "HTTP://127.0.0.1:8787", "access-control-request-method": "GET" },
|
||||
});
|
||||
expect(canonicalPreflight.statusCode).toBe(204);
|
||||
expect(canonicalPreflight.headers["access-control-allow-origin"]).toBe(localPublicUrl);
|
||||
|
||||
const attackerPreflight = await fixture.app.inject({
|
||||
method: "OPTIONS", url: "/me",
|
||||
headers: { origin: "https://attacker.example.test", "access-control-request-method": "GET" },
|
||||
});
|
||||
expect(attackerPreflight.headers["access-control-allow-origin"]).toBeUndefined();
|
||||
expect(attackerPreflight.headers["access-control-allow-credentials"]).toBeUndefined();
|
||||
|
||||
const allowed = await fixture.app.inject({
|
||||
method: "GET", url: "/me", headers: { cookie: fixture.cookie, origin: localPublicUrl },
|
||||
});
|
||||
expect(allowed.statusCode).toBe(200);
|
||||
expect(allowed.headers["access-control-allow-origin"]).toBe(localPublicUrl);
|
||||
expect(allowed.headers["access-control-allow-credentials"]).toBe("true");
|
||||
|
||||
const attacker = await fixture.app.inject({
|
||||
method: "GET", url: "/me", headers: { cookie: fixture.cookie, origin: "https://attacker.example.test" },
|
||||
});
|
||||
expect(attacker.statusCode).toBe(200);
|
||||
expect(attacker.headers["access-control-allow-origin"]).toBeUndefined();
|
||||
expect(attacker.headers["access-control-allow-credentials"]).toBeUndefined();
|
||||
|
||||
const nonBrowser = await fixture.app.inject({ method: "GET", url: "/me", headers: { cookie: fixture.cookie } });
|
||||
expect(nonBrowser.statusCode).toBe(200);
|
||||
expect(nonBrowser.headers["access-control-allow-origin"]).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
import { buildApp, type AppWithAuthSessionStore } from "../src/app.js";
|
||||
import { loadAuthenticationConfig } from "../src/auth/config.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 userA = { id: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8", username: "AdminA" };
|
||||
const userB = { id: "6ba7b811-9dad-4ed1-80b4-00c04fd430c8", username: "AdminB" };
|
||||
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 usersYaml(user: typeof userA): string {
|
||||
return [
|
||||
"version: 1", "users:", ` - id: ${user.id}`, ` username: ${user.username}`,
|
||||
` passwordHash: ${passwordHash}`, " roles:", " - admin", " enabled: true", " authRevision: 1", "",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function configYaml(usersFile: string) {
|
||||
return stringify({ version: 1, mode: "local", publicUrl, local: { usersFile } });
|
||||
}
|
||||
|
||||
function cookiePair(response: { headers: Record<string, string | string[] | undefined> }): string {
|
||||
const header = response.headers["set-cookie"];
|
||||
const first = Array.isArray(header) ? header[0] : header;
|
||||
return first?.split(";", 1)[0] ?? "";
|
||||
}
|
||||
|
||||
test("each login and session resolve uses the current config snapshot users file", async () => {
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-dynamic-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const authFile = join(directory, "auth.yaml");
|
||||
const usersAFile = join(directory, "users-a.yaml");
|
||||
const usersBFile = join(directory, "users-bbbbb.yaml");
|
||||
writeFileSync(usersAFile, usersYaml(userA), { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(usersBFile, usersYaml(userB), { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(authFile, configYaml("users-a.yaml"), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authFile, 0o600);
|
||||
chmodSync(usersAFile, 0o600);
|
||||
chmodSync(usersBFile, 0o600);
|
||||
const app = buildApp(loadConfig({
|
||||
THT_AUTH_CONFIG_FILE: authFile,
|
||||
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 signIn = (username: string) => app.inject({
|
||||
method: "POST", url: "/auth/local/login",
|
||||
headers: { origin: publicUrl, "sec-fetch-site": "same-origin" },
|
||||
payload: { username, password },
|
||||
});
|
||||
|
||||
const first = await signIn(userA.username);
|
||||
expect(first.statusCode).toBe(200);
|
||||
const aCookie = cookiePair(first);
|
||||
expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: aCookie } })).json())
|
||||
.toMatchObject({ subject: userA.id });
|
||||
|
||||
writeFileSync(authFile, configYaml("users-bbbbb.yaml"), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authFile, 0o600);
|
||||
expect(readFileSync(usersAFile, "utf8")).toContain(userA.username);
|
||||
|
||||
const removedUser = await signIn(userA.username);
|
||||
expect(removedUser.statusCode).toBe(401);
|
||||
expect(removedUser.json()).toEqual({ code: "invalid_credentials", error: "Invalid username or password" });
|
||||
expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: aCookie } })).statusCode).toBe(401);
|
||||
|
||||
const second = await signIn(userB.username);
|
||||
expect(second.statusCode).toBe(200);
|
||||
const bCookie = cookiePair(second);
|
||||
expect(await app.inject({ method: "GET", url: "/me", headers: { cookie: bCookie } }).then((response) => response.json()))
|
||||
.toMatchObject({ subject: userB.id });
|
||||
const token = bCookie.split("=", 2)[1] ?? "";
|
||||
const record = await (app as AppWithAuthSessionStore).thothiiAuthSessionStore?.resolve(token);
|
||||
expect(record).toMatchObject({ subject: userB.id, authConfigRevision: loadAuthenticationConfig(authFile).revision });
|
||||
});
|
||||
@@ -9,7 +9,7 @@ vi.mock("node:crypto", async (importOriginal) => {
|
||||
return { ...actual, argon2: argon2Spy };
|
||||
});
|
||||
|
||||
import { verifyPassword } from "../src/auth/password.js";
|
||||
import { isValidPasswordHash, verifyPassword } from "../src/auth/password.js";
|
||||
|
||||
interface Argon2Vector {
|
||||
password: string;
|
||||
@@ -76,6 +76,7 @@ describe("local Argon2id password verification", () => {
|
||||
`$argon2id$v=19$m=65536,t=3,p=5$${salt}$${digest}`,
|
||||
`$argon2id$v=19$m=65536,t=3,p=1$${salt}$${"A".repeat(88)}`,
|
||||
`$argon2id$v=19$m=65536,t=3,p=1$${salt}=$${digest}`,
|
||||
`$argon2id$v=19$m=8,t=1,p=1$${salt}$${digest}`,
|
||||
];
|
||||
|
||||
for (const phc of cases) {
|
||||
@@ -84,4 +85,24 @@ describe("local Argon2id password verification", () => {
|
||||
expect(argon2Spy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts only the exact Go Argon2id policy in registry PHCs", () => {
|
||||
const [empty, algorithm, version, parameters, salt, digest] = vectors[0].phc.split("$");
|
||||
expect(empty).toBe("");
|
||||
expect(algorithm).toBe("argon2id");
|
||||
expect(version).toBe("v=19");
|
||||
expect(isValidPasswordHash(`$${algorithm}$${version}$m=8,t=1,p=1$${salt}$${digest}`)).toBe(false);
|
||||
expect(isValidPasswordHash(`$${algorithm}$${version}$m=65536,t=1,p=1$${salt}$${digest}`)).toBe(false);
|
||||
expect(isValidPasswordHash(`$${algorithm}$${version}$m=65536,t=3,p=2$${salt}$${digest}`)).toBe(false);
|
||||
expect(isValidPasswordHash(`$${algorithm}$${version}$${parameters}$${salt}$${digest}`)).toBe(true);
|
||||
});
|
||||
|
||||
test("surfaces a sanitized operational error when native Argon2 fails", async () => {
|
||||
argon2Spy.mockImplementationOnce((_algorithm, _parameters, callback) => {
|
||||
callback(new Error("native details must not leave the verifier"));
|
||||
});
|
||||
|
||||
await expect(verifyPassword(vectors[0].password, vectors[0].phc))
|
||||
.rejects.toThrow("local_password_verification_failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,19 +47,30 @@ afterAll(async () => {
|
||||
test.each(stateChangingRoutes)(
|
||||
"$family $method $url rejects every production CSRF/session-boundary failure before downstream code",
|
||||
async ({ method, url }) => {
|
||||
const failures = [
|
||||
fixture.sessionHeaders({ "x-thothii-csrf": undefined }),
|
||||
fixture.sessionHeaders({ "x-thothii-csrf": "malformed" }),
|
||||
fixture.sessionHeaders({ origin: undefined }),
|
||||
fixture.sessionHeaders({ origin: "http://wrong.example.test" }),
|
||||
fixture.sessionHeaders({ "sec-fetch-site": "cross-site" }),
|
||||
const failures: Array<{ expectedStatus: number; headers: Record<string, string> }> = [
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": undefined }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": "malformed" }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": `${fixture.csrfToken}, ${fixture.csrfToken}` }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": "x".repeat(4097) }) },
|
||||
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: undefined }) },
|
||||
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: "thothii_session=not-a-token" }) },
|
||||
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: `${fixture.cookie}; ${fixture.cookie}` }) },
|
||||
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: `${fixture.cookie}; padding=${"x".repeat(4097)}` }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: undefined }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: "http://wrong.example.test" }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: `${localPublicUrl}, ${localPublicUrl}` }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: `${localPublicUrl}${"x".repeat(4097)}` }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "sec-fetch-site": "cross-site" }) },
|
||||
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "sec-fetch-site": "same-origin, same-origin" }) },
|
||||
];
|
||||
|
||||
for (const headers of failures) {
|
||||
for (const { expectedStatus, headers } of failures) {
|
||||
fixture.resetDownstreamHits();
|
||||
const response = await fixture.app.inject({ method, url, headers, payload: {} });
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" });
|
||||
expect(response.statusCode).toBe(expectedStatus);
|
||||
expect(response.json()).toEqual(expectedStatus === 403
|
||||
? { code: "csrf_failed", error: "Request origin validation failed" }
|
||||
: { code: "authentication_required", error: "Authentication is required" });
|
||||
expect(fixture.downstreamHits()).toBe(0);
|
||||
}
|
||||
},
|
||||
@@ -78,3 +89,16 @@ test("a valid real local session cookie and derived CSRF token cross the same pr
|
||||
expect(fixture.downstreamHits()).toBe(1);
|
||||
expect(localPublicUrl).toBe("http://127.0.0.1:8787");
|
||||
});
|
||||
|
||||
test("a valid control may omit optional Fetch Metadata while retaining its exact Origin and CSRF pair", async () => {
|
||||
fixture.resetDownstreamHits();
|
||||
const response = await fixture.app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
headers: fixture.sessionHeaders({ "sec-fetch-site": undefined }),
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fixture.downstreamHits()).toBe(1);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ function localConfig(url = publicUrl) {
|
||||
}
|
||||
|
||||
function usersYaml(options: { enabled?: boolean; username?: string } = {}): string {
|
||||
return [
|
||||
const users = [
|
||||
"version: 1",
|
||||
"users:",
|
||||
` - id: ${adminId}`,
|
||||
@@ -38,8 +38,19 @@ function usersYaml(options: { enabled?: boolean; username?: string } = {}): stri
|
||||
" - admin",
|
||||
` enabled: ${options.enabled ?? true}`,
|
||||
" authRevision: 1",
|
||||
"",
|
||||
].join("\n");
|
||||
];
|
||||
if (options.enabled === false) {
|
||||
users.push(
|
||||
" - id: 6ba7b811-9dad-4ed1-80b4-00c04fd430c8",
|
||||
" username: BackupAdmin",
|
||||
` passwordHash: ${passwordHash}`,
|
||||
" roles:",
|
||||
" - admin",
|
||||
" enabled: true",
|
||||
" authRevision: 1",
|
||||
);
|
||||
}
|
||||
return [...users, ""].join("\n");
|
||||
}
|
||||
|
||||
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
|
||||
@@ -401,6 +412,58 @@ test("a verifier failure is sanitized and releases its concurrency permit", asyn
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
test("operational registry failures do dummy work, return 503, and never consume login-failure capacity", async () => {
|
||||
let available = false;
|
||||
let verificationCalls = 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 () => {
|
||||
if (!available) throw new Error("registry file is unavailable");
|
||||
return user;
|
||||
},
|
||||
findBySubject: async () => user,
|
||||
verify: async () => {
|
||||
verificationCalls += 1;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 11; attempt += 1) {
|
||||
const response = await login(app);
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||
}
|
||||
expect(verificationCalls).toBe(0);
|
||||
|
||||
available = true;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) expect((await login(app)).statusCode).toBe(401);
|
||||
expect((await login(app)).statusCode).toBe(429);
|
||||
});
|
||||
|
||||
test("operational config failures return 503 and never consume login-failure capacity", async () => {
|
||||
const { app, authConfigFile } = await createLocalApp();
|
||||
writeFileSync(authConfigFile, "version: 1\nmode: unsupported\n", { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authConfigFile, 0o600);
|
||||
|
||||
for (let attempt = 0; attempt < 11; attempt += 1) {
|
||||
const response = await login(app);
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||
}
|
||||
|
||||
writeFileSync(authConfigFile, stringify(localConfig()), { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(authConfigFile, 0o600);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
expect((await login(app, { password: `${password}!` })).statusCode).toBe(401);
|
||||
}
|
||||
expect((await login(app, { password: `${password}!` })).statusCode).toBe(429);
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
@@ -5,6 +5,8 @@ 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 trusted claims transform through nginx to a non-admin principal", () => {
|
||||
const smoke = readFileSync("../scripts/unified-deployment-smoke.sh", "utf8");
|
||||
@@ -107,6 +109,48 @@ test("upstream mode rejects legacy client identity headers without proxy princip
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user