feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
Closes #1950 Before this, a box with no reachable OS keychain — the published container (no D-Bus session, #1848), Android/Termux (no platform binary, #1905), a minimal Linux install without libsecret — could not persist an OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore` degraded into a store that silently holds nothing: `get` returned null, `delete` no-opped, and `set` hard-failed with a 503. That is the correct failure behavior, but it left the documented, supported way to run the Inspector with no secret persistence whatsoever. Adds the two missing implementations and, more importantly, the policy that picks one and the surfaces that say which was picked. - `FileSecretStore` — one `0600` JSON document at `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The whole map is encrypted as a unit rather than value-by-value so the account names — `${serverId}:${field}` — are hidden too; a per-value scheme would leave a readable index of which servers you hold a client secret for. A file that can no longer be decrypted reads as empty (matching the interface's read tolerance) but *refuses to be written*, because replacing a file of still-valid secrets to satisfy an additive request destroys data. - `secret-store-selection.ts` — the policy. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright; otherwise probe the keychain and, if unreachable, fall back loudly. The fallback is `memory` in a container whose secrets directory is not on a mount, and `file` otherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a file `--rm` will discard. Cached per process so the banner, `/api/config`, and the store doing the writing cannot disagree. - Automatic, but never silent. The downgrade prints at startup, rides `GET /api/config`, and is named in a permanent 32px footer at the bottom of both dialogs that accept a secret. A startup banner is seen once by whoever started the process; a toast is seen once; a dismissible banner is the thing a user dismisses before doing the work it describes. The plaintext and memory cases render their caveat inline in a warning tone — a warning you must hover to discover is not one. - `SecretStoreUnavailableError` is now the base every store's `set` throws, so the routes translate one condition into a 503 instead of matching `KeychainUnavailableError` by name. Docs: the container section of the root README gains a table of which store you get and how to make secrets survive, plus the `MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules. Tests: the existing interface contract block is extracted to `secretStoreContract.ts` and run against all three implementations, so none can drift on the availability contract. `FileSecretStore` is tested against a real temp filesystem — mode, cross-instance round-trip, that the ciphertext does not contain the plaintext, the read-modify-write race, and every refusal path. Full `npm run ci` passes, per-file gate included. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
43525d9ea2
commit
bc408f5869
2 changed files with 2827 additions and 146 deletions
117
clients/web/src/test/core/auth/secret-storage-info.test.ts
Normal file
117
clients/web/src/test/core/auth/secret-storage-info.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* The secret-storage descriptor's presentation helpers (#1950).
|
||||
*
|
||||
* These are shared by the terminal banner and the browser footer, which is
|
||||
* the whole reason they live in `core/auth/` rather than beside either
|
||||
* consumer — so the cases below are about the *statements* they make,
|
||||
* particularly which states are loud and which are quiet. A helper that
|
||||
* quietly stopped warning about a plaintext file would leave both surfaces
|
||||
* silently wrong at once.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
secretStorageCaveat,
|
||||
secretStorageLabel,
|
||||
secretStorageSummary,
|
||||
secretStorageTone,
|
||||
type SecretStorageInfo,
|
||||
} from "@inspector/core/auth/secret-storage-info.js";
|
||||
|
||||
const keyring: SecretStorageInfo = {
|
||||
kind: "keyring",
|
||||
reason: "default",
|
||||
durable: true,
|
||||
};
|
||||
const encryptedFile: SecretStorageInfo = {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: false,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
};
|
||||
const plaintextFile: SecretStorageInfo = {
|
||||
...encryptedFile,
|
||||
plaintext: true,
|
||||
};
|
||||
const memory: SecretStorageInfo = {
|
||||
kind: "memory",
|
||||
reason: "fallback",
|
||||
durable: false,
|
||||
};
|
||||
|
||||
describe("secretStorageLabel", () => {
|
||||
it("names the keychain", () => {
|
||||
expect(secretStorageLabel(keyring)).toBe("OS keychain");
|
||||
});
|
||||
|
||||
it("distinguishes an encrypted file from an unencrypted one", () => {
|
||||
// The distinction is the point — "File" alone would answer "where"
|
||||
// while dropping the half a user needs to judge the risk.
|
||||
expect(secretStorageLabel(encryptedFile)).toBe("File (encrypted)");
|
||||
expect(secretStorageLabel(plaintextFile)).toBe("File (unencrypted)");
|
||||
});
|
||||
|
||||
it("says memory is session-scoped in the label itself", () => {
|
||||
expect(secretStorageLabel(memory)).toBe("Memory (this session only)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secretStorageTone", () => {
|
||||
it("stays neutral for the keychain and an encrypted file", () => {
|
||||
// A tone that fired for every store would carry no information.
|
||||
expect(secretStorageTone(keyring)).toBe("neutral");
|
||||
expect(secretStorageTone(encryptedFile)).toBe("neutral");
|
||||
});
|
||||
|
||||
it("warns for an unencrypted file and for memory", () => {
|
||||
expect(secretStorageTone(plaintextFile)).toBe("warn");
|
||||
expect(secretStorageTone(memory)).toBe("warn");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secretStorageCaveat", () => {
|
||||
it("has none for the quiet stores", () => {
|
||||
expect(secretStorageCaveat(keyring)).toBeUndefined();
|
||||
expect(secretStorageCaveat(encryptedFile)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the loss for memory", () => {
|
||||
expect(secretStorageCaveat(memory)).toContain(
|
||||
"lost when the Inspector exits",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the fix for an unencrypted file, not just the problem", () => {
|
||||
const caveat = secretStorageCaveat(plaintextFile);
|
||||
expect(caveat).toContain("unencrypted");
|
||||
expect(caveat).toContain("MCP_INSPECTOR_SECRET_KEY");
|
||||
});
|
||||
|
||||
it("prefers the memory caveat when a memory store somehow carries the flag", () => {
|
||||
// Defensive ordering: `plaintext` is meaningless for memory, and the
|
||||
// durability loss is the more consequential of the two statements.
|
||||
expect(secretStorageCaveat({ ...memory, plaintext: true })).toContain(
|
||||
"lost when the Inspector exits",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secretStorageSummary", () => {
|
||||
it("is just the label when there is nothing else to say", () => {
|
||||
expect(secretStorageSummary(keyring)).toBe("OS keychain");
|
||||
});
|
||||
|
||||
it("includes the path so a file-backed store is findable", () => {
|
||||
expect(secretStorageSummary(encryptedFile)).toContain(
|
||||
"/home/node/.mcp-inspector/secrets.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a fallback as a fallback", () => {
|
||||
// The banner has to distinguish "you configured this" from "we had to".
|
||||
expect(secretStorageSummary(memory)).toContain("fell back");
|
||||
expect(
|
||||
secretStorageSummary({ ...memory, reason: "configured" }),
|
||||
).not.toContain("fell back");
|
||||
});
|
||||
});
|
||||
|
|
@ -249,4 +249,55 @@ describe("useInitialConfig", () => {
|
|||
expect(result.current.version).toBeUndefined();
|
||||
expect(result.current.writable).toBe(true);
|
||||
});
|
||||
|
||||
describe("secretStorage (#1950)", () => {
|
||||
// The footer under a secret field reads this, so the hook's job is to hand
|
||||
// back only a descriptor it can vouch for. Every rejected shape must land
|
||||
// on `undefined` — which the footer renders as nothing — rather than on a
|
||||
// half-populated object that would render a confident wrong answer.
|
||||
it("passes through a well-formed descriptor", async () => {
|
||||
const info = {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
};
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ secretStorage: info }));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.secretStorage).toEqual(info);
|
||||
});
|
||||
|
||||
it("is undefined on a backend that omits the field", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
||||
const { result } = renderHook(() =>
|
||||
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.secretStorage).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a non-object", "keyring"],
|
||||
["null", null],
|
||||
["an unknown kind", { kind: "vault", durable: true }],
|
||||
["a missing kind", { durable: true, path: "/x" }],
|
||||
])("rejects %s", async (_label, value) => {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ secretStorage: value }));
|
||||
const { result } = renderHook(() =>
|
||||
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.secretStorage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue