fix(auth): pin native auth storage operations
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -35,3 +35,56 @@ test("configured OIDC advertises login but fails closed without its runtime clie
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("configured OIDC initializes login from the literal secret bundle without an environment duplicate", async () => {
|
||||
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-app-oidc-bundle-"));
|
||||
chmodSync(directory, 0o700);
|
||||
const file = join(directory, "auth.yaml");
|
||||
const bundle = join(directory, "thothii.secrets");
|
||||
const clientSecret = "bundle-only-oidc-client-secret";
|
||||
writeFileSync(file, stringify({
|
||||
version: 1, mode: "oidc", publicUrl: "https://thothii.example.org",
|
||||
oidc: {
|
||||
issuer: "https://authentik.example.org/application/o/thothii/", clientId: "thothii",
|
||||
clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid"], groupsClaim: "groups",
|
||||
},
|
||||
groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" },
|
||||
authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } },
|
||||
}), { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(bundle, `THT_OIDC_CLIENT_SECRET=${clientSecret}\nTHT_AUTHENTIK_API_TOKEN=bundle-only-authentik-token\n`, {
|
||||
encoding: "utf8", mode: 0o600,
|
||||
});
|
||||
chmodSync(file, 0o600);
|
||||
chmodSync(bundle, 0o600);
|
||||
const original = process.env.THT_OIDC_CLIENT_SECRET;
|
||||
delete process.env.THT_OIDC_CLIENT_SECRET;
|
||||
const oidcProtocolFactory = vi.fn((input: { clientSecret: string }) => ({
|
||||
authorizationUrl: async ({ state }: { state: string }) => new URL(`https://authentik.example.org/authorize?state=${state}`),
|
||||
callback: async () => { throw new Error("callback is outside this login-start regression"); },
|
||||
diagnose: async () => undefined,
|
||||
}));
|
||||
const authSessionStore = {
|
||||
createOidcState: async () => ({
|
||||
state: "s".repeat(43),
|
||||
record: { version: 1 },
|
||||
}),
|
||||
};
|
||||
try {
|
||||
const app = buildApp(loadConfig({
|
||||
NODE_ENV: "test", THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
|
||||
THT_SECRETS_FILE: bundle,
|
||||
}), { oidcProtocolFactory, authSessionStore } as never);
|
||||
try {
|
||||
const response = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(oidcProtocolFactory).toHaveBeenCalledWith(expect.objectContaining({ clientSecret }));
|
||||
expect(process.env.THT_OIDC_CLIENT_SECRET).toBeUndefined();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.THT_OIDC_CLIENT_SECRET;
|
||||
else process.env.THT_OIDC_CLIENT_SECRET = original;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createAuthenticationConfigProvider } from "../src/auth/config.js";
|
||||
import { createAuthentikGroupCatalog } from "../src/auth/authentik-group-catalog.js";
|
||||
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
|
||||
import { createOidcProtocol, OidcJwksUnavailableError } from "../src/auth/oidc-client.js";
|
||||
import { validateAuthSessionRoot } from "../src/auth/session-store.js";
|
||||
import type { LoadedAuthConfig } from "../src/auth/types.js";
|
||||
|
||||
const sentinels = [
|
||||
@@ -152,7 +151,7 @@ test("distinguishes a valid registry without an enabled admin from a malformed r
|
||||
chmodSync(validUsers, 0o600);
|
||||
const validReport = await createAuthDiagnoser({
|
||||
authMode: "local", authStateRoot: validRoot,
|
||||
sessionRootValidator: validateAuthSessionRoot,
|
||||
sessionRootValidator: acceptSessionRoot,
|
||||
authentication: { current: () => localConfig(join(validRoot, "auth.yaml")) },
|
||||
localUserRegistry: createLocalUserRegistry(validUsers),
|
||||
}).inspect({ live: false });
|
||||
@@ -164,7 +163,7 @@ test("distinguishes a valid registry without an enabled admin from a malformed r
|
||||
chmodSync(malformedUsers, 0o600);
|
||||
const malformedReport = await createAuthDiagnoser({
|
||||
authMode: "local", authStateRoot: malformedRoot,
|
||||
sessionRootValidator: validateAuthSessionRoot,
|
||||
sessionRootValidator: acceptSessionRoot,
|
||||
authentication: { current: () => localConfig(join(malformedRoot, "auth.yaml")) },
|
||||
localUserRegistry: createLocalUserRegistry(malformedUsers),
|
||||
}).inspect({ live: false });
|
||||
@@ -182,7 +181,7 @@ test("maps unsafe auth.yaml storage from the real provider to a redacted config
|
||||
chmodSync(unsafePath, 0o640);
|
||||
const report = await createAuthDiagnoser({
|
||||
authMode: "local", authStateRoot: root,
|
||||
sessionRootValidator: validateAuthSessionRoot,
|
||||
sessionRootValidator: acceptSessionRoot,
|
||||
authentication: createAuthenticationConfigProvider(unsafePath),
|
||||
}).inspect({ live: false });
|
||||
|
||||
@@ -442,73 +441,6 @@ test("redacts exceptional configuration, registry, protocol, and catalog errors"
|
||||
expect(report.checks.every((check) => check.level === "error" || check.level === "info")).toBe(true);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("uses the runtime validator for canonical, private session roots", async () => {
|
||||
const dependencies = (authStateRoot: string) => ({
|
||||
authMode: "none" as const,
|
||||
authStateRoot,
|
||||
sessionRootValidator: validateAuthSessionRoot,
|
||||
});
|
||||
const valid = privateRoot();
|
||||
await expect(createAuthDiagnoser(dependencies(valid)).inspect({ live: false }))
|
||||
.resolves.toMatchObject({ ready: true, checks: [expect.objectContaining({ code: "auth_ready" })] });
|
||||
|
||||
const realRoot = join(privateRoot(), "real-auth");
|
||||
mkdirSync(realRoot, { mode: 0o700 });
|
||||
chmodSync(realRoot, 0o700);
|
||||
const linkedRoot = join(privateRoot(), "linked-auth");
|
||||
symlinkSync(realRoot, linkedRoot);
|
||||
const absent = join(privateRoot(), "absent-auth");
|
||||
const absentParent = join(privateRoot(), "absent-parent");
|
||||
const absentNested = join(absentParent, "auth");
|
||||
const blockedParent = join(privateRoot(), "not-a-directory");
|
||||
writeFileSync(blockedParent, "blocked", { mode: 0o600 });
|
||||
const traversal = `${valid}/../${basename(valid)}`;
|
||||
|
||||
const missingReport = await createAuthDiagnoser(dependencies(absent)).inspect({ live: false });
|
||||
expect(missingReport).toMatchObject({ ready: true, checks: [expect.objectContaining({ code: "auth_ready" })] });
|
||||
expect(existsSync(absent)).toBe(false);
|
||||
|
||||
for (const unsafe of [traversal, linkedRoot, absentNested, join(blockedParent, "auth")]) {
|
||||
const report = await createAuthDiagnoser(dependencies(unsafe)).inspect({ live: false });
|
||||
expect(report).toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
|
||||
expect(JSON.stringify(report)).not.toContain(unsafe);
|
||||
}
|
||||
expect(existsSync(absentParent)).toBe(false);
|
||||
|
||||
chmodSync(valid, 0o750);
|
||||
await expect(createAuthDiagnoser(dependencies(valid)).inspect({ live: false }))
|
||||
.resolves.toMatchObject({ ready: false, checks: [expect.objectContaining({ code: "auth_session_store_invalid" })] });
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("diagnoses unsafe existing session-store children without creating missing children", async () => {
|
||||
const root = privateRoot();
|
||||
const outside = privateRoot();
|
||||
symlinkSync(outside, join(root, "sessions"));
|
||||
|
||||
const linked = await createAuthDiagnoser({
|
||||
authMode: "none", authStateRoot: root, sessionRootValidator: validateAuthSessionRoot,
|
||||
}).inspect({ live: false });
|
||||
expect(linked).toMatchObject({
|
||||
ready: false,
|
||||
checks: [expect.objectContaining({ code: "auth_session_store_invalid" })],
|
||||
});
|
||||
expect(existsSync(join(root, "oidc"))).toBe(false);
|
||||
expect(JSON.stringify(linked)).not.toContain(root);
|
||||
|
||||
rmSync(join(root, "sessions"));
|
||||
mkdirSync(join(root, "sessions"), { mode: 0o700 });
|
||||
chmodSync(join(root, "sessions"), 0o700);
|
||||
mkdirSync(join(root, "oidc"), { mode: 0o700 });
|
||||
chmodSync(join(root, "oidc"), 0o750);
|
||||
const nonPrivate = await createAuthDiagnoser({
|
||||
authMode: "none", authStateRoot: root, sessionRootValidator: validateAuthSessionRoot,
|
||||
}).inspect({ live: false });
|
||||
expect(nonPrivate).toMatchObject({
|
||||
ready: false,
|
||||
checks: [expect.objectContaining({ code: "auth_session_store_invalid" })],
|
||||
});
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("routes production POSIX static validation through the native auth-storage bridge", async () => {
|
||||
const validateRoot = vi.fn(async () => undefined);
|
||||
const report = await createAuthDiagnoser({
|
||||
|
||||
@@ -6,7 +6,7 @@ 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";
|
||||
import { prepareAuthStateRoot } from "./auth-test-fixtures.js";
|
||||
import { createFixtureAuthStorageBridge, prepareAuthStateRoot } from "./auth-test-fixtures.js";
|
||||
|
||||
const password = "correct horse battery staple";
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
@@ -54,7 +54,7 @@ test("each login and session resolve uses the current config snapshot users file
|
||||
THT_AUTH_CONFIG_FILE: authFile,
|
||||
THT_AUTH_STATE_ROOT: authStateRoot,
|
||||
THT_HARNESS_DIR: "/tmp/h",
|
||||
}));
|
||||
}), { authStorageBridgeForTest: createFixtureAuthStorageBridge() });
|
||||
cleanups.push(async () => {
|
||||
await app.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildApp, type AppWithAuthSessionStore } from "../src/app.js";
|
||||
import { loadAuthenticationConfig } from "../src/auth/config.js";
|
||||
import type { AuthenticationConfigProvider, LoadedAuthConfig } from "../src/auth/types.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { prepareAuthStateRoot } from "./auth-test-fixtures.js";
|
||||
import { createFixtureAuthStorageBridge, prepareAuthStateRoot } from "./auth-test-fixtures.js";
|
||||
|
||||
const password = "correct horse battery staple";
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
@@ -87,7 +87,7 @@ async function createFixture(first: "A" | "B", later: "A" | "B" | "oidc") {
|
||||
THT_HARNESS_DIR: "/tmp/h",
|
||||
});
|
||||
config.authentication = provider;
|
||||
const app = buildApp(config) as AppWithAuthSessionStore;
|
||||
const app = buildApp(config, { authStorageBridgeForTest: createFixtureAuthStorageBridge() }) as AppWithAuthSessionStore;
|
||||
cleanups.push(async () => {
|
||||
await app.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
|
||||
@@ -6,7 +6,7 @@ import { stringify } from "yaml";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { LoginFailureLimiter } from "../src/auth/routes.js";
|
||||
import { prepareAuthStateRoot } from "./auth-test-fixtures.js";
|
||||
import { createFixtureAuthStorageBridge, prepareAuthStateRoot } from "./auth-test-fixtures.js";
|
||||
|
||||
const password = "correct horse battery staple";
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
@@ -88,7 +88,10 @@ async function createLocalApp(options: {
|
||||
THT_AUTH_CONFIG_FILE: authConfigFile,
|
||||
THT_AUTH_STATE_ROOT: authStateRoot,
|
||||
THT_HARNESS_DIR: "/tmp/h",
|
||||
}), options.registry === undefined ? undefined : { localUserRegistry: options.registry } as any);
|
||||
}), {
|
||||
...(options.registry === undefined ? {} : { localUserRegistry: options.registry }),
|
||||
authStorageBridgeForTest: createFixtureAuthStorageBridge(),
|
||||
} as any);
|
||||
cleanups.push(async () => {
|
||||
await app.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
@@ -155,7 +158,7 @@ test("remembered login uses a persistent secure cookie under an HTTPS public URL
|
||||
chmodSync(usersFile, 0o600);
|
||||
prepareAuthStateRoot(authStateRoot);
|
||||
const config = () => loadConfig({ THT_AUTH_CONFIG_FILE: authConfigFile, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h" });
|
||||
const first = buildApp(config());
|
||||
const first = buildApp(config(), { authStorageBridgeForTest: createFixtureAuthStorageBridge() });
|
||||
try {
|
||||
const signedIn = await first.inject({
|
||||
method: "POST",
|
||||
@@ -169,7 +172,7 @@ test("remembered login uses a persistent secure cookie under an HTTPS public URL
|
||||
expect(setCookie).toContain("Secure");
|
||||
await first.close();
|
||||
|
||||
const restarted = buildApp(config());
|
||||
const restarted = buildApp(config(), { authStorageBridgeForTest: createFixtureAuthStorageBridge() });
|
||||
cleanups.push(async () => {
|
||||
await restarted.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
|
||||
@@ -7,18 +7,20 @@ import {
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
opendirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const fsHooks = vi.hoisted(() => ({
|
||||
@@ -66,12 +68,15 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
import {
|
||||
createFileAuthSessionStore,
|
||||
deriveCsrfToken,
|
||||
validateAuthSessionRoot,
|
||||
type AuthSessionStore,
|
||||
type FileAuthSessionStoreOptions,
|
||||
type SessionCreateInput,
|
||||
} from "../src/auth/session-store.js";
|
||||
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
|
||||
import {
|
||||
createWindowsAuthStorageBridge,
|
||||
type WindowsAuthStorageBridge,
|
||||
type WindowsAuthStorageDirectory,
|
||||
} from "../src/auth/windows-auth-storage.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const base = new Date("2030-01-02T03:04:05.000Z");
|
||||
@@ -123,11 +128,203 @@ function claimPath(rootPath: string, rawState: string): string {
|
||||
return join(rootPath, "oidc", `${createHash("sha256").update(rawState).digest("hex")}.claim`);
|
||||
}
|
||||
|
||||
// This fixture adapter keeps session-store behavior tests self-contained. Production has no
|
||||
// Node filesystem fallback: it always uses the hidden Go bridge. The Go authstorage suite owns
|
||||
// descriptor-pinning/race assertions; this adapter only supplies ordinary fixture semantics.
|
||||
function testPosixStorageBridge(
|
||||
ensureOverride?: (rootPath: string) => Promise<void>,
|
||||
): WindowsAuthStorageBridge {
|
||||
let overrideUsed = false;
|
||||
const ensure = async (rootPath: string): Promise<void> => {
|
||||
if (ensureOverride && !overrideUsed) {
|
||||
overrideUsed = true;
|
||||
await ensureOverride(rootPath);
|
||||
return;
|
||||
}
|
||||
if (!existsSync(rootPath)) {
|
||||
if (realpathSync(dirname(rootPath)) !== dirname(rootPath)) throw new Error("invalid");
|
||||
mkdirSync(rootPath, { mode: 0o700 });
|
||||
chmodSync(rootPath, 0o700);
|
||||
}
|
||||
const rootInfo = lstatSync(rootPath);
|
||||
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o7777) !== 0o700) throw new Error("invalid");
|
||||
for (const child of ["sessions", "oidc"]) {
|
||||
const path = join(rootPath, child);
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(path, { mode: 0o700 });
|
||||
chmodSync(path, 0o700);
|
||||
}
|
||||
const info = lstatSync(path);
|
||||
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o7777) !== 0o700) throw new Error("invalid");
|
||||
}
|
||||
};
|
||||
const directory = async (rootPath: string, name: WindowsAuthStorageDirectory): Promise<string> => {
|
||||
await ensure(rootPath);
|
||||
return join(rootPath, name);
|
||||
};
|
||||
const record = (path: string, links: readonly number[]): import("node:fs").Stats => {
|
||||
const info = lstatSync(path);
|
||||
if (!info.isFile() || info.isSymbolicLink() || !links.includes(info.nlink) || (info.mode & 0o7777) !== 0o600
|
||||
|| (typeof process.geteuid === "function" && info.uid !== process.geteuid())) {
|
||||
throw new Error("invalid");
|
||||
}
|
||||
return info;
|
||||
};
|
||||
const same = (left: import("node:fs").Stats, right: import("node:fs").Stats): boolean =>
|
||||
left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink;
|
||||
const paths = async (rootPath: string, name: WindowsAuthStorageDirectory, filename: string): Promise<string> =>
|
||||
join(await directory(rootPath, name), filename);
|
||||
const listNames = async (
|
||||
rootPath: string,
|
||||
name: WindowsAuthStorageDirectory,
|
||||
maximumEntries: number,
|
||||
): Promise<{ name: string; modifiedUnixMs: number }[]> => {
|
||||
const handle = opendirSync(await directory(rootPath, name));
|
||||
const entries: string[] = [];
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = handle.readSync();
|
||||
if (entry === null) break;
|
||||
entries.push(entry.name);
|
||||
if (entries.length > maximumEntries) throw new Error("invalid");
|
||||
}
|
||||
} finally {
|
||||
handle.closeSync();
|
||||
}
|
||||
return entries.sort().map((filename) => {
|
||||
const links = name === "oidc" ? [1, 2] : [1];
|
||||
const info = record(join(rootPath, name, filename), links);
|
||||
return { name: filename, modifiedUnixMs: info.mtimeMs };
|
||||
});
|
||||
};
|
||||
return {
|
||||
validateRoot: async (rootPath) => { await ensure(rootPath); },
|
||||
ensureLayout: ensure,
|
||||
readAuthConfig: (path) => readFileSync(path),
|
||||
readLocalUsers: async (path) => readFileSync(path),
|
||||
create: async (rootPath, name, filename, contents) => {
|
||||
const path = await paths(rootPath, name, filename);
|
||||
try {
|
||||
writeFileSync(path, contents, { flag: "wx", mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
record(path, [1]);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
read: async (rootPath, name, filename) => {
|
||||
const path = await paths(rootPath, name, filename);
|
||||
try {
|
||||
record(path, [1]);
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
return readFileSync(path);
|
||||
},
|
||||
replace: async (rootPath, name, filename, contents) => {
|
||||
const path = await paths(rootPath, name, filename);
|
||||
record(path, [1]);
|
||||
const temporary = `${path}.test-replacement`;
|
||||
writeFileSync(temporary, contents, { flag: "wx", mode: 0o600 });
|
||||
chmodSync(temporary, 0o600);
|
||||
renameSync(temporary, path);
|
||||
},
|
||||
remove: async (rootPath, name, filename) => {
|
||||
const path = await paths(rootPath, name, filename);
|
||||
try {
|
||||
record(path, [1]);
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
unlinkSync(path);
|
||||
return true;
|
||||
},
|
||||
list: async (rootPath, name, maximumEntries = 256) => await listNames(rootPath, name, maximumEntries),
|
||||
listPage: async (rootPath, name, afterName, maximumEntries) => {
|
||||
if (name !== "sessions") throw new Error("invalid");
|
||||
const handle = opendirSync(await directory(rootPath, name));
|
||||
const all: string[] = [];
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = handle.readSync();
|
||||
if (entry === null) break;
|
||||
all.push(entry.name);
|
||||
}
|
||||
} finally {
|
||||
handle.closeSync();
|
||||
}
|
||||
all.sort();
|
||||
for (const filename of all) record(join(rootPath, name, filename), [1]);
|
||||
const selected = all.filter((filename) => afterName === undefined || filename > afterName).slice(0, maximumEntries + 1);
|
||||
return {
|
||||
entries: selected.slice(0, maximumEntries).map((filename) => {
|
||||
const info = statSync(join(rootPath, name, filename));
|
||||
return { name: filename, modifiedUnixMs: info.mtimeMs };
|
||||
}),
|
||||
more: selected.length > maximumEntries,
|
||||
};
|
||||
},
|
||||
claimConsume: async (rootPath, filename) => {
|
||||
const source = await paths(rootPath, "oidc", filename);
|
||||
const claim = source.replace(/\.json$/, ".claim");
|
||||
try {
|
||||
record(source, [1]);
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return undefined;
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
linkSync(source, claim);
|
||||
} catch (error: any) {
|
||||
if (error?.code === "EEXIST") return undefined;
|
||||
throw error;
|
||||
}
|
||||
const contents = readFileSync(source);
|
||||
unlinkSync(source);
|
||||
unlinkSync(claim);
|
||||
return contents;
|
||||
},
|
||||
readClaim: async (rootPath, filename) => {
|
||||
const source = await paths(rootPath, "oidc", filename);
|
||||
const claim = source.replace(/\.json$/, ".claim");
|
||||
try {
|
||||
const sourceInfo = record(source, [2]);
|
||||
const claimInfo = record(claim, [2]);
|
||||
if (!same(sourceInfo, claimInfo)) throw new Error("invalid");
|
||||
return readFileSync(source);
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return undefined;
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
removeClaim: async (rootPath, filename) => {
|
||||
const source = await paths(rootPath, "oidc", filename);
|
||||
const claim = source.replace(/\.json$/, ".claim");
|
||||
try {
|
||||
const sourceInfo = record(source, [2]);
|
||||
const claimInfo = record(claim, [2]);
|
||||
if (!same(sourceInfo, claimInfo)) throw new Error("invalid");
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return false;
|
||||
return false;
|
||||
}
|
||||
unlinkSync(source);
|
||||
unlinkSync(claim);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validStore(storageRoot: string, options: FileAuthSessionStoreOptions = {}): AuthSessionStore {
|
||||
const posixStorageBridge = options.posixStorageBridge ?? testPosixStorageBridge();
|
||||
return createFileAuthSessionStore(storageRoot, {
|
||||
currentAuthConfigRevision: () => revision,
|
||||
findLocalUser: async () => validLocalUser,
|
||||
}, options);
|
||||
}, { ...options, posixStorageBridge });
|
||||
}
|
||||
|
||||
async function create(
|
||||
@@ -246,56 +443,7 @@ async function isolatedOidcCreator(storageRoot: string, attempts: number): Promi
|
||||
}
|
||||
|
||||
describe("file-backed auth session store", () => {
|
||||
test.skipIf(process.platform === "win32")("exports its side-effect-free canonical session-root validator", () => {
|
||||
const valid = root();
|
||||
expect(() => validateAuthSessionRoot(valid)).not.toThrow();
|
||||
|
||||
const outer = root();
|
||||
const realRoot = join(outer, "real-auth");
|
||||
mkdirSync(realRoot, { mode: 0o700 });
|
||||
chmodSync(realRoot, 0o700);
|
||||
const linkedRoot = join(outer, "linked-auth");
|
||||
symlinkSync(realRoot, linkedRoot);
|
||||
const traversal = `${valid}/../${basename(valid)}`;
|
||||
const absent = join(outer, "absent-auth");
|
||||
const absentNested = join(outer, "absent-parent", "auth");
|
||||
const fileParent = join(outer, "not-a-directory");
|
||||
writeFileSync(fileParent, "blocked", { mode: 0o600 });
|
||||
|
||||
expect(() => validateAuthSessionRoot(absent)).not.toThrow();
|
||||
expect(existsSync(absent)).toBe(false);
|
||||
for (const unsafe of [traversal, linkedRoot, absentNested, join(fileParent, "auth")]) {
|
||||
expect(() => validateAuthSessionRoot(unsafe)).toThrow("auth_session_store_invalid");
|
||||
}
|
||||
expect(existsSync(join(outer, "absent-parent"))).toBe(false);
|
||||
|
||||
for (const child of ["sessions", "oidc"] as const) {
|
||||
const childRoot = join(outer, `child-${child}`);
|
||||
mkdirSync(childRoot, { mode: 0o700 });
|
||||
chmodSync(childRoot, 0o700);
|
||||
const childPath = join(childRoot, child);
|
||||
const outside = root();
|
||||
symlinkSync(outside, childPath);
|
||||
expect(() => validateAuthSessionRoot(childRoot)).toThrow("auth_session_store_invalid");
|
||||
expect(readdirSync(outside).sort()).toEqual(["oidc", "sessions"]);
|
||||
unlinkSync(childPath);
|
||||
mkdirSync(childPath, { mode: 0o700 });
|
||||
chmodSync(childPath, 0o750);
|
||||
expect(() => validateAuthSessionRoot(childRoot)).toThrow("auth_session_store_invalid");
|
||||
}
|
||||
|
||||
const missingChildren = join(outer, "missing-children");
|
||||
mkdirSync(missingChildren, { mode: 0o700 });
|
||||
chmodSync(missingChildren, 0o700);
|
||||
expect(() => validateAuthSessionRoot(missingChildren)).not.toThrow();
|
||||
expect(existsSync(join(missingChildren, "sessions"))).toBe(false);
|
||||
expect(existsSync(join(missingChildren, "oidc"))).toBe(false);
|
||||
|
||||
chmodSync(valid, 0o750);
|
||||
expect(() => validateAuthSessionRoot(valid)).toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("delegates missing layout creation and then enforces static/runtime parity", async () => {
|
||||
test.skipIf(process.platform === "win32")("delegates missing layout creation to the retained native bridge", async () => {
|
||||
const storageRoot = join(root(), "auth");
|
||||
const ensureLayout = vi.fn(async (requestedRoot: string) => {
|
||||
expect(requestedRoot).toBe(storageRoot);
|
||||
@@ -306,14 +454,12 @@ describe("file-backed auth session store", () => {
|
||||
chmodSync(join(requestedRoot, child), 0o700);
|
||||
}
|
||||
});
|
||||
const store = validStore(storageRoot, { posixStorageBridge: { ensureLayout } });
|
||||
const store = validStore(storageRoot, { posixStorageBridge: testPosixStorageBridge(ensureLayout) });
|
||||
|
||||
await expect(create(store)).resolves.toMatchObject({ record: { method: "local" } });
|
||||
expect(ensureLayout).toHaveBeenCalledOnce();
|
||||
expect(() => validateAuthSessionRoot(storageRoot)).not.toThrow();
|
||||
|
||||
chmodSync(join(storageRoot, "oidc"), 0o750);
|
||||
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
|
||||
await expectStoreInvalid(store.createOidcState(oidcInput("n".repeat(16), "v".repeat(43)), base));
|
||||
});
|
||||
|
||||
@@ -330,7 +476,7 @@ describe("file-backed auth session store", () => {
|
||||
symlinkSync(outside, parent);
|
||||
throw new Error(`${storageRoot} rejected`);
|
||||
});
|
||||
const store = validStore(storageRoot, { posixStorageBridge: { ensureLayout } });
|
||||
const store = validStore(storageRoot, { posixStorageBridge: testPosixStorageBridge(ensureLayout) });
|
||||
|
||||
await expectStoreInvalid(create(store));
|
||||
expect(ensureLayout).toHaveBeenCalledOnce();
|
||||
@@ -349,14 +495,13 @@ describe("file-backed auth session store", () => {
|
||||
expect(existsSync(join(outside, "auth"))).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("rejects an uncreatable missing root without side effects in static and runtime paths", async () => {
|
||||
test.skipIf(process.platform === "win32")("rejects an uncreatable missing root without side effects", async () => {
|
||||
const outer = root();
|
||||
const lockedParent = join(outer, "locked-parent");
|
||||
mkdirSync(lockedParent, { mode: 0o700 });
|
||||
chmodSync(lockedParent, 0o500);
|
||||
const storageRoot = join(lockedParent, "auth");
|
||||
try {
|
||||
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
|
||||
await expectStoreInvalid(create(validStore(storageRoot)));
|
||||
expect(existsSync(storageRoot)).toBe(false);
|
||||
} finally {
|
||||
@@ -364,43 +509,20 @@ describe("file-backed auth session store", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("detects an ancestor replacement before creating any session directory", async () => {
|
||||
const outer = root();
|
||||
const outside = root();
|
||||
const parent = join(outer, "parent");
|
||||
const movedParent = join(outer, "parent-original");
|
||||
mkdirSync(parent, { mode: 0o700 });
|
||||
chmodSync(parent, 0o700);
|
||||
let replaced = false;
|
||||
fsHooks.afterLstat = (observed) => {
|
||||
if (observed !== parent) return false;
|
||||
renameSync(parent, movedParent);
|
||||
symlinkSync(outside, parent);
|
||||
replaced = true;
|
||||
return true;
|
||||
};
|
||||
test("does not fall back to Node paths when the retained POSIX bridge rejects creation", async () => {
|
||||
const storageRoot = join(root(), "auth");
|
||||
const bridge = {
|
||||
...testPosixStorageBridge(),
|
||||
create: async () => { throw new Error("native create rejected"); },
|
||||
} as WindowsAuthStorageBridge;
|
||||
|
||||
await expectStoreInvalid(create(validStore(join(parent, "auth"))));
|
||||
expect(replaced).toBe(true);
|
||||
expect(existsSync(join(outside, "auth"))).toBe(false);
|
||||
expect(existsSync(join(movedParent, "auth"))).toBe(false);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("rejects a session root owned by another identity", () => {
|
||||
const storageRoot = root();
|
||||
fsHooks.transformLstat = (observed, info) => {
|
||||
if (observed !== storageRoot) return info;
|
||||
const foreign = Object.create(info) as import("node:fs").Stats;
|
||||
Object.defineProperty(foreign, "uid", { value: info.uid + 1 });
|
||||
return foreign;
|
||||
};
|
||||
|
||||
expect(() => validateAuthSessionRoot(storageRoot)).toThrow("auth_session_store_invalid");
|
||||
await expectStoreInvalid(create(validStore(storageRoot, { posixStorageBridge: bridge })));
|
||||
expect(existsSync(storageRoot)).toBe(false);
|
||||
});
|
||||
|
||||
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
|
||||
const storageRoot = root();
|
||||
const store = createFileAuthSessionStore(storageRoot);
|
||||
const store = createFileAuthSessionStore(storageRoot, undefined, { posixStorageBridge: testPosixStorageBridge() });
|
||||
const created = await create(store);
|
||||
|
||||
await expect(store.resolve(created.token)).resolves.toBeUndefined();
|
||||
@@ -412,7 +534,7 @@ describe("file-backed auth session store", () => {
|
||||
const store = createFileAuthSessionStore(storageRoot, {
|
||||
currentAuthConfigRevision: () => { throw new Error("dependency unavailable"); },
|
||||
findLocalUser: async () => validLocalUser,
|
||||
});
|
||||
}, { posixStorageBridge: testPosixStorageBridge() });
|
||||
const created = await create(store);
|
||||
|
||||
await expectStoreInvalid(store.resolve(created.token));
|
||||
@@ -562,21 +684,13 @@ describe("file-backed auth session store", () => {
|
||||
.rejects.toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("fails closed if the ordinary-session directory changes during a page scan", async () => {
|
||||
test("fails closed when the retained POSIX bridge rejects a session continuation page", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const seed = await create(store, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 30 * 60_000 });
|
||||
const contents = readFileSync(digestPath(storageRoot, "sessions", seed.token));
|
||||
unlinkSync(digestPath(storageRoot, "sessions", seed.token));
|
||||
const filename = sessionFilename(0);
|
||||
const sessionsDirectory = join(storageRoot, "sessions");
|
||||
writeFileSync(join(sessionsDirectory, filename), contents, { mode: 0o600 });
|
||||
fsHooks.beforeLstat = (path) => {
|
||||
if (path !== join(sessionsDirectory, filename)) return false;
|
||||
const changed = new Date(base.getTime() + 60 * 60_000);
|
||||
utimesSync(sessionsDirectory, changed, changed);
|
||||
return true;
|
||||
};
|
||||
const bridge = {
|
||||
...testPosixStorageBridge(),
|
||||
listPage: async () => { throw new Error("native page rejected"); },
|
||||
} as WindowsAuthStorageBridge;
|
||||
const store = validStore(storageRoot, { posixStorageBridge: bridge });
|
||||
|
||||
await expect(store.prune(new Date(base.getTime() + 2 * 60_000)))
|
||||
.rejects.toThrow("auth_session_store_invalid");
|
||||
@@ -853,46 +967,38 @@ describe("file-backed auth session store", () => {
|
||||
await expectStoreInvalid(create(validStore(linkedRoot)));
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("refuses storage owned by a different identity", async () => {
|
||||
test("fails closed when the retained POSIX bridge rejects a session read", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const bridge = {
|
||||
...testPosixStorageBridge(),
|
||||
read: async () => { throw new Error("native read rejected"); },
|
||||
} as WindowsAuthStorageBridge;
|
||||
const store = validStore(storageRoot, { posixStorageBridge: bridge });
|
||||
const created = await create(store);
|
||||
const sessions = join(storageRoot, "sessions");
|
||||
fsHooks.transformLstat = (observed, info) => {
|
||||
if (observed !== sessions) return info;
|
||||
const foreign = Object.create(info) as import("node:fs").Stats;
|
||||
Object.defineProperty(foreign, "uid", { value: info.uid + 1 });
|
||||
return foreign;
|
||||
};
|
||||
|
||||
await expectStoreInvalid(store.resolve(created.token));
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("refuses directory replacement during a session read", async () => {
|
||||
test("does not bypass a retained POSIX bridge read failure", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const bridge = {
|
||||
...testPosixStorageBridge(),
|
||||
read: async () => { throw new Error("native root replacement rejected"); },
|
||||
} as WindowsAuthStorageBridge;
|
||||
const store = validStore(storageRoot, { posixStorageBridge: bridge });
|
||||
const created = await create(store);
|
||||
const sessions = join(storageRoot, "sessions");
|
||||
const replacement = join(storageRoot, "sessions-replacement");
|
||||
fsHooks.afterRead = () => {
|
||||
renameSync(sessions, replacement);
|
||||
symlinkSync(replacement, sessions);
|
||||
};
|
||||
|
||||
await expectStoreInvalid(store.resolve(created.token));
|
||||
});
|
||||
|
||||
test("refuses file replacement during a touch", async () => {
|
||||
test("fails closed when the retained POSIX bridge rejects a session replacement", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const bridge = {
|
||||
...testPosixStorageBridge(),
|
||||
replace: async () => { throw new Error("native replace rejected"); },
|
||||
} as WindowsAuthStorageBridge;
|
||||
const store = validStore(storageRoot, { posixStorageBridge: bridge });
|
||||
const created = await create(store);
|
||||
const path = digestPath(storageRoot, "sessions", created.token);
|
||||
const replacement = `${path}.replacement`;
|
||||
fsHooks.afterWrite = () => {
|
||||
writeFileSync(replacement, "{}", { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
renameSync(replacement, path);
|
||||
};
|
||||
|
||||
await expectStoreInvalid(store.touch(created.token, new Date(base.getTime() + 5 * 60_000)));
|
||||
});
|
||||
@@ -914,37 +1020,26 @@ describe("file-backed auth session store", () => {
|
||||
await expectStoreInvalid(store.touch(created.token, new Date(base.getTime() + 5 * 60_000)));
|
||||
});
|
||||
|
||||
test("refuses file replacement during revoke", async () => {
|
||||
test("fails closed when the retained POSIX bridge rejects a session removal", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const bridge = {
|
||||
...testPosixStorageBridge(),
|
||||
remove: async () => { throw new Error("native remove rejected"); },
|
||||
} as WindowsAuthStorageBridge;
|
||||
const store = validStore(storageRoot, { posixStorageBridge: bridge });
|
||||
const created = await create(store);
|
||||
const path = digestPath(storageRoot, "sessions", created.token);
|
||||
const replacement = `${path}.replacement`;
|
||||
writeFileSync(replacement, "{}", { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(replacement, 0o600);
|
||||
fsHooks.afterLstat = (observed) => {
|
||||
if (observed !== path) return false;
|
||||
renameSync(replacement, path);
|
||||
return true;
|
||||
};
|
||||
|
||||
await expectStoreInvalid(store.revoke(created.token));
|
||||
expect(readFileSync(path, "utf8")).toBe("{}");
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("refuses directory replacement during revoke", async () => {
|
||||
test("does not bypass a retained POSIX bridge removal failure", async () => {
|
||||
const storageRoot = root();
|
||||
const store = validStore(storageRoot);
|
||||
const bridge = {
|
||||
...testPosixStorageBridge(),
|
||||
remove: async () => { throw new Error("native root replacement rejected"); },
|
||||
} as WindowsAuthStorageBridge;
|
||||
const store = validStore(storageRoot, { posixStorageBridge: bridge });
|
||||
const created = await create(store);
|
||||
const path = digestPath(storageRoot, "sessions", created.token);
|
||||
const sessions = join(storageRoot, "sessions");
|
||||
const replacement = join(storageRoot, "sessions-replacement");
|
||||
fsHooks.afterLstat = (observed) => {
|
||||
if (observed !== path) return false;
|
||||
renameSync(sessions, replacement);
|
||||
symlinkSync(replacement, sessions);
|
||||
return true;
|
||||
};
|
||||
|
||||
await expectStoreInvalid(store.revoke(created.token));
|
||||
});
|
||||
@@ -1354,7 +1449,7 @@ describe("file-backed auth session store", () => {
|
||||
const store = createFileAuthSessionStore(storageRoot, {
|
||||
currentAuthConfigRevision: () => currentRevision,
|
||||
findLocalUser: async () => localUser,
|
||||
});
|
||||
}, { posixStorageBridge: testPosixStorageBridge() });
|
||||
|
||||
const configChanged = await create(store);
|
||||
currentRevision = "b".repeat(64);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { stringify } from "yaml";
|
||||
import { buildApp, type BuildAppDeps } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { createFixturePosixAuthStorageBridge } from "./fixtures/posix-auth-storage-bridge.mjs";
|
||||
|
||||
export const localPassword = "correct horse battery staple";
|
||||
export const localPasswordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
|
||||
@@ -47,6 +48,11 @@ export function prepareAuthStateRoot(root: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit test-only substitute for the production tht-backed POSIX storage bridge. */
|
||||
export function createFixtureAuthStorageBridge() {
|
||||
return createFixturePosixAuthStorageBridge();
|
||||
}
|
||||
|
||||
/** Creates a production-local app and authenticates through the real login/session boundary. */
|
||||
export async function createLocalAuthFixture(
|
||||
deps?: BuildAppDeps,
|
||||
@@ -82,11 +88,17 @@ export async function createLocalAuthFixture(
|
||||
|
||||
const authStateRoot = join(directory, "auth-state");
|
||||
prepareAuthStateRoot(authStateRoot);
|
||||
const app = buildApp(loadConfig({
|
||||
THT_AUTH_CONFIG_FILE: authConfigFile,
|
||||
THT_AUTH_STATE_ROOT: authStateRoot,
|
||||
THT_HARNESS_DIR: "/tmp/h",
|
||||
}), deps);
|
||||
const app = buildApp(
|
||||
loadConfig({
|
||||
THT_AUTH_CONFIG_FILE: authConfigFile,
|
||||
THT_AUTH_STATE_ROOT: authStateRoot,
|
||||
THT_HARNESS_DIR: "/tmp/h",
|
||||
}),
|
||||
{
|
||||
...deps,
|
||||
authStorageBridgeForTest: deps?.authStorageBridgeForTest ?? createFixtureAuthStorageBridge(),
|
||||
},
|
||||
);
|
||||
let downstream = 0;
|
||||
app.addHook("preHandler", async () => { downstream += 1; });
|
||||
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
import { createFileAuthSessionStore } from "../../src/auth/session-store.js";
|
||||
import { createFixturePosixAuthStorageBridge } from "./posix-auth-storage-bridge.mjs";
|
||||
|
||||
const root = process.env.THT_TEST_SESSION_ROOT;
|
||||
const state = process.env.THT_TEST_OIDC_STATE;
|
||||
@@ -9,7 +10,9 @@ process.send("ready");
|
||||
process.once("message", async (message) => {
|
||||
if (message !== "consume") process.exit(3);
|
||||
try {
|
||||
const record = await createFileAuthSessionStore(root).consumeOidcState(state);
|
||||
const record = await createFileAuthSessionStore(root, undefined, {
|
||||
posixStorageBridge: createFixturePosixAuthStorageBridge(),
|
||||
}).consumeOidcState(state);
|
||||
process.send?.({ consumed: record !== undefined });
|
||||
process.exit(0);
|
||||
} catch {
|
||||
|
||||
+5
-1
@@ -1,4 +1,5 @@
|
||||
import { createFileAuthSessionStore } from "../../src/auth/session-store.js";
|
||||
import { createFixturePosixAuthStorageBridge } from "./posix-auth-storage-bridge.mjs";
|
||||
|
||||
const root = process.env.THT_TEST_SESSION_ROOT;
|
||||
const attempts = Number(process.env.THT_TEST_OIDC_ATTEMPTS);
|
||||
@@ -7,7 +8,10 @@ const capacity = Number(process.env.THT_TEST_OIDC_CAPACITY);
|
||||
if (!root || !Number.isSafeInteger(attempts) || attempts < 1 || Number.isNaN(now.getTime())
|
||||
|| !Number.isSafeInteger(capacity) || capacity < 1) process.exit(2);
|
||||
|
||||
const store = createFileAuthSessionStore(root, undefined, { oidcStateCapacity: capacity });
|
||||
const store = createFileAuthSessionStore(root, undefined, {
|
||||
oidcStateCapacity: capacity,
|
||||
posixStorageBridge: createFixturePosixAuthStorageBridge(),
|
||||
});
|
||||
process.send?.("ready");
|
||||
process.once("message", async (message) => {
|
||||
if (message !== "create") process.exit(3);
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
opendirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
WindowsAuthStorageBridge,
|
||||
WindowsAuthStorageDirectory,
|
||||
} from "../../src/auth/windows-auth-storage.js";
|
||||
|
||||
// Test-process fixture only. The production POSIX implementation is the Go auth-storage bridge;
|
||||
// the Go package covers retained-descriptor adversarial races separately.
|
||||
export function createFixturePosixAuthStorageBridge(): WindowsAuthStorageBridge {
|
||||
const ensure = async (root: string): Promise<void> => {
|
||||
if (!existsSync(root)) {
|
||||
mkdirSync(root, { mode: 0o700 });
|
||||
chmodSync(root, 0o700);
|
||||
}
|
||||
for (const name of ["sessions", "oidc"]) {
|
||||
const path = join(root, name);
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(path, { mode: 0o700 });
|
||||
chmodSync(path, 0o700);
|
||||
}
|
||||
}
|
||||
};
|
||||
const path = async (root: string, directory: WindowsAuthStorageDirectory, filename: string): Promise<string> => {
|
||||
await ensure(root);
|
||||
return join(root, directory, filename);
|
||||
};
|
||||
const names = async (root: string, directory: WindowsAuthStorageDirectory): Promise<string[]> => {
|
||||
await ensure(root);
|
||||
const handle = opendirSync(join(root, directory));
|
||||
const result: string[] = [];
|
||||
try {
|
||||
for (;;) {
|
||||
const entry = handle.readSync();
|
||||
if (entry === null) break;
|
||||
result.push(entry.name);
|
||||
}
|
||||
} finally {
|
||||
handle.closeSync();
|
||||
}
|
||||
return result.sort();
|
||||
};
|
||||
const missing = (error: unknown): boolean => (error as { code?: unknown })?.code === "ENOENT";
|
||||
return {
|
||||
validateRoot: ensure,
|
||||
ensureLayout: ensure,
|
||||
readAuthConfig: (value) => readFileSync(value),
|
||||
readLocalUsers: async (value) => readFileSync(value),
|
||||
create: async (root, directory, filename, contents) => {
|
||||
try {
|
||||
const value = await path(root, directory, filename);
|
||||
writeFileSync(value, contents, { flag: "wx", mode: 0o600 });
|
||||
chmodSync(value, 0o600);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (missing(error) || (error as { code?: unknown })?.code === "EEXIST") return false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
read: async (root, directory, filename) => {
|
||||
try { return readFileSync(await path(root, directory, filename)); } catch (error) {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
replace: async (root, directory, filename, contents) => {
|
||||
const value = await path(root, directory, filename);
|
||||
const temporary = `${value}.fixture-replacement`;
|
||||
writeFileSync(temporary, contents, { flag: "wx", mode: 0o600 });
|
||||
renameSync(temporary, value);
|
||||
},
|
||||
remove: async (root, directory, filename) => {
|
||||
try {
|
||||
unlinkSync(await path(root, directory, filename));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (missing(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
list: async (root, directory, maximumEntries = 256) => {
|
||||
const result = await names(root, directory);
|
||||
if (result.length > maximumEntries) throw new Error("fixture list overflow");
|
||||
return result.map((name) => ({ name, modifiedUnixMs: lstatSync(join(root, directory, name)).mtimeMs }));
|
||||
},
|
||||
listPage: async (root, directory, afterName, maximumEntries) => {
|
||||
if (directory !== "sessions") throw new Error("fixture directory invalid");
|
||||
const selected = (await names(root, directory)).filter((name) => afterName === undefined || name > afterName);
|
||||
return {
|
||||
entries: selected.slice(0, maximumEntries).map((name) => ({
|
||||
name, modifiedUnixMs: lstatSync(join(root, directory, name)).mtimeMs,
|
||||
})),
|
||||
more: selected.length > maximumEntries,
|
||||
};
|
||||
},
|
||||
claimConsume: async (root, filename) => {
|
||||
const source = await path(root, "oidc", filename);
|
||||
const claim = source.replace(/\.json$/, ".claim");
|
||||
try {
|
||||
linkSync(source, claim);
|
||||
} catch (error) {
|
||||
if (missing(error) || (error as { code?: unknown })?.code === "EEXIST") return undefined;
|
||||
throw error;
|
||||
}
|
||||
const contents = readFileSync(source);
|
||||
unlinkSync(source);
|
||||
unlinkSync(claim);
|
||||
return contents;
|
||||
},
|
||||
readClaim: async (root, filename) => {
|
||||
const source = await path(root, "oidc", filename);
|
||||
const claim = source.replace(/\.json$/, ".claim");
|
||||
try {
|
||||
const left = lstatSync(source);
|
||||
const right = lstatSync(claim);
|
||||
if (left.ino !== right.ino || left.dev !== right.dev || left.nlink !== 2 || right.nlink !== 2) return undefined;
|
||||
return readFileSync(source);
|
||||
} catch (error) {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
removeClaim: async (root, filename) => {
|
||||
const source = await path(root, "oidc", filename);
|
||||
const claim = source.replace(/\.json$/, ".claim");
|
||||
try {
|
||||
const left = lstatSync(source);
|
||||
const right = lstatSync(claim);
|
||||
if (left.ino !== right.ino || left.dev !== right.dev || left.nlink !== 2 || right.nlink !== 2) return false;
|
||||
unlinkSync(source);
|
||||
unlinkSync(claim);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (missing(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ writeFileSync(marker, `started:${process.pid}\n`);
|
||||
process.on("exit", () => appendFileSync(marker, "exited\n"));
|
||||
process.on("SIGTERM", () => {
|
||||
appendFileSync(marker, "terminated\n");
|
||||
process.exit(0);
|
||||
if (mode !== "timeout") process.exit(0);
|
||||
});
|
||||
|
||||
if (mode === "stdin") {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { createLocalUserRegistry } from "../src/auth/local-registry.js";
|
||||
|
||||
const password = "correct horse battery staple";
|
||||
@@ -204,4 +204,28 @@ describe("local user registry", () => {
|
||||
|
||||
await expect(registry.findByUsername("admin")).resolves.toMatchObject({ displayName: "Owner" });
|
||||
});
|
||||
|
||||
test("routes native Windows users.yaml loading only through the bounded auth-storage bridge", async () => {
|
||||
const usersPath = "C:\\ProgramData\\ThothII\\auth\\users.yaml";
|
||||
const readLocalUsers = vi.fn(async (path: string) => {
|
||||
expect(path).toBe(usersPath);
|
||||
return Buffer.from(registryYaml(userYaml({ displayName: "Bridge administrator" })), "utf8");
|
||||
});
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
if (!originalPlatform) throw new Error("platform descriptor unavailable");
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
try {
|
||||
const registry = createLocalUserRegistry(usersPath, { windowsStorageBridge: { readLocalUsers } } as never);
|
||||
await expect(registry.findByUsername("ADMIN")).resolves.toMatchObject({
|
||||
id: adminId,
|
||||
displayName: "Bridge administrator",
|
||||
});
|
||||
await expect(registry.hasEnabledAdmin()).resolves.toBe(true);
|
||||
// Windows reloads from the bridge on every registry observation so an atomic host
|
||||
// replacement cannot be missed between authorization checks.
|
||||
expect(readLocalUsers).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../src/auth/windows-auth-storage.js";
|
||||
|
||||
const root = "C:\\ProgramData\\ThothII\\auth";
|
||||
const posixRoot = "/var/lib/thothii/auth";
|
||||
const filename = "a".repeat(64) + ".json";
|
||||
const realChildFixture = fileURLToPath(new URL("./fixtures/windows-auth-storage-real-child.mjs", import.meta.url));
|
||||
const fixtureRoots: string[] = [];
|
||||
@@ -29,18 +30,25 @@ async function waitForMarker(marker: string, expected: string): Promise<void> {
|
||||
throw new Error(`real helper marker did not contain ${expected}`);
|
||||
}
|
||||
|
||||
function realChildBridge(mode: "timeout" | "stdout" | "stderr" | "stdin") {
|
||||
function realChildBridge(
|
||||
mode: "timeout" | "stdout" | "stderr" | "stdin",
|
||||
pathStyle: "windows" | "posix",
|
||||
) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "thothii-auth-bridge-child-"));
|
||||
fixtureRoots.push(directory);
|
||||
const marker = join(directory, "marker.txt");
|
||||
const launcher = join(directory, "tht.exe");
|
||||
writeFileSync(launcher, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(realChildFixture)} ${shellQuote(mode)} ${shellQuote(marker)} "$@"\n`, { mode: 0o700 });
|
||||
chmodSync(launcher, 0o700);
|
||||
const factory = pathStyle === "windows" ? createWindowsAuthStorageBridge : createPosixAuthStorageBridge;
|
||||
return {
|
||||
marker,
|
||||
bridge: createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
bridge: factory({
|
||||
thtExecutable: pathStyle === "windows" ? "C:\\tht.exe" : launcher,
|
||||
spawnChild: (_executable, args, options) => spawn(launcher, [...args], options),
|
||||
// Leave enough startup headroom for a real child under a busy CI host while retaining a
|
||||
// sub-1.5-second bound from request start through final settlement.
|
||||
deadlinesForTest: { timeoutMs: 750, terminationGraceMs: 50, finalSettlementMs: 500 },
|
||||
...(mode === "stdin" ? {
|
||||
beforeInputForTest: async () => {
|
||||
await waitForMarker(marker, "stdin-closed");
|
||||
@@ -67,16 +75,18 @@ class FakeBridgeChild extends EventEmitter {
|
||||
readonly stdout = new PassThrough();
|
||||
readonly stderr = new PassThrough();
|
||||
readonly kill = vi.fn(() => true);
|
||||
readonly unref = vi.fn();
|
||||
|
||||
close(code = 0, signal: NodeJS.Signals | null = null): void {
|
||||
this.emit("close", code, signal);
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeForChild(child: FakeBridgeChild) {
|
||||
function bridgeForChild(child: FakeBridgeChild, pathStyle: "windows" | "posix" = "windows") {
|
||||
const spawnChild = vi.fn(() => child);
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\tht.exe",
|
||||
const factory = pathStyle === "windows" ? createWindowsAuthStorageBridge : createPosixAuthStorageBridge;
|
||||
const bridge = factory({
|
||||
thtExecutable: pathStyle === "windows" ? "C:\\tht.exe" : "/opt/thothii/bin/tht",
|
||||
spawnChild,
|
||||
} as never);
|
||||
return { bridge, spawnChild };
|
||||
@@ -214,6 +224,34 @@ describe("Windows auth-storage bridge", () => {
|
||||
expect(JSON.stringify(syncCalls[0]!.args)).not.toContain(config.toString("utf8"));
|
||||
});
|
||||
|
||||
test("reads native Windows users.yaml only through a bounded hidden bridge request", async () => {
|
||||
const users = Buffer.from("version: 1\nusers:\n - passwordHash: not-in-argv\n", "utf8");
|
||||
const calls: Array<{ args: readonly string[]; input: Buffer; maximumOutputBytes: number }> = [];
|
||||
const bridge = createWindowsAuthStorageBridge({
|
||||
thtExecutable: "C:\\Program Files\\ThothII\\tht.exe",
|
||||
invoke: async (call) => {
|
||||
calls.push(call);
|
||||
return {
|
||||
code: 0,
|
||||
stdout: Buffer.from(`${JSON.stringify({
|
||||
version: 1, ok: true, found: true, contentBase64: users.toString("base64"),
|
||||
})}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(bridge.readLocalUsers(`${root}\\users.yaml`)).resolves.toEqual(users);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.args).toEqual(["_auth-storage"]);
|
||||
expect(calls[0]!.maximumOutputBytes).toBeGreaterThan(1024 * 1024);
|
||||
expect(JSON.parse(calls[0]!.input.toString("utf8"))).toEqual({
|
||||
version: 1, operation: "read-local-users", root, filename: "users.yaml",
|
||||
});
|
||||
expect(JSON.stringify(calls[0]!.args)).not.toContain("not-in-argv");
|
||||
expect(calls[0]!.input.toString("utf8")).not.toContain("not-in-argv");
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ label: "nonzero", result: { code: 1, stdout: Buffer.from('{"version":1,"ok":true}\n'), stderr: Buffer.from("secret") } },
|
||||
{ label: "malformed stdout", result: { code: 0, stdout: Buffer.from("not-json"), stderr: Buffer.alloc(0) } },
|
||||
@@ -400,7 +438,7 @@ describe("Windows auth-storage bridge", () => {
|
||||
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test("aborts a stdin-closed looping helper on timeout and waits for close", async () => {
|
||||
test("settles a stdin-closed looping helper by a final deadline when close never arrives", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeBridgeChild();
|
||||
const { bridge, spawnChild } = bridgeForChild(child);
|
||||
@@ -410,17 +448,44 @@ describe("Windows auth-storage bridge", () => {
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(spawnChild).toHaveBeenCalledOnce();
|
||||
expect(child.kill).toHaveBeenCalledOnce();
|
||||
expect(child.unref).toHaveBeenCalledOnce();
|
||||
expect(child.stdin.destroyed).toBe(true);
|
||||
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
|
||||
|
||||
child.close();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(child.kill).toHaveBeenCalledTimes(2);
|
||||
await expect(outcome).resolves.toBe("rejected");
|
||||
expect(() => child.emit("error", new Error("late helper failure"))).not.toThrow();
|
||||
expect(() => child.stdin.emit("error", new Error("late stdin failure"))).not.toThrow();
|
||||
} finally {
|
||||
child.close();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("releases bounded late-error guards when a finally-dead helper closes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeBridgeChild();
|
||||
const { bridge } = bridgeForChild(child);
|
||||
const outcome = bridge.list(root, "sessions").then(() => "resolved", () => "rejected");
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await expect(outcome).resolves.toBe("rejected");
|
||||
expect(child.listenerCount("error")).toBe(1);
|
||||
expect(child.stdin.listenerCount("error")).toBe(1);
|
||||
expect(child.stdout.listenerCount("error")).toBe(1);
|
||||
expect(child.stderr.listenerCount("error")).toBe(1);
|
||||
|
||||
child.close();
|
||||
expect(child.listenerCount("error")).toBe(0);
|
||||
expect(child.stdin.listenerCount("error")).toBe(0);
|
||||
expect(child.stdout.listenerCount("error")).toBe(0);
|
||||
expect(child.stderr.listenerCount("error")).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["stdout", "stderr"] as const)("aborts a %s-flooding helper and waits for close", async (stream) => {
|
||||
const child = new FakeBridgeChild();
|
||||
const { bridge, spawnChild } = bridgeForChild(child);
|
||||
@@ -469,6 +534,10 @@ describe("Windows auth-storage bridge", () => {
|
||||
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
|
||||
child.close();
|
||||
await expect(outcome).resolves.toBe("rejected");
|
||||
expect(() => child.emit("error", new Error("late helper failure"))).not.toThrow();
|
||||
expect(() => child.stdin.emit("error", new Error("late stdin failure"))).not.toThrow();
|
||||
expect(() => child.stdout.emit("error", new Error("late stdout failure"))).not.toThrow();
|
||||
expect(() => child.stderr.emit("error", new Error("late stderr failure"))).not.toThrow();
|
||||
});
|
||||
|
||||
test("fails closed when launching the helper throws before a child exists", async () => {
|
||||
@@ -480,16 +549,19 @@ describe("Windows auth-storage bridge", () => {
|
||||
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
|
||||
});
|
||||
|
||||
test.each(["timeout", "stdout", "stderr", "stdin"] as const)("kills a real %s helper process through the production spawn path", async (mode) => {
|
||||
const { bridge, marker } = realChildBridge(mode);
|
||||
test.each([
|
||||
...(["windows", "posix"] as const).flatMap((pathStyle) =>
|
||||
(["timeout", "stdout", "stderr", "stdin"] as const).map((mode) => ({ pathStyle, mode }))),
|
||||
])("settles a real $pathStyle $mode helper within the production deadline", async ({ pathStyle, mode }) => {
|
||||
const { bridge, marker } = realChildBridge(mode, pathStyle);
|
||||
const startedAt = Date.now();
|
||||
const pending = bridge.list(root, "sessions");
|
||||
const pending = bridge.list(pathStyle === "windows" ? root : posixRoot, "sessions");
|
||||
const outcome = pending.then(() => undefined, (error: unknown) => error);
|
||||
await waitForMarker(marker, "started");
|
||||
if (mode === "stdin") await waitForMarker(marker, "before-input");
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({ message: "auth_session_store_invalid" });
|
||||
await waitForMarker(marker, "terminated");
|
||||
if (mode === "stdin") expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
}, 10_000);
|
||||
expect(Date.now() - startedAt).toBeLessThan(1_500);
|
||||
}, 5_000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user