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
+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;
},
};
}