fix(auth): harden Windows storage bridge

This commit is contained in:
2026-08-16 22:16:13 +02:00
parent c9b02fc57e
commit 7d9ca13f1d
12 changed files with 473 additions and 29 deletions
+60
View File
@@ -59,6 +59,7 @@ import {
type AuthSessionStore,
type SessionCreateInput,
} from "../src/auth/session-store.js";
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
const roots: string[] = [];
const base = new Date("2030-01-02T03:04:05.000Z");
@@ -579,6 +580,65 @@ describe("file-backed auth session store", () => {
}
});
test("prunes nonempty Windows directories through the Go lower-camel list DTO", async () => {
const records = new Map<string, Buffer>();
const key = (directory: string, entry: string) => `${directory}/${entry}`;
const response = (value: Record<string, unknown>) => ({
code: 0,
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...value })}\n`),
stderr: Buffer.alloc(0),
});
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async ({ input }) => {
const request = JSON.parse(input.toString("utf8")) as {
operation: string;
directory: "sessions" | "oidc";
filename?: string;
contentBase64?: string;
};
const entry = request.filename === undefined ? undefined : key(request.directory, request.filename);
switch (request.operation) {
case "create":
if (entry === undefined || request.contentBase64 === undefined || records.has(entry)) return response({ created: false });
records.set(entry, Buffer.from(request.contentBase64, "base64"));
return response({ created: true });
case "read":
return entry === undefined || !records.has(entry)
? response({})
: response({ found: true, contentBase64: records.get(entry)?.toString("base64") });
case "remove":
return response({ removed: entry !== undefined && records.delete(entry) });
case "list":
return response({
// Raw lower-camel entry objects, exactly as authstorage's Go response emits them.
entries: [...records.keys()]
.filter((value) => value.startsWith(`${request.directory}/`))
.map((value) => ({ name: value.slice(request.directory.length + 1), modifiedUnixMs: base.getTime() })),
});
default:
throw new Error("unexpected bridge operation");
}
},
});
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatform) throw new Error("platform descriptor unavailable");
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
const store = createFileAuthSessionStore("C:\\ProgramData\\ThothII\\auth", {
currentAuthConfigRevision: () => revision,
findLocalUser: async () => validLocalUser,
}, { windowsStorageBridge: bridge });
await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
expect(records).toHaveLength(0);
} finally {
Object.defineProperty(process, "platform", originalPlatform);
}
});
test("revokes on config, local-user, revision, enabled, or role mismatch before returning", async () => {
const storageRoot = root();
let currentRevision = revision;
+119 -1
View File
@@ -1,9 +1,31 @@
import { describe, expect, test } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, test, vi } from "vitest";
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
const root = "C:\\ProgramData\\ThothII\\auth";
const filename = "a".repeat(64) + ".json";
class FakeBridgeChild extends EventEmitter {
readonly stdin = new PassThrough();
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
readonly kill = vi.fn(() => true);
close(code = 0, signal: NodeJS.Signals | null = null): void {
this.emit("close", code, signal);
}
}
function bridgeForChild(child: FakeBridgeChild) {
const spawnChild = vi.fn(() => child);
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
spawnChild,
} as never);
return { bridge, spawnChild };
}
describe("Windows auth-storage bridge", () => {
test("uses hidden tht argv and sends record bytes only over bounded stdin", async () => {
const calls: Array<{ executable: string; args: readonly string[]; input: Buffer; timeoutMs: number }> = [];
@@ -71,4 +93,100 @@ describe("Windows auth-storage bridge", () => {
expect(() => createWindowsAuthStorageBridge({ thtExecutable: "tht.exe && unexpected" }))
.toThrow("auth_session_store_invalid");
});
test("parses the lower-camel list DTO emitted by the Go helper for a nonempty directory", async () => {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async () => ({
code: 0,
// This is the raw JSON object emitted by authstorage.response after Go's DTO encoding.
stdout: Buffer.from(`{"version":1,"ok":true,"entries":[{"name":"${filename}","modifiedUnixMs":1893456245000}]}\n`),
stderr: Buffer.alloc(0),
}),
});
await expect(bridge.list(root, "oidc")).resolves.toEqual([
{ name: filename, modifiedUnixMs: 1_893_456_245_000 },
]);
});
test("aborts a stdin-closed looping helper on timeout and waits for close", async () => {
vi.useFakeTimers();
const child = new FakeBridgeChild();
const { bridge, spawnChild } = bridgeForChild(child);
const pending = bridge.list(root, "sessions");
const outcome = pending.then(() => "resolved", () => "rejected");
try {
await vi.advanceTimersByTimeAsync(5_000);
expect(spawnChild).toHaveBeenCalledOnce();
expect(child.kill).toHaveBeenCalledOnce();
expect(child.stdin.destroyed).toBe(true);
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
child.close();
await expect(outcome).resolves.toBe("rejected");
} finally {
child.close();
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);
const pending = bridge.list(root, "sessions");
const outcome = pending.then(() => "resolved", () => "rejected");
await Promise.resolve();
expect(spawnChild).toHaveBeenCalledOnce();
child[stream].write(Buffer.alloc(64 * 1024 + 1));
await Promise.resolve();
expect(child.kill).toHaveBeenCalledOnce();
expect(child.stdin.destroyed).toBe(true);
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
child.close();
await expect(outcome).resolves.toBe("rejected");
});
test("aborts a helper when its stdin errors and waits for termination", async () => {
const child = new FakeBridgeChild();
const { bridge, spawnChild } = bridgeForChild(child);
const stdinErrored = new Promise<void>((resolve) => child.stdin.once("error", () => resolve()));
child.stdin.once("finish", () => child.stdin.destroy(new Error("stdin failure")));
const pending = bridge.list(root, "sessions");
const outcome = pending.then(() => "resolved", () => "rejected");
await Promise.resolve();
expect(spawnChild).toHaveBeenCalledOnce();
await stdinErrored;
expect(child.kill).toHaveBeenCalledOnce();
child.close();
await expect(outcome).resolves.toBe("rejected");
});
test("aborts an errored child exactly once and waits for its close event", async () => {
const child = new FakeBridgeChild();
const { bridge, spawnChild } = bridgeForChild(child);
const pending = bridge.list(root, "sessions");
const outcome = pending.then(() => "resolved", () => "rejected");
await Promise.resolve();
expect(spawnChild).toHaveBeenCalledOnce();
child.emit("error", new Error("helper error"));
child.emit("error", new Error("duplicate helper error"));
expect(child.kill).toHaveBeenCalledOnce();
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
child.close();
await expect(outcome).resolves.toBe("rejected");
});
test("fails closed when launching the helper throws before a child exists", async () => {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
spawnChild: () => { throw new Error("launch detail must not escape"); },
});
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
});
});