From 8477a69a291248a0c7d0b0bdc3458e7027c05cdd Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 16 Aug 2026 22:44:44 +0200 Subject: [PATCH] fix(auth): tighten bridge claim protocol --- backend/src/auth/session-store.ts | 3 + backend/src/auth/windows-auth-storage.ts | 42 +++++-- backend/test/auth-session-store.test.ts | 87 +++++++++++++ .../windows-auth-storage-real-child.mjs | 21 ++++ backend/test/windows-auth-storage.test.ts | 114 +++++++++++++++++- tools/tht/internal/authstorage/storage.go | 61 +++++----- .../tht/internal/authstorage/storage_test.go | 45 ++++++- 7 files changed, 333 insertions(+), 40 deletions(-) create mode 100644 backend/test/fixtures/windows-auth-storage-real-child.mjs diff --git a/backend/src/auth/session-store.ts b/backend/src/auth/session-store.ts index ad8216c1..1b0d68d2 100644 --- a/backend/src/auth/session-store.ts +++ b/backend/src/auth/session-store.ts @@ -609,6 +609,9 @@ function claimOidcState(directory: string, filename: string): OidcStateClaim | u before = fileIdentity(lstatSync(statePath) as Stats); } catch (error) { if (isNotFound(error)) return undefined; + // A winner can install its hard-link after the first claim check and before this state + // identity check. That process owns the state; this contender must fail closed as used. + if (oidcClaimExists(directory, filename)) return undefined; throw invalid(); } try { diff --git a/backend/src/auth/windows-auth-storage.ts b/backend/src/auth/windows-auth-storage.ts index c6b21738..d840956b 100644 --- a/backend/src/auth/windows-auth-storage.ts +++ b/backend/src/auth/windows-auth-storage.ts @@ -70,6 +70,8 @@ export interface WindowsAuthStorageBridgeOptions { 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; } const responseSchema = z.strictObject({ @@ -117,8 +119,8 @@ function validateRoot(root: string): void { || !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 validateFilename(filename: string, allowClaim = false): void { + if (typeof filename !== "string" || (!DIGEST_FILENAME.test(filename) && !(allowClaim && CLAIM_FILENAME.test(filename)))) throw invalid(); } function safeThtExecutable(value: string | undefined): string { @@ -144,7 +146,16 @@ function parseResponse(result: WindowsAuthStorageInvocationResult): BridgeRespon function encodedRequest(request: BridgeRequest): Buffer { validateRoot(request.root); - if (request.filename !== undefined) validateFilename(request.filename); + if (request.operation === "list") { + if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid(); + } else { + if (request.filename === undefined) throw invalid(); + const allowClaim = request.operation === "remove" && request.directory === "oidc"; + validateFilename(request.filename, allowClaim); + 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) 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(); @@ -165,6 +176,7 @@ const spawnTht: WindowsAuthStorageSpawn = (executable, args, options) => spawn(e async function invokeTht( invocation: WindowsAuthStorageInvocation, spawnChild: WindowsAuthStorageSpawn = spawnTht, + beforeInputForTest?: () => Promise, ): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -260,10 +272,18 @@ async function invokeTht( stderr.push(Buffer.from(chunk)); }); stdin.once("error", abort); - try { - stdin.end(invocation.input); - } catch { - abort(); + 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); } }); } @@ -279,7 +299,11 @@ function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefi export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge { const executable = safeThtExecutable(options.thtExecutable); - const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(invocation, options.spawnChild)); + const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht( + invocation, + options.spawnChild, + options.beforeInputForTest, + )); const request = async (value: BridgeRequest): Promise => { try { const response = await invoke({ @@ -325,7 +349,7 @@ export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridge 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(); + if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc" && CLAIM_FILENAME.test(entry.name))) throw invalid(); } return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs })); }, diff --git a/backend/test/auth-session-store.test.ts b/backend/test/auth-session-store.test.ts index b8754943..9be986e1 100644 --- a/backend/test/auth-session-store.test.ts +++ b/backend/test/auth-session-store.test.ts @@ -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) => { + 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) => ({ + 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; diff --git a/backend/test/fixtures/windows-auth-storage-real-child.mjs b/backend/test/fixtures/windows-auth-storage-real-child.mjs new file mode 100644 index 00000000..394666f8 --- /dev/null +++ b/backend/test/fixtures/windows-auth-storage-real-child.mjs @@ -0,0 +1,21 @@ +import { appendFileSync, closeSync, writeFileSync } from "node:fs"; + +const [, , mode, marker] = process.argv; + +writeFileSync(marker, `started:${process.pid}\n`); +process.on("exit", () => appendFileSync(marker, "exited\n")); +process.on("SIGTERM", () => { + appendFileSync(marker, "terminated\n"); + process.exit(0); +}); + +if (mode === "stdin") { + closeSync(0); + appendFileSync(marker, "stdin-closed\n"); +} else if (mode === "stdout") { + process.stdout.write(Buffer.alloc(64 * 1024 + 1)); +} else if (mode === "stderr") { + process.stderr.write(Buffer.alloc(64 * 1024 + 1)); +} + +setInterval(() => {}, 1_000); diff --git a/backend/test/windows-auth-storage.test.ts b/backend/test/windows-auth-storage.test.ts index b0cbb0a5..72460463 100644 --- a/backend/test/windows-auth-storage.test.ts +++ b/backend/test/windows-auth-storage.test.ts @@ -1,10 +1,63 @@ +import { appendFileSync, chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { createWindowsAuthStorageBridge } from "../src/auth/windows-auth-storage.js"; const root = "C:\\ProgramData\\ThothII\\auth"; const filename = "a".repeat(64) + ".json"; +const realChildFixture = fileURLToPath(new URL("./fixtures/windows-auth-storage-real-child.mjs", import.meta.url)); +const fixtureRoots: string[] = []; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +async function waitForMarker(marker: string, expected: string): Promise { + const deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + if (existsSync(marker) && readFileSync(marker, "utf8").includes(expected)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`real helper marker did not contain ${expected}`); +} + +function realChildBridge(mode: "timeout" | "stdout" | "stderr" | "stdin") { + const directory = mkdtempSync(join(tmpdir(), "thothii-auth-bridge-child-")); + fixtureRoots.push(directory); + const marker = join(directory, "marker.txt"); + const launcher = join(directory, "tht.exe"); + writeFileSync(launcher, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(realChildFixture)} ${shellQuote(mode)} ${shellQuote(marker)} "$@"\n`, { mode: 0o700 }); + chmodSync(launcher, 0o700); + return { + marker, + bridge: createWindowsAuthStorageBridge({ + thtExecutable: "C:\\tht.exe", + spawnChild: (_executable, args, options) => spawn(launcher, [...args], options), + ...(mode === "stdin" ? { + beforeInputForTest: async () => { + await waitForMarker(marker, "stdin-closed"); + appendFileSync(marker, "before-input\n"); + }, + } : {}), + } as never), + }; +} + +afterEach(() => { + for (const directory of fixtureRoots.splice(0)) { + const marker = join(directory, "marker.txt"); + try { + const pid = Number(/^started:(\d+)$/m.exec(readFileSync(marker, "utf8"))?.[1]); + if (Number.isSafeInteger(pid) && pid > 0) process.kill(pid, "SIGKILL"); + } catch { /* the test-owned child already exited or did not start */ } + rmSync(directory, { recursive: true, force: true }); + } +}); class FakeBridgeChild extends EventEmitter { readonly stdin = new PassThrough(); @@ -110,6 +163,52 @@ describe("Windows auth-storage bridge", () => { ]); }); + test("parses the Go helper's required empty 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), + }), + }); + + await expect(bridge.list(root, "sessions")).resolves.toEqual([]); + }); + + test("allows only canonical OIDC claim removal and rejects claims elsewhere", async () => { + const claim = `${"b".repeat(64)}.claim`; + const invoke = vi.fn(async () => ({ + code: 0, + stdout: Buffer.from('{"version":1,"ok":true,"removed":true}\n'), + stderr: Buffer.alloc(0), + })); + const bridge = createWindowsAuthStorageBridge({ thtExecutable: "C:\\tht.exe", invoke }); + + await expect(bridge.remove(root, "oidc", claim)).resolves.toBe(true); + await expect(bridge.remove(root, "sessions", claim)).rejects.toThrow("auth_session_store_invalid"); + await expect(bridge.read(root, "oidc", claim)).rejects.toThrow("auth_session_store_invalid"); + await expect(bridge.create(root, "oidc", claim, Buffer.from("record"))).rejects.toThrow("auth_session_store_invalid"); + await expect(bridge.replace(root, "oidc", claim, Buffer.from("record"))).rejects.toThrow("auth_session_store_invalid"); + await expect(bridge.remove(root, "oidc", `../${claim}`)).rejects.toThrow("auth_session_store_invalid"); + await expect(bridge.remove(root, "oidc", `${claim}.bak`)).rejects.toThrow("auth_session_store_invalid"); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + test("rejects OIDC claim entries returned for a sessions list", async () => { + const claim = `${"c".repeat(64)}.claim`; + const bridge = createWindowsAuthStorageBridge({ + thtExecutable: "C:\\tht.exe", + invoke: async () => ({ + code: 0, + stdout: Buffer.from(`{"version":1,"ok":true,"entries":[{"name":"${claim}","modifiedUnixMs":1}]}\n`), + stderr: Buffer.alloc(0), + }), + }); + + await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid"); + }); + test("aborts a stdin-closed looping helper on timeout and waits for close", async () => { vi.useFakeTimers(); const child = new FakeBridgeChild(); @@ -189,4 +288,17 @@ describe("Windows auth-storage bridge", () => { await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid"); }); + + test.each(["timeout", "stdout", "stderr", "stdin"] as const)("kills a real %s helper process through the production spawn path", async (mode) => { + const { bridge, marker } = realChildBridge(mode); + const startedAt = Date.now(); + const pending = bridge.list(root, "sessions"); + const outcome = pending.then(() => undefined, (error: unknown) => error); + await waitForMarker(marker, "started"); + if (mode === "stdin") await waitForMarker(marker, "before-input"); + + await expect(outcome).resolves.toMatchObject({ message: "auth_session_store_invalid" }); + await waitForMarker(marker, "terminated"); + if (mode === "stdin") expect(Date.now() - startedAt).toBeLessThan(2_000); + }, 10_000); }); diff --git a/tools/tht/internal/authstorage/storage.go b/tools/tht/internal/authstorage/storage.go index 7dfd74f1..4766bf0c 100644 --- a/tools/tht/internal/authstorage/storage.go +++ b/tools/tht/internal/authstorage/storage.go @@ -41,15 +41,15 @@ type request struct { } type response struct { - Version int `json:"version"` - OK bool `json:"ok"` - Created bool `json:"created,omitempty"` - Replaced bool `json:"replaced,omitempty"` - Removed bool `json:"removed,omitempty"` - Found bool `json:"found,omitempty"` - Claimed bool `json:"claimed,omitempty"` - ContentBase64 string `json:"contentBase64,omitempty"` - Entries []safeio.PrivateDirectoryEntry `json:"entries,omitempty"` + Version int `json:"version"` + OK bool `json:"ok"` + Created bool `json:"created,omitempty"` + Replaced bool `json:"replaced,omitempty"` + Removed bool `json:"removed,omitempty"` + Found bool `json:"found,omitempty"` + Claimed bool `json:"claimed,omitempty"` + ContentBase64 string `json:"contentBase64,omitempty"` + Entries *[]safeio.PrivateDirectoryEntry `json:"entries,omitempty"` } // Run accepts exactly one strict JSON request on stdin and emits exactly one JSON response on @@ -96,7 +96,7 @@ func fail(stderr io.Writer) int { } func execute(input request) (response, error) { - if input.Version != protocolVersion || !validDirectory(input.Directory) { + if input.Version != protocolVersion || !validDirectory(input.Directory) || !validOperationShape(input) { return response{}, errInvalid } directory, err := storageDirectory(input.Root, input.Directory) @@ -106,7 +106,7 @@ func execute(input request) (response, error) { switch input.Operation { case "create": contents, err := decodeContents(input) - if err != nil || !digestFilename.MatchString(input.Filename) { + if err != nil { return response{}, errInvalid } created, err := createPrivate(directory, input.Filename, contents) @@ -115,9 +115,6 @@ func execute(input request) (response, error) { } return response{Version: protocolVersion, OK: true, Created: created}, nil case "read": - if !digestFilename.MatchString(input.Filename) { - return response{}, errInvalid - } contents, found, err := readPrivate(directory, input.Filename, recordMaximum(input.Directory)) if err != nil { return response{}, errInvalid @@ -125,7 +122,7 @@ func execute(input request) (response, error) { return contentResponse(found, contents), nil case "replace": contents, err := decodeContents(input) - if err != nil || !digestFilename.MatchString(input.Filename) { + if err != nil { return response{}, errInvalid } if err := safeio.ReplaceCanonicalRegular(filepath.Join(directory, input.Filename), contents, 0o600); err != nil { @@ -133,9 +130,6 @@ func execute(input request) (response, error) { } return response{Version: protocolVersion, OK: true, Replaced: true}, nil case "remove": - if !digestFilename.MatchString(input.Filename) && !claimFilename.MatchString(input.Filename) { - return response{}, errInvalid - } removed, err := removePrivate(directory, input.Filename) if err != nil { return response{}, errInvalid @@ -147,20 +141,14 @@ func execute(input request) (response, error) { return response{}, errInvalid } for _, entry := range entries { - if !digestFilename.MatchString(entry.Name) && !claimFilename.MatchString(entry.Name) { + if !digestFilename.MatchString(entry.Name) && !(input.Directory == "oidc" && claimFilename.MatchString(entry.Name)) { return response{}, errInvalid } } - return response{Version: protocolVersion, OK: true, Entries: entries}, nil + return response{Version: protocolVersion, OK: true, Entries: &entries}, nil case "claim-consume": - if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) { - return response{}, errInvalid - } return claimConsume(directory, input.Filename) case "read-claim": - if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) { - return response{}, errInvalid - } contents, found, err := safeio.ReadCanonicalPrivateClaim( filepath.Join(directory, input.Filename), filepath.Join(directory, asClaimFilename(input.Filename)), @@ -171,9 +159,6 @@ func execute(input request) (response, error) { } return contentResponse(found, contents), nil case "remove-claim": - if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) { - return response{}, errInvalid - } removed, err := safeio.RemoveCanonicalPrivateClaim( filepath.Join(directory, input.Filename), filepath.Join(directory, asClaimFilename(input.Filename)), @@ -187,6 +172,24 @@ func execute(input request) (response, error) { } } +func validOperationShape(input request) bool { + noContents := input.ContentBase64 == "" + switch input.Operation { + case "create", "replace": + return digestFilename.MatchString(input.Filename) + case "read": + return noContents && digestFilename.MatchString(input.Filename) + case "remove": + return noContents && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && claimFilename.MatchString(input.Filename))) + case "list": + return input.Filename == "" && noContents + case "claim-consume", "read-claim", "remove-claim": + return input.Directory == "oidc" && noContents && digestFilename.MatchString(input.Filename) + default: + return false + } +} + func contentResponse(found bool, contents []byte) response { if !found { return response{Version: protocolVersion, OK: true} diff --git a/tools/tht/internal/authstorage/storage_test.go b/tools/tht/internal/authstorage/storage_test.go index ea310344..3111a0d9 100644 --- a/tools/tht/internal/authstorage/storage_test.go +++ b/tools/tht/internal/authstorage/storage_test.go @@ -35,7 +35,7 @@ func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T) t.Fatal("replace did not report success") } listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"}) - if len(listed.Entries) != 1 || listed.Entries[0].Name != filename { + if listed.Entries == nil || len(*listed.Entries) != 1 || (*listed.Entries)[0].Name != filename { t.Fatalf("list = %#v, want exactly %q", listed.Entries, filename) } removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: filename}) @@ -61,6 +61,49 @@ func TestProtocolListSerializesLowerCamelBridgeDTO(t *testing.T) { } } +func TestProtocolListAlwaysSerializesAnEmptyEntriesArray(t *testing.T) { + root := filepath.Join(privateTestRoot(t), "auth") + input, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"}) + if err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 { + t.Fatalf("Run() code = %d stderr = %q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"entries":[]`) { + t.Fatalf("empty list bridge JSON = %q, want entries array", stdout.String()) + } +} + +func TestProtocolPermitsClaimNamesOnlyForOIDCRemove(t *testing.T) { + root := filepath.Join(privateTestRoot(t), "auth") + digest := "abababababababababababababababababababababababababababababababab.json" + claim := strings.TrimSuffix(digest, ".json") + ".claim" + for _, directory := range []string{"sessions", "oidc"} { + runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: directory}) + } + if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "sessions", claim), []byte("orphan"), 0o600); err != nil { + t.Fatal(err) + } + if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "oidc", claim), []byte("orphan"), 0o600); err != nil { + t.Fatal(err) + } + + runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: claim}) + runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", Filename: claim}) + runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "oidc", Filename: claim}) + runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) + runRejected(t, request{Version: 1, Operation: "replace", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) + runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: "../" + claim}) + runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: strings.TrimSuffix(claim, ".claim") + ".claim.bak"}) + + removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: claim}) + if !removed.Removed { + t.Fatal("OIDC orphan claim removal did not report success") + } +} + func TestRunRejectsProtocolOverflowAndTrailingJSONValues(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") valid, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})