640 lines
28 KiB
TypeScript
640 lines
28 KiB
TypeScript
import { spawn, spawnSync } from "node:child_process";
|
|
import { posix, win32 } from "node:path";
|
|
import type { Readable, Writable } from "node:stream";
|
|
import { z } from "zod";
|
|
|
|
const PROTOCOL_VERSION = 1;
|
|
const MAX_PROTOCOL_BYTES = 64 * 1024;
|
|
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
const MAX_AUTH_CONFIG_BYTES = 1024 * 1024;
|
|
const MAX_AUTH_CONFIG_BASE64_BYTES = 4 * Math.ceil(MAX_AUTH_CONFIG_BYTES / 3);
|
|
const MAX_AUTH_CONFIG_RESPONSE_BYTES = MAX_AUTH_CONFIG_BASE64_BYTES + 1024;
|
|
const MAX_SESSION_BYTES = 16 * 1024;
|
|
const MAX_OIDC_BYTES = 8 * 1024;
|
|
const DEFAULT_MAX_ENTRIES = 256;
|
|
const MAX_ENTRIES = 512;
|
|
const TIMEOUT_MS = 5_000;
|
|
const TERMINATION_GRACE_MS = 100;
|
|
const FINAL_SETTLEMENT_MS = 750;
|
|
const DIGEST_FILENAME = /^[a-f0-9]{64}\.json$/;
|
|
const CLAIM_FILENAME = /^[a-f0-9]{64}\.claim$/;
|
|
const OIDC_SLOT_FILENAME = /^slot-(?:[0-5][0-9]|6[0-3])\.json$/;
|
|
const AUTH_CONFIG_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,249}\.yaml$/;
|
|
|
|
const invalid = (): Error => new Error("auth_session_store_invalid");
|
|
|
|
export type WindowsAuthStorageDirectory = "sessions" | "oidc";
|
|
|
|
export interface WindowsAuthStorageEntry {
|
|
name: string;
|
|
modifiedUnixMs: number;
|
|
}
|
|
|
|
export interface WindowsAuthStoragePage {
|
|
entries: WindowsAuthStorageEntry[];
|
|
more: boolean;
|
|
}
|
|
|
|
/** Internal adapter boundary for the file-session store's native Windows path. */
|
|
export interface WindowsAuthStorageBridge {
|
|
validateRoot(root: string): Promise<void>;
|
|
ensureLayout(root: string): Promise<void>;
|
|
readAuthConfig(path: string): Buffer;
|
|
readLocalUsers(path: string): Promise<Buffer>;
|
|
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,
|
|
maximumEntries?: number,
|
|
): Promise<WindowsAuthStorageEntry[]>;
|
|
listPage(
|
|
root: string,
|
|
directory: "sessions",
|
|
afterName: string | undefined,
|
|
maximumEntries: number,
|
|
): Promise<WindowsAuthStoragePage>;
|
|
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;
|
|
maximumOutputBytes: number;
|
|
}
|
|
|
|
export interface WindowsAuthStorageInvocationResult {
|
|
code: number;
|
|
stdout: Buffer;
|
|
stderr: Buffer;
|
|
}
|
|
|
|
interface WindowsAuthStorageChild {
|
|
readonly stdin: Writable | null;
|
|
readonly stdout: Readable | null;
|
|
readonly stderr: Readable | null;
|
|
kill(signal?: NodeJS.Signals | number): boolean;
|
|
unref?(): void;
|
|
on(event: "error", listener: (error: Error) => void): this;
|
|
once(event: "error", listener: (error: Error) => void): this;
|
|
once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
|
removeListener?(event: "error" | "close", listener: (...args: any[]) => void): this;
|
|
}
|
|
|
|
type WindowsAuthStorageSpawn = (
|
|
executable: string,
|
|
args: readonly string[],
|
|
options: { shell: false; windowsHide: true; stdio: ["pipe", "pipe", "pipe"]; env: NodeJS.ProcessEnv },
|
|
) => WindowsAuthStorageChild;
|
|
|
|
export interface WindowsAuthStorageBridgeOptions {
|
|
/** Test-only transport seam. Production always uses the no-shell child-process invocation. */
|
|
invoke?: (invocation: WindowsAuthStorageInvocation) => Promise<WindowsAuthStorageInvocationResult>;
|
|
/** Test-only synchronous seam used by the synchronous authentication-config provider. */
|
|
invokeSync?: (invocation: WindowsAuthStorageInvocation) => WindowsAuthStorageInvocationResult;
|
|
/** Optional configured tht path. Defaults to THT_BIN, then the safe bare command `tht`. */
|
|
thtExecutable?: string;
|
|
/** Test-only child-launch seam; production uses the fixed no-shell Node child-process launcher. */
|
|
spawnChild?: WindowsAuthStorageSpawn;
|
|
/** Test-only input scheduling seam for real child-process lifecycle tests. */
|
|
beforeInputForTest?: () => Promise<void>;
|
|
/** Test-only bounded lifecycle timings. Production always uses the fixed deadlines below. */
|
|
deadlinesForTest?: {
|
|
timeoutMs?: number;
|
|
terminationGraceMs?: number;
|
|
finalSettlementMs?: number;
|
|
};
|
|
}
|
|
|
|
type AuthStoragePathStyle = "posix" | "windows";
|
|
|
|
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_AUTH_CONFIG_BASE64_BYTES).optional(),
|
|
entries: z.array(z.strictObject({
|
|
name: z.string().max(128),
|
|
modifiedUnixMs: z.number().int().safe().nonnegative(),
|
|
})).max(MAX_ENTRIES).optional(),
|
|
more: z.boolean().optional(),
|
|
validated: z.boolean().optional(),
|
|
prepared: z.boolean().optional(),
|
|
});
|
|
|
|
type BridgeResponse = z.infer<typeof responseSchema>;
|
|
|
|
interface BridgeRequest {
|
|
version: typeof PROTOCOL_VERSION;
|
|
operation: "validate-root" | "ensure-layout" | "read-auth-config" | "read-local-users" | "create" | "read" | "replace" | "remove" | "list" | "claim-consume" | "read-claim" | "remove-claim";
|
|
root: string;
|
|
directory?: WindowsAuthStorageDirectory;
|
|
filename?: string;
|
|
contentBase64?: string;
|
|
maximumEntries?: number;
|
|
afterName?: string;
|
|
continuation?: true;
|
|
}
|
|
|
|
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, pathStyle: AuthStoragePathStyle): void {
|
|
const paths = pathStyle === "windows" ? win32 : posix;
|
|
if (typeof root !== "string" || root.length === 0 || /[\u0000-\u001f\u007f]/.test(root)
|
|
|| !paths.isAbsolute(root) || paths.normalize(root) !== root) throw invalid();
|
|
}
|
|
|
|
function validateFilename(filename: string, allowClaim = false, allowOidcSlot = false): void {
|
|
if (typeof filename !== "string" || (!DIGEST_FILENAME.test(filename)
|
|
&& !(allowClaim && CLAIM_FILENAME.test(filename))
|
|
&& !(allowOidcSlot && OIDC_SLOT_FILENAME.test(filename)))) throw invalid();
|
|
}
|
|
|
|
function safeThtExecutable(value: string | undefined, pathStyle: AuthStoragePathStyle): string {
|
|
const executable = value ?? process.env.THT_AUTH_STORAGE_BIN ?? process.env.THT_BIN ?? "tht";
|
|
if (typeof executable !== "string" || executable.length === 0 || /[\u0000-\u001f\u007f]/.test(executable)) throw invalid();
|
|
if (executable === "tht" || (pathStyle === "windows" && executable === "tht.exe")) return executable;
|
|
const paths = pathStyle === "windows" ? win32 : posix;
|
|
if (paths.isAbsolute(executable) && paths.normalize(executable) === executable
|
|
&& (pathStyle === "posix" || /\.exe$/i.test(executable))) return executable;
|
|
throw invalid();
|
|
}
|
|
|
|
function parseResponse(result: WindowsAuthStorageInvocationResult, maximumOutputBytes: number): BridgeResponse {
|
|
if (!Number.isInteger(result.code) || result.code !== 0 || !Buffer.isBuffer(result.stdout)
|
|
|| !Buffer.isBuffer(result.stderr) || result.stderr.length > MAX_RESPONSE_BYTES
|
|
|| result.stdout.length === 0 || result.stdout.length > maximumOutputBytes) {
|
|
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, pathStyle: AuthStoragePathStyle): Buffer {
|
|
validateRoot(request.root, pathStyle);
|
|
if (request.operation === "validate-root" || request.operation === "ensure-layout") {
|
|
if (request.directory !== undefined || request.filename !== undefined || request.contentBase64 !== undefined
|
|
|| request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
|
|
} else if (request.operation === "read-auth-config" || request.operation === "read-local-users") {
|
|
if (request.directory !== undefined || request.contentBase64 !== undefined || request.maximumEntries !== undefined
|
|
|| request.afterName !== undefined || request.continuation !== undefined
|
|
|| request.filename === undefined || !AUTH_CONFIG_FILENAME.test(request.filename)) throw invalid();
|
|
} else if (request.operation === "list") {
|
|
if (request.directory === undefined) throw invalid();
|
|
if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid();
|
|
if (request.maximumEntries !== undefined && (!Number.isInteger(request.maximumEntries)
|
|
|| request.maximumEntries < 1 || request.maximumEntries > MAX_ENTRIES)) throw invalid();
|
|
if (request.continuation === true) {
|
|
if (request.directory !== "sessions" || (request.afterName !== undefined && !DIGEST_FILENAME.test(request.afterName))) {
|
|
throw invalid();
|
|
}
|
|
} else if (request.afterName !== undefined || request.continuation !== undefined) {
|
|
throw invalid();
|
|
}
|
|
} else {
|
|
if (request.directory === undefined) throw invalid();
|
|
if (request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
|
|
if (request.filename === undefined) throw invalid();
|
|
const allowClaim = request.operation === "remove" && request.directory === "oidc";
|
|
const allowOidcSlot = request.directory === "oidc"
|
|
&& (request.operation === "create" || request.operation === "read" || request.operation === "remove");
|
|
validateFilename(request.filename, allowClaim, allowOidcSlot);
|
|
if (request.contentBase64 !== undefined && request.operation !== "create" && request.operation !== "replace") throw invalid();
|
|
}
|
|
if ((request.operation === "claim-consume" || request.operation === "read-claim" || request.operation === "remove-claim")
|
|
&& request.directory !== "oidc") throw invalid();
|
|
if (request.contentBase64 !== undefined) {
|
|
if (request.directory === undefined) throw invalid();
|
|
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 }),
|
|
};
|
|
}
|
|
|
|
const spawnTht: WindowsAuthStorageSpawn = (executable, args, options) => spawn(executable, [...args], options);
|
|
|
|
function invokeThtSync(invocation: WindowsAuthStorageInvocation): WindowsAuthStorageInvocationResult {
|
|
try {
|
|
const result = spawnSync(invocation.executable, [...invocation.args], {
|
|
shell: false,
|
|
windowsHide: true,
|
|
env: environmentForBridge(),
|
|
input: invocation.input,
|
|
timeout: invocation.timeoutMs,
|
|
maxBuffer: invocation.maximumOutputBytes,
|
|
encoding: "buffer",
|
|
});
|
|
if (result.error || result.signal !== null || typeof result.status !== "number"
|
|
|| !Buffer.isBuffer(result.stdout) || !Buffer.isBuffer(result.stderr)) throw invalid();
|
|
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
}
|
|
|
|
async function invokeTht(
|
|
invocation: WindowsAuthStorageInvocation,
|
|
spawnChild: WindowsAuthStorageSpawn = spawnTht,
|
|
beforeInputForTest?: () => Promise<void>,
|
|
terminationGraceMs = TERMINATION_GRACE_MS,
|
|
finalSettlementMs = FINAL_SETTLEMENT_MS,
|
|
): Promise<WindowsAuthStorageInvocationResult> {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let aborted = false;
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
let terminationTimer: NodeJS.Timeout | undefined;
|
|
let finalSettlementTimer: NodeJS.Timeout | undefined;
|
|
let lateErrorReleaseTimer: NodeJS.Timeout | undefined;
|
|
const stdout: Buffer[] = [];
|
|
const stderr: Buffer[] = [];
|
|
let stdoutBytes = 0;
|
|
let stderrBytes = 0;
|
|
let child: WindowsAuthStorageChild | undefined;
|
|
let stdin: Writable | undefined;
|
|
let stdoutStream: Readable | undefined;
|
|
let stderrStream: Readable | undefined;
|
|
const swallowChildError = (): void => undefined;
|
|
const swallowStreamError = (): void => undefined;
|
|
const releaseQuarantine = (): void => {
|
|
if (lateErrorReleaseTimer !== undefined) clearTimeout(lateErrorReleaseTimer);
|
|
lateErrorReleaseTimer = undefined;
|
|
removeChildListener("error", swallowChildError);
|
|
removeChildListener("close", releaseQuarantine);
|
|
removeStreamListener(stdin, "error", swallowStreamError);
|
|
removeStreamListener(stdoutStream, "error", swallowStreamError);
|
|
removeStreamListener(stderrStream, "error", swallowStreamError);
|
|
};
|
|
const quarantineLateErrors = (): void => {
|
|
// A final-deadline settlement can precede a broken ChildProcess object's terminal events.
|
|
// Keep only no-capture listeners for a bounded grace period so a late EventEmitter error
|
|
// cannot become uncaught, including after an already-observed close event.
|
|
child?.on("error", swallowChildError);
|
|
child?.once("close", releaseQuarantine);
|
|
stdin?.on("error", swallowStreamError);
|
|
stdoutStream?.on("error", swallowStreamError);
|
|
stderrStream?.on("error", swallowStreamError);
|
|
lateErrorReleaseTimer = setTimeout(releaseQuarantine, finalSettlementMs);
|
|
lateErrorReleaseTimer.unref?.();
|
|
};
|
|
const removeChildListener = (event: "error" | "close", listener: (...args: any[]) => void): void => {
|
|
try { child?.removeListener?.(event, listener); } catch { /* the helper is already terminal */ }
|
|
};
|
|
const removeStreamListener = (stream: Writable | Readable | undefined, event: "data" | "error", listener: (...args: any[]) => void): void => {
|
|
try { stream?.removeListener(event, listener); } catch { /* the helper is already terminal */ }
|
|
};
|
|
const stopStream = (stream: Writable | Readable | null | undefined): void => {
|
|
try { stream?.destroy(); } catch { /* abort is already fail-closed */ }
|
|
};
|
|
const onChildError = (): void => abort();
|
|
const onStdinError = (): void => abort();
|
|
const onStdoutError = (): void => abort();
|
|
const onStderrError = (): void => abort();
|
|
const onStdoutData = (chunk: Buffer): void => {
|
|
if (aborted || settled) return;
|
|
stdoutBytes += chunk.length;
|
|
if (stdoutBytes > invocation.maximumOutputBytes) {
|
|
abort();
|
|
return;
|
|
}
|
|
stdout.push(Buffer.from(chunk));
|
|
};
|
|
const onStderrData = (chunk: Buffer): void => {
|
|
if (aborted || settled) return;
|
|
stderrBytes += chunk.length;
|
|
if (stderrBytes > MAX_RESPONSE_BYTES) {
|
|
abort();
|
|
return;
|
|
}
|
|
stderr.push(Buffer.from(chunk));
|
|
};
|
|
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
if (settled) return;
|
|
if (aborted || code === null || !Number.isInteger(code) || signal !== null) {
|
|
settle(() => reject(invalid()), true);
|
|
return;
|
|
}
|
|
settle(() => resolve({
|
|
code,
|
|
stdout: Buffer.concat(stdout),
|
|
stderr: Buffer.concat(stderr),
|
|
}));
|
|
};
|
|
const cleanup = (quarantine = false): void => {
|
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
if (terminationTimer !== undefined) clearTimeout(terminationTimer);
|
|
if (finalSettlementTimer !== undefined) clearTimeout(finalSettlementTimer);
|
|
removeChildListener("error", onChildError);
|
|
removeChildListener("close", onClose as (...args: any[]) => void);
|
|
removeStreamListener(stdin, "error", onStdinError);
|
|
removeStreamListener(stdoutStream, "data", onStdoutData);
|
|
removeStreamListener(stdoutStream, "error", onStdoutError);
|
|
removeStreamListener(stderrStream, "data", onStderrData);
|
|
removeStreamListener(stderrStream, "error", onStderrError);
|
|
if (quarantine) quarantineLateErrors();
|
|
};
|
|
const settle = (callback: () => void, quarantine = false): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup(quarantine);
|
|
callback();
|
|
};
|
|
const abort = (): void => {
|
|
if (aborted || settled) return;
|
|
aborted = true;
|
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
if (child !== undefined) {
|
|
stopStream(stdin);
|
|
stopStream(stdoutStream);
|
|
stopStream(stderrStream);
|
|
try { child.kill("SIGTERM"); } catch { /* final settlement still owns completion */ }
|
|
try { child.unref?.(); } catch { /* the bounded timers still own completion */ }
|
|
}
|
|
terminationTimer = setTimeout(() => {
|
|
if (settled || child === undefined) return;
|
|
try { child.kill("SIGKILL"); } catch { /* final settlement still owns completion */ }
|
|
}, terminationGraceMs);
|
|
finalSettlementTimer = setTimeout(() => {
|
|
settle(() => reject(invalid()), true);
|
|
}, finalSettlementMs);
|
|
};
|
|
try {
|
|
child = spawnChild(invocation.executable, invocation.args, {
|
|
shell: false,
|
|
windowsHide: true,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: environmentForBridge(),
|
|
});
|
|
} catch {
|
|
settle(() => reject(invalid()));
|
|
return;
|
|
}
|
|
child.once("close", onClose);
|
|
child.on("error", onChildError);
|
|
if (!child.stdin || !child.stdout || !child.stderr) {
|
|
abort();
|
|
return;
|
|
}
|
|
stdin = child.stdin;
|
|
stdoutStream = child.stdout;
|
|
stderrStream = child.stderr;
|
|
timeout = setTimeout(() => {
|
|
abort();
|
|
}, invocation.timeoutMs);
|
|
stdoutStream.on("data", onStdoutData);
|
|
stdoutStream.once("error", onStdoutError);
|
|
stderrStream.on("data", onStderrData);
|
|
stderrStream.once("error", onStderrError);
|
|
stdin.once("error", onStdinError);
|
|
const writeInput = (): void => {
|
|
if (aborted || settled) return;
|
|
try {
|
|
stdin.end(invocation.input);
|
|
} catch {
|
|
abort();
|
|
}
|
|
};
|
|
if (beforeInputForTest === undefined) {
|
|
writeInput();
|
|
} else {
|
|
void Promise.resolve().then(beforeInputForTest).then(writeInput, abort);
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function listedEntries(
|
|
response: BridgeResponse,
|
|
directory: WindowsAuthStorageDirectory,
|
|
maximumEntries: number,
|
|
): WindowsAuthStorageEntry[] {
|
|
if (response.entries === undefined || response.entries.length > maximumEntries) throw invalid();
|
|
const names = new Set<string>();
|
|
for (const entry of response.entries) {
|
|
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc"
|
|
&& (CLAIM_FILENAME.test(entry.name) || OIDC_SLOT_FILENAME.test(entry.name)))) throw invalid();
|
|
if (names.has(entry.name)) throw invalid();
|
|
names.add(entry.name);
|
|
}
|
|
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
|
|
}
|
|
|
|
function createAuthStorageBridge(
|
|
pathStyle: AuthStoragePathStyle,
|
|
options: WindowsAuthStorageBridgeOptions = {},
|
|
): WindowsAuthStorageBridge {
|
|
const executable = safeThtExecutable(options.thtExecutable, pathStyle);
|
|
const testDeadlines = options.deadlinesForTest;
|
|
const timeoutMs = testDeadlines?.timeoutMs ?? TIMEOUT_MS;
|
|
const terminationGraceMs = testDeadlines?.terminationGraceMs ?? TERMINATION_GRACE_MS;
|
|
const finalSettlementMs = testDeadlines?.finalSettlementMs ?? FINAL_SETTLEMENT_MS;
|
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > TIMEOUT_MS
|
|
|| !Number.isSafeInteger(terminationGraceMs) || terminationGraceMs < 1 || terminationGraceMs > TIMEOUT_MS
|
|
|| !Number.isSafeInteger(finalSettlementMs) || finalSettlementMs <= terminationGraceMs || finalSettlementMs > TIMEOUT_MS) {
|
|
throw invalid();
|
|
}
|
|
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(
|
|
invocation,
|
|
options.spawnChild,
|
|
options.beforeInputForTest,
|
|
terminationGraceMs,
|
|
finalSettlementMs,
|
|
));
|
|
const invokeSync = options.invokeSync ?? invokeThtSync;
|
|
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
|
|
try {
|
|
const maximumOutputBytes = value.operation === "read-local-users"
|
|
? MAX_AUTH_CONFIG_RESPONSE_BYTES
|
|
: MAX_RESPONSE_BYTES;
|
|
const response = await invoke({
|
|
executable,
|
|
args: ["_auth-storage"],
|
|
input: encodedRequest(value, pathStyle),
|
|
timeoutMs,
|
|
maximumOutputBytes,
|
|
});
|
|
return parseResponse(response, maximumOutputBytes);
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
};
|
|
const syncRequest = (value: BridgeRequest): BridgeResponse => {
|
|
try {
|
|
const response = invokeSync({
|
|
executable,
|
|
args: ["_auth-storage"],
|
|
input: encodedRequest(value, pathStyle),
|
|
timeoutMs,
|
|
maximumOutputBytes: MAX_AUTH_CONFIG_RESPONSE_BYTES,
|
|
});
|
|
return parseResponse(response, MAX_AUTH_CONFIG_RESPONSE_BYTES);
|
|
} catch {
|
|
throw invalid();
|
|
}
|
|
};
|
|
const recordRequest = (operation: "create" | "read" | "replace" | "remove" | "claim-consume" | "read-claim" | "remove-claim", root: string, directory: WindowsAuthStorageDirectory, filename: string, contents?: Buffer): BridgeRequest => ({
|
|
version: PROTOCOL_VERSION,
|
|
operation,
|
|
root,
|
|
directory,
|
|
filename,
|
|
...(contents === undefined ? {} : { contentBase64: contents.toString("base64") }),
|
|
});
|
|
|
|
return {
|
|
async validateRoot(root) {
|
|
const response = await request({ version: PROTOCOL_VERSION, operation: "validate-root", root });
|
|
if (response.validated !== true
|
|
|| Object.keys(response).some((key) => !["version", "ok", "validated"].includes(key))) throw invalid();
|
|
},
|
|
async ensureLayout(root) {
|
|
const response = await request({ version: PROTOCOL_VERSION, operation: "ensure-layout", root });
|
|
if (response.prepared !== true
|
|
|| Object.keys(response).some((key) => !["version", "ok", "prepared"].includes(key))) throw invalid();
|
|
},
|
|
readAuthConfig(path) {
|
|
const paths = pathStyle === "windows" ? win32 : posix;
|
|
if (typeof path !== "string" || path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
|
|
|| !paths.isAbsolute(path) || paths.normalize(path) !== path) throw invalid();
|
|
const root = paths.dirname(path);
|
|
const filename = paths.basename(path);
|
|
if (!AUTH_CONFIG_FILENAME.test(filename) || paths.join(root, filename) !== path) throw invalid();
|
|
const response = syncRequest({ version: PROTOCOL_VERSION, operation: "read-auth-config", root, filename });
|
|
if (Object.keys(response).some((key) => !["version", "ok", "found", "contentBase64"].includes(key))) throw invalid();
|
|
const contents = contentFrom(response, MAX_AUTH_CONFIG_BYTES);
|
|
if (contents === undefined) throw invalid();
|
|
return contents;
|
|
},
|
|
async readLocalUsers(path) {
|
|
const paths = pathStyle === "windows" ? win32 : posix;
|
|
if (typeof path !== "string" || path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
|
|
|| !paths.isAbsolute(path) || paths.normalize(path) !== path) throw invalid();
|
|
const root = paths.dirname(path);
|
|
const filename = paths.basename(path);
|
|
if (!AUTH_CONFIG_FILENAME.test(filename) || paths.join(root, filename) !== path) throw invalid();
|
|
const response = await request({ version: PROTOCOL_VERSION, operation: "read-local-users", root, filename });
|
|
if (Object.keys(response).some((key) => !["version", "ok", "found", "contentBase64"].includes(key))) throw invalid();
|
|
const contents = contentFrom(response, MAX_AUTH_CONFIG_BYTES);
|
|
if (contents === undefined) throw invalid();
|
|
return contents;
|
|
},
|
|
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, maximumEntries = DEFAULT_MAX_ENTRIES) {
|
|
if (!Number.isInteger(maximumEntries) || maximumEntries < 1 || maximumEntries > MAX_ENTRIES) throw invalid();
|
|
const response = await request({
|
|
version: PROTOCOL_VERSION,
|
|
operation: "list",
|
|
root,
|
|
directory,
|
|
maximumEntries,
|
|
});
|
|
if (response.more !== undefined) throw invalid();
|
|
return listedEntries(response, directory, maximumEntries);
|
|
},
|
|
async listPage(root, directory, afterName, maximumEntries) {
|
|
if (directory !== "sessions" || !Number.isInteger(maximumEntries)
|
|
|| maximumEntries < 1 || maximumEntries > MAX_ENTRIES
|
|
|| (afterName !== undefined && !DIGEST_FILENAME.test(afterName))) throw invalid();
|
|
const response = await request({
|
|
version: PROTOCOL_VERSION,
|
|
operation: "list",
|
|
root,
|
|
directory,
|
|
maximumEntries,
|
|
continuation: true,
|
|
...(afterName === undefined ? {} : { afterName }),
|
|
});
|
|
if (response.more === undefined) throw invalid();
|
|
const entries = listedEntries(response, directory, maximumEntries);
|
|
let previous = afterName;
|
|
for (const entry of entries) {
|
|
if (previous !== undefined && entry.name <= previous) throw invalid();
|
|
previous = entry.name;
|
|
}
|
|
if (response.more && entries.length !== maximumEntries) throw invalid();
|
|
if (response.more && (previous === undefined || previous === afterName)) throw invalid();
|
|
return { entries, more: response.more };
|
|
},
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
|
|
return createAuthStorageBridge("windows", options);
|
|
}
|
|
|
|
/** POSIX uses the same single hidden tht protocol and bounds, with native canonical path rules. */
|
|
export function createPosixAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
|
|
return createAuthStorageBridge("posix", options);
|
|
}
|