fix(auth): tighten bridge claim protocol
This commit is contained in:
@@ -22,6 +22,7 @@ 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),
|
||||
}));
|
||||
@@ -45,6 +46,8 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
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;
|
||||
@@ -69,6 +72,7 @@ const validLocalUser = { enabled: true, authRevision: 7, roles: ["admin"] as con
|
||||
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 });
|
||||
@@ -297,6 +301,23 @@ describe("file-backed auth session store", () => {
|
||||
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({ nonce: "n".repeat(43), codeVerifier: "v".repeat(43), returnTo: "/" }, 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);
|
||||
@@ -639,6 +660,72 @@ describe("file-backed auth session store", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user