779 lines
33 KiB
TypeScript
779 lines
33 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { fork } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
linkSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
renameSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
|
|
const fsHooks = vi.hoisted(() => ({
|
|
afterRead: undefined as undefined | (() => void),
|
|
afterWrite: undefined as undefined | (() => void),
|
|
beforeLstat: undefined as undefined | ((path: string) => boolean),
|
|
afterLstat: undefined as undefined | ((path: string) => boolean),
|
|
transformLstat: undefined as undefined | ((path: string, info: import("node:fs").Stats) => import("node:fs").Stats),
|
|
}));
|
|
|
|
vi.mock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
return {
|
|
...actual,
|
|
readSync: (...args: Parameters<typeof actual.readSync>) => {
|
|
const result = actual.readSync(...args);
|
|
const callback = fsHooks.afterRead;
|
|
fsHooks.afterRead = undefined;
|
|
callback?.();
|
|
return result;
|
|
},
|
|
writeSync: (...args: Parameters<typeof actual.writeSync>) => {
|
|
const result = actual.writeSync(...args);
|
|
const callback = fsHooks.afterWrite;
|
|
fsHooks.afterWrite = undefined;
|
|
callback?.();
|
|
return result;
|
|
},
|
|
lstatSync: (...args: Parameters<typeof actual.lstatSync>) => {
|
|
const before = fsHooks.beforeLstat;
|
|
if (before?.(String(args[0]))) fsHooks.beforeLstat = undefined;
|
|
const original = actual.lstatSync(...args);
|
|
const result = fsHooks.transformLstat?.(String(args[0]), original) ?? original;
|
|
const callback = fsHooks.afterLstat;
|
|
if (callback?.(String(args[0]))) fsHooks.afterLstat = undefined;
|
|
return result;
|
|
},
|
|
};
|
|
});
|
|
import {
|
|
createFileAuthSessionStore,
|
|
deriveCsrfToken,
|
|
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");
|
|
const revision = "a".repeat(64);
|
|
const validLocalUser = { enabled: true, authRevision: 7, roles: ["admin"] as const };
|
|
|
|
function oidcInput(nonce: string, codeVerifier: string) {
|
|
return { nonce, codeVerifier, returnTo: "/" as const, authConfigRevision: revision, issuer: "https://issuer.example.test" };
|
|
}
|
|
|
|
afterEach(() => {
|
|
fsHooks.afterRead = undefined;
|
|
fsHooks.afterWrite = undefined;
|
|
fsHooks.beforeLstat = undefined;
|
|
fsHooks.afterLstat = undefined;
|
|
fsHooks.transformLstat = undefined;
|
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
function root(): string {
|
|
const path = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-session-"));
|
|
chmodSync(path, 0o700);
|
|
roots.push(path);
|
|
return path;
|
|
}
|
|
|
|
function digestPath(rootPath: string, directory: "sessions" | "oidc", rawValue: string): string {
|
|
return join(rootPath, directory, `${createHash("sha256").update(rawValue).digest("hex")}.json`);
|
|
}
|
|
|
|
function claimPath(rootPath: string, rawState: string): string {
|
|
return join(rootPath, "oidc", `${createHash("sha256").update(rawState).digest("hex")}.claim`);
|
|
}
|
|
|
|
function validStore(storageRoot: string): AuthSessionStore {
|
|
return createFileAuthSessionStore(storageRoot, {
|
|
currentAuthConfigRevision: () => revision,
|
|
findLocalUser: async () => validLocalUser,
|
|
});
|
|
}
|
|
|
|
async function create(
|
|
store: AuthSessionStore,
|
|
overrides: Partial<SessionCreateInput> = {},
|
|
now = base,
|
|
) {
|
|
return store.create({
|
|
principal: {
|
|
issuer: "local",
|
|
subject: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8",
|
|
displayName: "Local administrator",
|
|
roles: ["admin"],
|
|
permissions: ["session.use", "settings.manage"],
|
|
isAdmin: true,
|
|
},
|
|
method: "local",
|
|
remembered: true,
|
|
userAuthRevision: 7,
|
|
authConfigRevision: revision,
|
|
idleTtlMs: 10 * 60_000,
|
|
absoluteTtlMs: 30 * 60_000,
|
|
...overrides,
|
|
}, now);
|
|
}
|
|
|
|
async function expectStoreInvalid(operation: Promise<unknown>): Promise<void> {
|
|
await expect(operation).rejects.toThrow("auth_session_store_invalid");
|
|
}
|
|
|
|
async function isolatedOidcConsumer(storageRoot: string, state: string): Promise<{
|
|
start(): void;
|
|
result: Promise<boolean>;
|
|
}> {
|
|
const child = fork(new URL("./fixtures/oidc-state-consumer.mts", import.meta.url), [], {
|
|
cwd: process.cwd(),
|
|
execArgv: ["--import", "tsx"],
|
|
env: {
|
|
...process.env,
|
|
THT_TEST_SESSION_ROOT: storageRoot,
|
|
THT_TEST_OIDC_STATE: state,
|
|
},
|
|
silent: true,
|
|
});
|
|
const ready = new Promise<void>((resolve, reject) => {
|
|
child.once("message", (message) => {
|
|
if (message === "ready") resolve();
|
|
else reject(new Error("OIDC consumer did not become ready"));
|
|
});
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
if (code !== null && code !== 0) reject(new Error("OIDC consumer exited before ready"));
|
|
});
|
|
});
|
|
const result = new Promise<boolean>((resolve, reject) => {
|
|
child.on("message", (message) => {
|
|
if (message && typeof message === "object" && "consumed" in message) {
|
|
const outcome = message as { consumed: unknown; failed?: unknown };
|
|
if (outcome.failed === true) reject(new Error("OIDC consumer failed"));
|
|
else resolve(outcome.consumed === true);
|
|
}
|
|
});
|
|
child.on("error", reject);
|
|
child.on("exit", (code) => {
|
|
if (code !== 0) reject(new Error("OIDC consumer exited without a result"));
|
|
});
|
|
});
|
|
await ready;
|
|
return { start: () => child.send("consume"), result };
|
|
}
|
|
|
|
describe("file-backed auth session store", () => {
|
|
test("fails closed and revokes a session when constructed without validity dependencies", async () => {
|
|
const storageRoot = root();
|
|
const store = createFileAuthSessionStore(storageRoot);
|
|
const created = await create(store);
|
|
|
|
await expect(store.resolve(created.token)).resolves.toBeUndefined();
|
|
expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false);
|
|
});
|
|
|
|
test("revokes a session when a validity dependency throws", async () => {
|
|
const storageRoot = root();
|
|
const store = createFileAuthSessionStore(storageRoot, {
|
|
currentAuthConfigRevision: () => { throw new Error("dependency unavailable"); },
|
|
findLocalUser: async () => validLocalUser,
|
|
});
|
|
const created = await create(store);
|
|
|
|
await expectStoreInvalid(store.resolve(created.token));
|
|
expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false);
|
|
});
|
|
|
|
test("creates 256-bit opaque tokens, digest-only files, and derived CSRF values", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const first = await create(store);
|
|
const second = await create(store);
|
|
const path = digestPath(storageRoot, "sessions", first.token);
|
|
const contents = readFileSync(path, "utf8");
|
|
|
|
expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
expect(Buffer.from(first.token, "base64url")).toHaveLength(32);
|
|
expect(second.token).not.toBe(first.token);
|
|
expect(lstatSync(path).isFile()).toBe(true);
|
|
expect(path).toMatch(/[a-f0-9]{64}\.json$/);
|
|
expect(contents).not.toContain(first.token);
|
|
expect(contents).not.toContain(first.csrfToken);
|
|
expect(deriveCsrfToken(first.token)).toBe(first.csrfToken);
|
|
expect(first.csrfToken).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
expect(first.csrfToken).not.toBe(first.token);
|
|
|
|
if (process.platform !== "win32") {
|
|
expect(lstatSync(storageRoot).mode & 0o7777).toBe(0o700);
|
|
expect(lstatSync(join(storageRoot, "sessions")).mode & 0o7777).toBe(0o700);
|
|
expect(lstatSync(join(storageRoot, "oidc")).mode & 0o7777).toBe(0o700);
|
|
expect(lstatSync(path).mode & 0o7777).toBe(0o600);
|
|
}
|
|
});
|
|
|
|
test("survives a backend restart and respects idle and absolute expiry", async () => {
|
|
const storageRoot = root();
|
|
const firstStore = validStore(storageRoot);
|
|
const created = await create(firstStore);
|
|
const restartedStore = validStore(storageRoot);
|
|
|
|
await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 9 * 60_000)))
|
|
.resolves.toMatchObject({ subject: created.record.subject, remembered: true });
|
|
await expect(restartedStore.resolve(created.token, new Date(base.getTime() + 10 * 60_000)))
|
|
.resolves.toBeUndefined();
|
|
expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false);
|
|
|
|
const absolute = await create(restartedStore, { idleTtlMs: 30 * 60_000, absoluteTtlMs: 10 * 60_000 });
|
|
await expect(restartedStore.resolve(absolute.token, new Date(base.getTime() + 10 * 60_000)))
|
|
.resolves.toBeUndefined();
|
|
});
|
|
|
|
test("touches at most once per five minutes and never extends absolute expiry", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await create(store);
|
|
const path = digestPath(storageRoot, "sessions", created.token);
|
|
const before = readFileSync(path, "utf8");
|
|
|
|
await store.touch(created.token, new Date(base.getTime() + 4 * 60_000));
|
|
expect(readFileSync(path, "utf8")).toBe(before);
|
|
|
|
await store.touch(created.token, new Date(base.getTime() + 5 * 60_000));
|
|
await expect(store.resolve(created.token, new Date(base.getTime() + 5 * 60_000))).resolves.toMatchObject({
|
|
lastSeenAt: "2030-01-02T03:09:05.000Z",
|
|
idleExpiresAt: "2030-01-02T03:19:05.000Z",
|
|
absoluteExpiresAt: "2030-01-02T03:34:05.000Z",
|
|
});
|
|
});
|
|
|
|
test("revokes sessions and prunes expired session and OIDC-state records", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const revoked = await create(store);
|
|
const expired = await create(store, { idleTtlMs: 60_000, absoluteTtlMs: 60_000 });
|
|
const oidc = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
|
|
|
|
await store.revoke(revoked.token);
|
|
await expect(store.resolve(revoked.token)).resolves.toBeUndefined();
|
|
expect(existsSync(digestPath(storageRoot, "sessions", revoked.token))).toBe(false);
|
|
|
|
await expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(2);
|
|
expect(existsSync(digestPath(storageRoot, "sessions", expired.token))).toBe(false);
|
|
expect(existsSync(digestPath(storageRoot, "oidc", oidc.state))).toBe(false);
|
|
});
|
|
|
|
test("creates bounded OIDC state records that expire and are single-use", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
|
|
const path = digestPath(storageRoot, "oidc", created.state);
|
|
|
|
expect(created.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
expect(readFileSync(path, "utf8")).not.toContain(created.state);
|
|
await expect(store.consumeOidcState(created.state, new Date(base.getTime() + 9 * 60_000)))
|
|
.resolves.toMatchObject({
|
|
nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/",
|
|
authConfigRevision: revision, issuer: "https://issuer.example.test",
|
|
});
|
|
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
|
|
|
|
const expired = await store.createOidcState(oidcInput("x".repeat(43), "y".repeat(43)), base);
|
|
await expect(store.consumeOidcState(expired.state, new Date(base.getTime() + 10 * 60_000)))
|
|
.resolves.toBeUndefined();
|
|
});
|
|
|
|
test("fails closed when an OIDC state already has an atomic filesystem claim", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
|
|
const statePath = digestPath(storageRoot, "oidc", created.state);
|
|
linkSync(statePath, claimPath(storageRoot, created.state));
|
|
|
|
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
|
|
expect(existsSync(statePath)).toBe(true);
|
|
});
|
|
|
|
test("treats a competing OIDC claim installed between availability and state checks as unavailable", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
|
|
const statePath = digestPath(storageRoot, "oidc", created.state);
|
|
const stateClaimPath = claimPath(storageRoot, created.state);
|
|
fsHooks.beforeLstat = (observed) => {
|
|
if (observed !== statePath) return false;
|
|
linkSync(statePath, stateClaimPath);
|
|
return true;
|
|
};
|
|
|
|
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
|
|
expect(existsSync(statePath)).toBe(true);
|
|
expect(existsSync(stateClaimPath)).toBe(true);
|
|
});
|
|
|
|
test("prunes an expired OIDC state abandoned after an atomic claim", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
|
|
const statePath = digestPath(storageRoot, "oidc", created.state);
|
|
const stateClaimPath = claimPath(storageRoot, created.state);
|
|
linkSync(statePath, stateClaimPath);
|
|
|
|
await expect(store.prune(new Date(base.getTime() + 10 * 60_000))).resolves.toBe(1);
|
|
expect(existsSync(statePath)).toBe(false);
|
|
expect(existsSync(stateClaimPath)).toBe(false);
|
|
});
|
|
|
|
test("retains an in-flight orphan claim but removes it after the bounded recovery window", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
|
|
const statePath = digestPath(storageRoot, "oidc", created.state);
|
|
const stateClaimPath = claimPath(storageRoot, created.state);
|
|
linkSync(statePath, stateClaimPath);
|
|
unlinkSync(statePath);
|
|
|
|
await expect(store.consumeOidcState(created.state)).resolves.toBeUndefined();
|
|
expect(existsSync(stateClaimPath)).toBe(true);
|
|
await expect(store.prune(new Date("2031-01-02T03:04:05.000Z"))).resolves.toBe(1);
|
|
expect(existsSync(stateClaimPath)).toBe(false);
|
|
});
|
|
|
|
test("allows exactly one separate Node isolate to consume an OIDC state", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await store.createOidcState(oidcInput("n".repeat(43), "v".repeat(43)), base);
|
|
const [first, second] = await Promise.all([
|
|
isolatedOidcConsumer(storageRoot, created.state),
|
|
isolatedOidcConsumer(storageRoot, created.state),
|
|
]);
|
|
|
|
first.start();
|
|
second.start();
|
|
const outcomes = await Promise.all([first.result, second.result]);
|
|
expect(outcomes.filter(Boolean)).toHaveLength(1);
|
|
expect(existsSync(digestPath(storageRoot, "oidc", created.state))).toBe(false);
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("refuses symlinked and hard-linked session records", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const symlinked = await create(store);
|
|
const symlinkPath = digestPath(storageRoot, "sessions", symlinked.token);
|
|
const target = `${symlinkPath}.target`;
|
|
renameSync(symlinkPath, target);
|
|
symlinkSync(target, symlinkPath);
|
|
await expectStoreInvalid(store.resolve(symlinked.token));
|
|
|
|
const hardLinked = await create(store);
|
|
const hardLinkPath = digestPath(storageRoot, "sessions", hardLinked.token);
|
|
linkSync(hardLinkPath, `${hardLinkPath}.link`);
|
|
await expectStoreInvalid(store.resolve(hardLinked.token));
|
|
});
|
|
|
|
test("refuses malformed and oversized session records without disclosing their contents", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const malformed = await create(store);
|
|
const malformedPath = digestPath(storageRoot, "sessions", malformed.token);
|
|
writeFileSync(malformedPath, "{}", { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(malformedPath, 0o600);
|
|
await expectStoreInvalid(store.resolve(malformed.token));
|
|
|
|
const oversized = await create(store);
|
|
const oversizedPath = digestPath(storageRoot, "sessions", oversized.token);
|
|
writeFileSync(oversizedPath, "#".repeat(20_000), { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(oversizedPath, 0o600);
|
|
await expectStoreInvalid(store.resolve(oversized.token));
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("refuses unsafe existing roots and storage subdirectories", async () => {
|
|
const unsafeRoot = root();
|
|
chmodSync(unsafeRoot, 0o755);
|
|
await expectStoreInvalid(create(validStore(unsafeRoot)));
|
|
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await create(store);
|
|
chmodSync(join(storageRoot, "sessions"), 0o755);
|
|
await expectStoreInvalid(store.resolve(created.token));
|
|
|
|
const outer = root();
|
|
const realRoot = join(outer, "real-auth");
|
|
mkdirSync(realRoot, { mode: 0o700 });
|
|
chmodSync(realRoot, 0o700);
|
|
const linkedRoot = join(outer, "linked-auth");
|
|
symlinkSync(realRoot, linkedRoot);
|
|
await expectStoreInvalid(create(validStore(linkedRoot)));
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("refuses storage owned by a different identity", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await create(store);
|
|
const sessions = join(storageRoot, "sessions");
|
|
fsHooks.transformLstat = (observed, info) => {
|
|
if (observed !== sessions) return info;
|
|
const foreign = Object.create(info) as import("node:fs").Stats;
|
|
Object.defineProperty(foreign, "uid", { value: info.uid + 1 });
|
|
return foreign;
|
|
};
|
|
|
|
await expectStoreInvalid(store.resolve(created.token));
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("refuses directory replacement during a session read", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await create(store);
|
|
const sessions = join(storageRoot, "sessions");
|
|
const replacement = join(storageRoot, "sessions-replacement");
|
|
fsHooks.afterRead = () => {
|
|
renameSync(sessions, replacement);
|
|
symlinkSync(replacement, sessions);
|
|
};
|
|
|
|
await expectStoreInvalid(store.resolve(created.token));
|
|
});
|
|
|
|
test("refuses file replacement during a touch", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await create(store);
|
|
const path = digestPath(storageRoot, "sessions", created.token);
|
|
const replacement = `${path}.replacement`;
|
|
fsHooks.afterWrite = () => {
|
|
writeFileSync(replacement, "{}", { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(replacement, 0o600);
|
|
renameSync(replacement, path);
|
|
};
|
|
|
|
await expectStoreInvalid(store.touch(created.token, new Date(base.getTime() + 5 * 60_000)));
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("refuses directory replacement during a touch", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await create(store);
|
|
const path = digestPath(storageRoot, "sessions", created.token);
|
|
const sessions = join(storageRoot, "sessions");
|
|
const replacement = join(storageRoot, "sessions-replacement");
|
|
fsHooks.afterLstat = (observed) => {
|
|
if (observed !== path) return false;
|
|
renameSync(sessions, replacement);
|
|
symlinkSync(replacement, sessions);
|
|
return true;
|
|
};
|
|
|
|
await expectStoreInvalid(store.touch(created.token, new Date(base.getTime() + 5 * 60_000)));
|
|
});
|
|
|
|
test("refuses file replacement during revoke", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await create(store);
|
|
const path = digestPath(storageRoot, "sessions", created.token);
|
|
const replacement = `${path}.replacement`;
|
|
writeFileSync(replacement, "{}", { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(replacement, 0o600);
|
|
fsHooks.afterLstat = (observed) => {
|
|
if (observed !== path) return false;
|
|
renameSync(replacement, path);
|
|
return true;
|
|
};
|
|
|
|
await expectStoreInvalid(store.revoke(created.token));
|
|
expect(readFileSync(path, "utf8")).toBe("{}");
|
|
});
|
|
|
|
test.skipIf(process.platform === "win32")("refuses directory replacement during revoke", async () => {
|
|
const storageRoot = root();
|
|
const store = validStore(storageRoot);
|
|
const created = await create(store);
|
|
const path = digestPath(storageRoot, "sessions", created.token);
|
|
const sessions = join(storageRoot, "sessions");
|
|
const replacement = join(storageRoot, "sessions-replacement");
|
|
fsHooks.afterLstat = (observed) => {
|
|
if (observed !== path) return false;
|
|
renameSync(sessions, replacement);
|
|
symlinkSync(replacement, sessions);
|
|
return true;
|
|
};
|
|
|
|
await expectStoreInvalid(store.revoke(created.token));
|
|
});
|
|
|
|
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 {
|
|
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(oidcInput("n".repeat(43), "v".repeat(43)), 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(oidcInput("x".repeat(43), "y".repeat(43)), 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("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(oidcInput("n".repeat(43), "v".repeat(43)), 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("prunes empty Windows directories through the required Go entries array", async () => {
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
invoke: async () => ({
|
|
code: 0,
|
|
stdout: Buffer.from('{"version":1,"ok":true,"entries":[]}\n'),
|
|
stderr: Buffer.alloc(0),
|
|
}),
|
|
});
|
|
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 expect(store.prune(base)).resolves.toBe(0);
|
|
} finally {
|
|
Object.defineProperty(process, "platform", originalPlatform);
|
|
}
|
|
});
|
|
|
|
test("prunes a stale Windows OIDC orphan claim through the Go DTO path", async () => {
|
|
const claim = `${"d".repeat(64)}.claim`;
|
|
let removed = false;
|
|
const bridge = createWindowsAuthStorageBridge({
|
|
thtExecutable: "C:\\tht.exe",
|
|
invoke: async ({ input }) => {
|
|
const request = JSON.parse(input.toString("utf8")) as { operation: string; directory: string; filename?: string };
|
|
const response = (value: Record<string, unknown>) => ({
|
|
code: 0,
|
|
stdout: Buffer.from(`${JSON.stringify({ version: 1, ok: true, ...value })}\n`),
|
|
stderr: Buffer.alloc(0),
|
|
});
|
|
if (request.operation === "list") {
|
|
return response({
|
|
entries: request.directory === "oidc"
|
|
? [{ name: claim, modifiedUnixMs: base.getTime() }]
|
|
: [],
|
|
});
|
|
}
|
|
if (request.operation === "remove" && request.directory === "oidc" && request.filename === claim) {
|
|
removed = true;
|
|
return response({ removed: true });
|
|
}
|
|
throw new Error("unexpected bridge request");
|
|
},
|
|
});
|
|
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 expect(store.prune(new Date(base.getTime() + 11 * 60_000))).resolves.toBe(1);
|
|
expect(removed).toBe(true);
|
|
} 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;
|
|
let localUser: { enabled: boolean; authRevision: number; roles: readonly ("user" | "admin")[] } | undefined = {
|
|
enabled: true,
|
|
authRevision: 7,
|
|
roles: ["admin"],
|
|
};
|
|
const store = createFileAuthSessionStore(storageRoot, {
|
|
currentAuthConfigRevision: () => currentRevision,
|
|
findLocalUser: async () => localUser,
|
|
});
|
|
|
|
const configChanged = await create(store);
|
|
currentRevision = "b".repeat(64);
|
|
await expect(store.resolve(configChanged.token)).resolves.toBeUndefined();
|
|
expect(existsSync(digestPath(storageRoot, "sessions", configChanged.token))).toBe(false);
|
|
|
|
currentRevision = revision;
|
|
for (const mismatch of [
|
|
undefined,
|
|
{ enabled: false, authRevision: 7, roles: ["admin"] as const },
|
|
{ enabled: true, authRevision: 8, roles: ["admin"] as const },
|
|
{ enabled: true, authRevision: 7, roles: ["user"] as const },
|
|
]) {
|
|
localUser = mismatch;
|
|
const session = await create(store);
|
|
await expect(store.resolve(session.token)).resolves.toBeUndefined();
|
|
expect(existsSync(digestPath(storageRoot, "sessions", session.token))).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("serializes concurrent resolve and revoke without resurrecting a record", async () => {
|
|
const storageRoot = root();
|
|
const firstStore = validStore(storageRoot);
|
|
const secondStore = validStore(storageRoot);
|
|
const created = await create(firstStore);
|
|
|
|
await Promise.all([
|
|
...Array.from({ length: 8 }, () => firstStore.resolve(created.token)),
|
|
...Array.from({ length: 8 }, () => secondStore.revoke(created.token)),
|
|
]);
|
|
|
|
await expect(firstStore.resolve(created.token)).resolves.toBeUndefined();
|
|
expect(existsSync(digestPath(storageRoot, "sessions", created.token))).toBe(false);
|
|
});
|
|
});
|