fix(auth): add Windows session storage bridge
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user