fix(auth): add Windows session storage bridge

This commit is contained in:
2026-08-16 21:43:03 +02:00
parent 6bf8218fea
commit c9b02fc57e
14 changed files with 1630 additions and 8 deletions
+140 -5
View File
@@ -22,6 +22,10 @@ import { dirname, isAbsolute, join, normalize } from "node:path";
import { z } from "zod";
import type { PrincipalContext } from "./principal.js";
import type { AuthSessionRecord, OidcStateRecord, Permission, Role } from "./types.js";
import {
createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge,
} from "./windows-auth-storage.js";
const TOKEN_BYTES = 32;
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
@@ -96,6 +100,11 @@ export interface AuthSessionStore {
consumeOidcState(state: string, now?: Date): Promise<OidcStateRecord | undefined>;
}
/** Narrow test seam for the native Windows tht-backed storage adaptor. */
export interface FileAuthSessionStoreOptions {
windowsStorageBridge?: WindowsAuthStorageBridge;
}
interface FileIdentity {
dev: number;
ino: number;
@@ -300,10 +309,8 @@ function privateDirectory(path: string): void {
}
function storageDirectories(root: string): StorageDirectories {
// Node's chmod is not a Windows DACL boundary. The existing Go operator store has a
// CreateFile security-descriptor path, but no equivalent safe Node primitive is available.
// Refuse before probing or creating the configured root rather than publishing browser state
// with inherited ACLs.
// Native Windows calls must dispatch to the tht DACL-capable bridge before reaching this
// POSIX-only helper. Keep this guard so an un-routed caller cannot fall back to chmod.
if (process.platform === "win32") throw invalid();
if (typeof root !== "string" || root.length === 0 || root.includes("\0")
|| !isAbsolute(root) || normalize(root) !== root) throw invalid();
@@ -526,6 +533,15 @@ function parseOidcStateRecord(source: string): OidcStateRecord {
}
}
function parseWindowsRecord<T>(contents: Buffer, maximumBytes: number, parse: (source: string) => T): T {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > maximumBytes) throw invalid();
try {
return parse(new TextDecoder("utf-8", { fatal: true }).decode(contents));
} catch {
throw invalid();
}
}
interface OidcStateClaim {
state: TrustedFile<OidcStateRecord>;
claimIdentity: FileIdentity;
@@ -701,7 +717,20 @@ export function deriveCsrfToken(sessionToken: string): string {
}
}
export function createFileAuthSessionStore(root: string, validity?: AuthSessionValidity): AuthSessionStore {
export function createFileAuthSessionStore(
root: string,
validity?: AuthSessionValidity,
options: FileAuthSessionStoreOptions = {},
): AuthSessionStore {
const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined;
function requiredWindowsStorage(): WindowsAuthStorageBridge {
if (windowsStorage === undefined) throw invalid();
return windowsStorage;
}
async function createSession(input: SessionCreateInput, now = new Date()): Promise<CreatedAuthSession> {
const nowMs = dateMilliseconds(now);
let validated: z.infer<typeof sessionInputSchema>;
@@ -730,6 +759,16 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
absoluteExpiresAt: isoAt(absoluteExpiresMs),
};
const contents = serialize(record, MAX_SESSION_RECORD_BYTES);
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
for (let attempt = 0; attempt < 8; attempt += 1) {
const token = randomBytes(TOKEN_BYTES).toString("base64url");
if (await bridge.create(root, "sessions", digestFilename(token), contents)) {
return { token, csrfToken: deriveCsrfToken(token), record };
}
}
throw invalid();
}
const directories = storageDirectories(root);
for (let attempt = 0; attempt < 8; attempt += 1) {
const token = randomBytes(TOKEN_BYTES).toString("base64url");
@@ -746,6 +785,24 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
const nowMs = dateMilliseconds(now);
const filename = digestFilename(token);
return withLock(lockKey(root, "sessions", filename), async () => {
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
const contents = await bridge.read(root, "sessions", filename);
if (!contents) return undefined;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs)) {
await bridge.remove(root, "sessions", filename);
return undefined;
}
try {
if (await recordIsCurrent(record, validity)) return record;
} catch {
await bridge.remove(root, "sessions", filename);
throw invalid();
}
await bridge.remove(root, "sessions", filename);
return undefined;
}
const directories = storageDirectories(root);
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (!trusted) return undefined;
@@ -769,6 +826,27 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
const nowMs = dateMilliseconds(now);
const filename = digestFilename(token);
await withLock(lockKey(root, "sessions", filename), async () => {
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
const contents = await bridge.read(root, "sessions", filename);
if (!contents) return;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs)) {
await bridge.remove(root, "sessions", filename);
return;
}
const lastSeenMs = Date.parse(record.lastSeenAt);
if (nowMs <= lastSeenMs || nowMs - lastSeenMs < TOUCH_INTERVAL_MS) return;
const idleWindowMs = Date.parse(record.idleExpiresAt) - lastSeenMs;
if (idleWindowMs <= 0 || idleWindowMs > MAX_TTL_MS) throw invalid();
const touched: AuthSessionRecord = {
...record,
lastSeenAt: isoAt(nowMs),
idleExpiresAt: isoAt(Math.min(nowMs + idleWindowMs, Date.parse(record.absoluteExpiresAt))),
};
await bridge.replace(root, "sessions", filename, serialize(touched, MAX_SESSION_RECORD_BYTES));
return;
}
const directories = storageDirectories(root);
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (!trusted) return;
@@ -798,6 +876,10 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
if (!canonicalRawValue(token)) return;
const filename = digestFilename(token);
await withLock(lockKey(root, "sessions", filename), async () => {
if (process.platform === "win32") {
await requiredWindowsStorage().remove(root, "sessions", filename);
return;
}
const directories = storageDirectories(root);
removeTrusted(directories.sessions, filename);
});
@@ -822,6 +904,14 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
expiresAt: isoAt(expiresMs),
};
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = randomBytes(TOKEN_BYTES).toString("base64url");
if (await bridge.create(root, "oidc", digestFilename(state), contents)) return { state, record };
}
throw invalid();
}
const directories = storageDirectories(root);
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = randomBytes(TOKEN_BYTES).toString("base64url");
@@ -835,6 +925,12 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
const nowMs = dateMilliseconds(now);
const filename = digestFilename(state);
return withLock(lockKey(root, "oidc", filename), async () => {
if (process.platform === "win32") {
const contents = await requiredWindowsStorage().claimConsume(root, filename);
if (!contents) return undefined;
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
return oidcStateExpired(record, nowMs) ? undefined : record;
}
const directories = storageDirectories(root);
const claim = claimOidcState(directories.oidc, filename);
// An installed claim belongs to another process/store instance. Only the process which
@@ -851,6 +947,45 @@ export function createFileAuthSessionStore(root: string, validity?: AuthSessionV
async function prune(now = new Date()): Promise<number> {
const nowMs = dateMilliseconds(now);
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
const sessionEntries = await bridge.list(root, "sessions");
const oidcEntries = await bridge.list(root, "oidc");
let removed = 0;
for (const entry of sessionEntries) {
if (!DIGEST_FILENAME_PATTERN.test(entry.name)) throw invalid();
const contents = await bridge.read(root, "sessions", entry.name);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", entry.name)) removed += 1;
}
const stateNames = new Set(oidcEntries.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry.name)).map((entry) => entry.name));
const claimEntries = new Map(oidcEntries
.filter((entry) => CLAIM_FILENAME_PATTERN.test(entry.name))
.map((entry) => [entry.name, entry]));
if (stateNames.size + claimEntries.size !== oidcEntries.length) throw invalid();
for (const filename of stateNames) {
const claim = claimFilename(filename);
const contents = claimEntries.has(claim)
? await bridge.readClaim(root, filename)
: await bridge.read(root, "oidc", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_OIDC_STATE_RECORD_BYTES, parseOidcStateRecord);
if (oidcStateExpired(record, nowMs)) {
const didRemove = claimEntries.has(claim)
? await bridge.removeClaim(root, filename)
: await bridge.remove(root, "oidc", filename);
if (didRemove) removed += 1;
}
}
for (const [claim, entry] of claimEntries) {
const filename = `${claim.slice(0, -".claim".length)}.json`;
if (stateNames.has(filename)) continue;
if (nowMs >= entry.modifiedUnixMs + OIDC_STATE_TTL_MS
&& await bridge.remove(root, "oidc", claim)) removed += 1;
}
return removed;
}
const directories = storageDirectories(root);
let removed = 0;
const pruneDirectory = async (
+278
View File
@@ -0,0 +1,278 @@
import { spawn } from "node:child_process";
import { win32 } from "node:path";
import { z } from "zod";
const PROTOCOL_VERSION = 1;
const MAX_PROTOCOL_BYTES = 64 * 1024;
const MAX_RESPONSE_BYTES = 64 * 1024;
const MAX_SESSION_BYTES = 16 * 1024;
const MAX_OIDC_BYTES = 8 * 1024;
const MAX_ENTRIES = 256;
const TIMEOUT_MS = 5_000;
const DIGEST_FILENAME = /^[a-f0-9]{64}\.json$/;
const CLAIM_FILENAME = /^[a-f0-9]{64}\.claim$/;
const invalid = (): Error => new Error("auth_session_store_invalid");
export type WindowsAuthStorageDirectory = "sessions" | "oidc";
export interface WindowsAuthStorageEntry {
name: string;
modifiedUnixMs: number;
}
/** Internal adapter boundary for the file-session store's native Windows path. */
export interface WindowsAuthStorageBridge {
create(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<boolean>;
read(root: string, directory: WindowsAuthStorageDirectory, filename: string): Promise<Buffer | undefined>;
replace(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<void>;
remove(root: string, directory: WindowsAuthStorageDirectory, filename: string): Promise<boolean>;
list(root: string, directory: WindowsAuthStorageDirectory): Promise<WindowsAuthStorageEntry[]>;
claimConsume(root: string, filename: string): Promise<Buffer | undefined>;
readClaim(root: string, filename: string): Promise<Buffer | undefined>;
removeClaim(root: string, filename: string): Promise<boolean>;
}
export interface WindowsAuthStorageInvocation {
executable: string;
args: readonly string[];
input: Buffer;
timeoutMs: number;
}
export interface WindowsAuthStorageInvocationResult {
code: number;
stdout: Buffer;
stderr: Buffer;
}
export interface WindowsAuthStorageBridgeOptions {
/** Test-only transport seam. Production always uses the no-shell child-process invocation. */
invoke?: (invocation: WindowsAuthStorageInvocation) => Promise<WindowsAuthStorageInvocationResult>;
/** Optional configured tht path. Defaults to THT_BIN, then the safe bare command `tht`. */
thtExecutable?: string;
}
const responseSchema = z.strictObject({
version: z.literal(PROTOCOL_VERSION),
ok: z.literal(true),
created: z.boolean().optional(),
replaced: z.boolean().optional(),
removed: z.boolean().optional(),
found: z.boolean().optional(),
contentBase64: z.string().max(MAX_PROTOCOL_BYTES).optional(),
entries: z.array(z.strictObject({
name: z.string().max(128),
modifiedUnixMs: z.number().int().safe().nonnegative(),
})).max(MAX_ENTRIES).optional(),
});
type BridgeResponse = z.infer<typeof responseSchema>;
interface BridgeRequest {
version: typeof PROTOCOL_VERSION;
operation: "create" | "read" | "replace" | "remove" | "list" | "claim-consume" | "read-claim" | "remove-claim";
root: string;
directory: WindowsAuthStorageDirectory;
filename?: string;
contentBase64?: string;
}
function directoryMaximum(directory: WindowsAuthStorageDirectory): number {
return directory === "sessions" ? MAX_SESSION_BYTES : MAX_OIDC_BYTES;
}
function canonicalBase64(value: string, maximum: number): Buffer {
if (typeof value !== "string" || value.length > Math.ceil(maximum / 3) * 4) throw invalid();
try {
const decoded = Buffer.from(value, "base64");
if (decoded.length === 0 || decoded.length > maximum || decoded.toString("base64") !== value) throw invalid();
return decoded;
} catch {
throw invalid();
}
}
function validateRoot(root: string): void {
if (typeof root !== "string" || root.length === 0 || /[\u0000-\u001f\u007f]/.test(root)
|| !win32.isAbsolute(root) || win32.normalize(root) !== root) throw invalid();
}
function validateFilename(filename: string, claim = false): void {
if (typeof filename !== "string" || !(claim ? CLAIM_FILENAME : DIGEST_FILENAME).test(filename)) throw invalid();
}
function safeThtExecutable(value: string | undefined): string {
const executable = value ?? process.env.THT_BIN ?? "tht";
if (typeof executable !== "string" || executable.length === 0 || /[\u0000-\u001f\u007f]/.test(executable)) throw invalid();
if (executable === "tht" || executable === "tht.exe") return executable;
if (win32.isAbsolute(executable) && win32.normalize(executable) === executable && /\.exe$/i.test(executable)) return executable;
throw invalid();
}
function parseResponse(result: WindowsAuthStorageInvocationResult): BridgeResponse {
if (!Number.isInteger(result.code) || result.code !== 0 || !Buffer.isBuffer(result.stdout)
|| !Buffer.isBuffer(result.stderr) || result.stdout.length === 0 || result.stdout.length > MAX_RESPONSE_BYTES) {
throw invalid();
}
try {
const source = new TextDecoder("utf-8", { fatal: true }).decode(result.stdout);
return responseSchema.parse(JSON.parse(source));
} catch {
throw invalid();
}
}
function encodedRequest(request: BridgeRequest): Buffer {
validateRoot(request.root);
if (request.filename !== undefined) validateFilename(request.filename);
if (request.contentBase64 !== undefined) canonicalBase64(request.contentBase64, directoryMaximum(request.directory));
const encoded = Buffer.from(JSON.stringify(request), "utf8");
if (encoded.length === 0 || encoded.length > MAX_PROTOCOL_BYTES) throw invalid();
return encoded;
}
function environmentForBridge(): NodeJS.ProcessEnv {
const path = process.env.PATH;
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
return {
...(path === undefined ? {} : { PATH: path }),
...(systemRoot === undefined ? {} : { SystemRoot: systemRoot }),
};
}
async function invokeTht(invocation: WindowsAuthStorageInvocation): Promise<WindowsAuthStorageInvocationResult> {
return new Promise((resolve, reject) => {
let settled = false;
let timeout: NodeJS.Timeout | undefined;
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
const settle = (callback: () => void): void => {
if (settled) return;
settled = true;
if (timeout !== undefined) clearTimeout(timeout);
callback();
};
let child: ReturnType<typeof spawn>;
try {
child = spawn(invocation.executable, [...invocation.args], {
shell: false,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
env: environmentForBridge(),
});
} catch {
reject(invalid());
return;
}
if (!child.stdin || !child.stdout || !child.stderr) {
try { child.kill(); } catch { /* unavailable child streams fail closed */ }
reject(invalid());
return;
}
const stdin = child.stdin;
const stdoutStream = child.stdout;
const stderrStream = child.stderr;
timeout = setTimeout(() => {
try { child.kill(); } catch { /* child failure is converted below */ }
settle(() => reject(invalid()));
}, invocation.timeoutMs);
child.once("error", () => settle(() => reject(invalid())));
stdoutStream.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.length;
if (stdoutBytes > MAX_RESPONSE_BYTES) {
try { child.kill(); } catch { /* child failure is converted below */ }
settle(() => reject(invalid()));
return;
}
stdout.push(Buffer.from(chunk));
});
stderrStream.on("data", (chunk: Buffer) => {
stderrBytes += chunk.length;
if (stderrBytes <= MAX_RESPONSE_BYTES) stderr.push(Buffer.from(chunk));
});
child.once("close", (code) => settle(() => resolve({
code: code ?? -1,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
})));
stdin.once("error", () => settle(() => reject(invalid())));
stdin.end(invocation.input);
});
}
function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefined {
if (response.found !== true) {
if (response.contentBase64 !== undefined) throw invalid();
return undefined;
}
if (response.contentBase64 === undefined) throw invalid();
return canonicalBase64(response.contentBase64, maximum);
}
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
const executable = safeThtExecutable(options.thtExecutable);
const invoke = options.invoke ?? invokeTht;
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
try {
const response = await invoke({
executable,
args: ["_auth-storage"],
input: encodedRequest(value),
timeoutMs: TIMEOUT_MS,
});
return parseResponse(response);
} catch {
throw invalid();
}
};
const recordRequest = (operation: BridgeRequest["operation"], root: string, directory: WindowsAuthStorageDirectory, filename: string, contents?: Buffer): BridgeRequest => ({
version: PROTOCOL_VERSION,
operation,
root,
directory,
filename,
...(contents === undefined ? {} : { contentBase64: contents.toString("base64") }),
});
return {
async create(root, directory, filename, contents) {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > directoryMaximum(directory)) throw invalid();
const response = await request(recordRequest("create", root, directory, filename, contents));
if (response.created === undefined) throw invalid();
return response.created;
},
async read(root, directory, filename) {
return contentFrom(await request(recordRequest("read", root, directory, filename)), directoryMaximum(directory));
},
async replace(root, directory, filename, contents) {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > directoryMaximum(directory)) throw invalid();
const response = await request(recordRequest("replace", root, directory, filename, contents));
if (response.replaced !== true) throw invalid();
},
async remove(root, directory, filename) {
const response = await request(recordRequest("remove", root, directory, filename));
return response.removed === true;
},
async list(root, directory) {
const response = await request({ version: PROTOCOL_VERSION, operation: "list", root, directory });
if (response.entries === undefined) throw invalid();
for (const entry of response.entries) {
if (!DIGEST_FILENAME.test(entry.name) && !CLAIM_FILENAME.test(entry.name)) throw invalid();
}
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
},
async claimConsume(root, filename) {
return contentFrom(await request(recordRequest("claim-consume", root, "oidc", filename)), MAX_OIDC_BYTES);
},
async readClaim(root, filename) {
return contentFrom(await request(recordRequest("read-claim", root, "oidc", filename)), MAX_OIDC_BYTES);
},
async removeClaim(root, filename) {
const response = await request(recordRequest("remove-claim", root, "oidc", filename));
return response.removed === true;
},
};
}
+79 -2
View File
@@ -488,20 +488,97 @@ describe("file-backed auth session store", () => {
await expectStoreInvalid(store.revoke(created.token));
});
test("rejects native Windows storage before any state write", async () => {
test("routes native Windows session creation through an injected storage bridge", async () => {
const storageRoot = root();
const store = validStore(join(storageRoot, "windows-auth-state"));
const createRecord = vi.fn(async () => true);
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
if (!originalPlatform) throw new Error("platform descriptor unavailable");
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
await expectStoreInvalid(create(store));
const created = await create(
createFileAuthSessionStore(join(storageRoot, "windows-auth-state"), {
currentAuthConfigRevision: () => revision,
findLocalUser: async () => validLocalUser,
}, { windowsStorageBridge: { create: createRecord } } as never),
);
expect(created.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(createRecord).toHaveBeenCalledTimes(1);
expect(existsSync(join(storageRoot, "windows-auth-state"))).toBe(false);
} finally {
Object.defineProperty(process, "platform", originalPlatform);
}
});
test("routes native Windows lifecycle and OIDC operations only through the storage bridge", async () => {
const records = new Map<string, Buffer>();
const calls: string[] = [];
const key = (directory: string, filename: string) => `${directory}/${filename}`;
const bridge = {
create: async (_root: string, directory: string, filename: string, contents: Buffer) => {
calls.push("create");
const entry = key(directory, filename);
if (records.has(entry)) return false;
records.set(entry, Buffer.from(contents));
return true;
},
read: async (_root: string, directory: string, filename: string) => {
calls.push("read");
const value = records.get(key(directory, filename));
return value === undefined ? undefined : Buffer.from(value);
},
replace: async (_root: string, directory: string, filename: string, contents: Buffer) => {
calls.push("replace");
records.set(key(directory, filename), Buffer.from(contents));
},
remove: async (_root: string, directory: string, filename: string) => {
calls.push("remove");
return records.delete(key(directory, filename));
},
list: async (_root: string, directory: string) => {
calls.push("list");
return [...records.keys()]
.filter((entry) => entry.startsWith(`${directory}/`))
.map((entry) => ({ name: entry.slice(directory.length + 1), modifiedUnixMs: base.getTime() }));
},
claimConsume: async (_root: string, filename: string) => {
calls.push("claim-consume");
const entry = key("oidc", filename);
const value = records.get(entry);
records.delete(entry);
return value === undefined ? undefined : Buffer.from(value);
},
readClaim: async () => undefined,
removeClaim: async () => false,
};
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 } as never);
const session = await create(store);
await expect(store.resolve(session.token)).resolves.toMatchObject({ subject: session.record.subject });
await store.touch(session.token, new Date(base.getTime() + 5 * 60_000));
await store.revoke(session.token);
await expect(store.resolve(session.token)).resolves.toBeUndefined();
const oidc = await store.createOidcState({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, base);
await expect(store.consumeOidcState(oidc.state, new Date(base.getTime() + 9 * 60_000)))
.resolves.toMatchObject({ nonce: "n".repeat(43) });
await expect(store.consumeOidcState(oidc.state)).resolves.toBeUndefined();
await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
await store.createOidcState({ nonce: "x".repeat(43), codeVerifier: "y".repeat(43), returnTo: "/" }, base);
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
expect(calls).toEqual(expect.arrayContaining(["create", "read", "replace", "remove", "claim-consume", "list"]));
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;
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js";
const root = "C:\\ProgramData\\ThothII\\auth";
const filename = "a".repeat(64) + ".json";
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 }> = [];
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\Program Files\\ThothII\\tht.exe",
invoke: async (call) => {
calls.push(call);
return { code: 0, stdout: Buffer.from('{"version":1,"ok":true,"created":true}\n'), stderr: Buffer.alloc(0) };
},
});
await expect(bridge.create(root, "sessions", filename, Buffer.from('{"subject":"record-data"}'))).resolves.toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
executable: "C:\\Program Files\\ThothII\\tht.exe",
args: ["_auth-storage"],
});
expect(JSON.stringify(calls[0].args)).not.toContain("record-data");
expect(JSON.parse(calls[0].input.toString("utf8"))).toMatchObject({
version: 1,
operation: "create",
root,
directory: "sessions",
filename,
contentBase64: Buffer.from('{"subject":"record-data"}').toString("base64"),
});
expect(calls[0].timeoutMs).toBeGreaterThan(0);
});
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) } },
{ label: "unexpected stdout", result: { code: 0, stdout: Buffer.from('{"version":1,"ok":true}\nextra'), stderr: Buffer.alloc(0) } },
{ label: "unexpected JSON field", result: { code: 0, stdout: Buffer.from('{"version":1,"ok":true,"created":true,"detail":"secret"}\n'), stderr: Buffer.alloc(0) } },
])("fails closed on $label bridge output", async ({ result }) => {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async () => result,
});
await expect(bridge.create(root, "sessions", filename, Buffer.from("record")))
.rejects.toThrow("auth_session_store_invalid");
});
test("fails closed on a bridge timeout without disclosing request content", async () => {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async () => { throw new Error("timeout secret-record"); },
});
await expect(bridge.create(root, "sessions", filename, Buffer.from("secret-record")))
.rejects.toThrow("auth_session_store_invalid");
});
test("rejects a claimed-read response without bounded record bytes", async () => {
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
invoke: async () => ({ code: 0, stdout: Buffer.from('{"version":1,"ok":true,"found":true}\n'), stderr: Buffer.alloc(0) }),
});
await expect(bridge.readClaim(root, filename)).rejects.toThrow("auth_session_store_invalid");
});
test("rejects an executable value that would require shell parsing", () => {
expect(() => createWindowsAuthStorageBridge({ thtExecutable: "tht.exe && unexpected" }))
.toThrow("auth_session_store_invalid");
});
});