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
3 changed files with 2827 additions and 146 deletions
|
|
@ -0,0 +1,105 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
import { SecretStorageFooter } from "./SecretStorageFooter";
|
||||
|
||||
const keyring: SecretStorageInfo = {
|
||||
kind: "keyring",
|
||||
reason: "default",
|
||||
durable: true,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SecretStorageFooter> = {
|
||||
title: "Elements/SecretStorageFooter",
|
||||
component: SecretStorageFooter,
|
||||
args: { info: keyring },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SecretStorageFooter>;
|
||||
|
||||
// The ordinary case on a desktop install: the OS keychain, stated quietly.
|
||||
export const Keychain: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByText("Secrets: OS keychain"),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"neutral",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// A container with a mounted volume and `MCP_INSPECTOR_SECRET_KEY` set: durable,
|
||||
// encrypted, and therefore still quiet — the path is the useful part.
|
||||
export const EncryptedFile: Story = {
|
||||
args: {
|
||||
info: {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: false,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByText("Secrets: File (encrypted)"),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText("/home/node/.mcp-inspector/secrets.json"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// The same container without a passphrase. The caveat is rendered inline and in
|
||||
// the warning tone — this is what "plaintext gets a loud banner" looks like at
|
||||
// the point where the secret is actually typed.
|
||||
export const UnencryptedFile: Story = {
|
||||
args: {
|
||||
info: {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
plaintext: true,
|
||||
path: "/home/node/.mcp-inspector/secrets.json",
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByText("Secrets: File (unencrypted)"),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"warn",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// The published container with nothing mounted: session-scoped, and it says so
|
||||
// rather than promising a durability the writable layer can't keep.
|
||||
export const InMemory: Story = {
|
||||
args: { info: { kind: "memory", reason: "fallback", durable: false } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByText("Secrets: Memory (this session only)"),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText(/lost when the Inspector exits/),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// A backend that reports no store (legacy build): the band is absent entirely.
|
||||
export const Unknown: Story = {
|
||||
args: { info: undefined },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByTestId("secret-storage-footer")).toBeNull();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { SecretStorageFooter } from "./SecretStorageFooter";
|
||||
|
||||
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("SecretStorageFooter", () => {
|
||||
it("names the keychain with no caveat and no warning tone", () => {
|
||||
renderWithMantine(<SecretStorageFooter info={keyring} />);
|
||||
expect(screen.getByText("Secrets: OS keychain")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"neutral",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the path for an encrypted file, quietly", () => {
|
||||
// Nothing is wrong here, so the band must not shout — the path is the
|
||||
// useful part and the tone stays neutral.
|
||||
renderWithMantine(<SecretStorageFooter info={encryptedFile} />);
|
||||
expect(screen.getByText("Secrets: File (encrypted)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("/home/node/.mcp-inspector/secrets.json"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"neutral",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the plaintext caveat inline, not only on hover", () => {
|
||||
// This is the "loud banner" decision made concrete: a warning you have
|
||||
// to hover to discover is not a warning. The caveat takes the visible
|
||||
// slot, displacing the path (which stays in the tooltip).
|
||||
renderWithMantine(<SecretStorageFooter info={plaintextFile} />);
|
||||
expect(screen.getByText("Secrets: File (unencrypted)")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Set MCP_INSPECTOR_SECRET_KEY to encrypt them/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns that an in-memory store loses secrets on exit", () => {
|
||||
renderWithMantine(<SecretStorageFooter info={memory} />);
|
||||
expect(
|
||||
screen.getByText("Secrets: Memory (this session only)"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/lost when the Inspector exits/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing when the backend didn't report a store", () => {
|
||||
// A guessed answer under a secret field is worse than no answer, so the
|
||||
// unknown case is silence rather than a default.
|
||||
renderWithMantine(<SecretStorageFooter info={undefined} />);
|
||||
expect(screen.queryByTestId("secret-storage-footer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { Group, Text, Tooltip } from "@mantine/core";
|
||||
import { TbAlertTriangle, TbDeviceDesktop, TbLock } from "react-icons/tb";
|
||||
import {
|
||||
secretStorageCaveat,
|
||||
secretStorageLabel,
|
||||
secretStorageTone,
|
||||
type SecretStorageInfo,
|
||||
} from "@inspector/core/auth/secret-storage-info.js";
|
||||
|
||||
/**
|
||||
* Height of the footer band. Matches `InspectorView`'s `FOOTER_HEIGHT`
|
||||
* deliberately: these are the app's only permanent status strips, and a
|
||||
* modal footer standing a few pixels taller than the one behind it would
|
||||
* read as a different kind of thing.
|
||||
*/
|
||||
export const SECRET_STORAGE_FOOTER_HEIGHT = 32;
|
||||
|
||||
export interface SecretStorageFooterProps {
|
||||
/**
|
||||
* The active store, from `GET /api/config`. Renders nothing when
|
||||
* undefined — a backend that didn't report one leaves the question
|
||||
* genuinely open, and an invented answer directly beneath a secret field
|
||||
* is worse than silence.
|
||||
*/
|
||||
info?: SecretStorageInfo;
|
||||
}
|
||||
|
||||
// A thin band, same height as the app footer. Mantine's compound Modal has no
|
||||
// `Modal.Footer`, so it renders as the last child of `Modal.Content`, after the
|
||||
// `Modal.Body`, and its theme variant pins it with `position: sticky` — the
|
||||
// mirror of Mantine's own sticky header, because `Modal.Content` scrolls
|
||||
// header, body and footer as one. `nowrap` keeps it to one line at any modal
|
||||
// width; the truncating detail text on the right absorbs a narrow modal.
|
||||
const FooterBand = Group.withProps({
|
||||
variant: "secretStorageFooter",
|
||||
h: SECRET_STORAGE_FOOTER_HEIGHT,
|
||||
px: "md",
|
||||
gap: 6,
|
||||
align: "center",
|
||||
wrap: "nowrap",
|
||||
w: "100%",
|
||||
});
|
||||
|
||||
const FooterLabel = Text.withProps({
|
||||
size: "xs",
|
||||
fw: 500,
|
||||
variant: "nowrap",
|
||||
});
|
||||
|
||||
// The detail — a path, or the caveat sentence — is the part allowed to lose
|
||||
// characters, since the label before it carries the answer.
|
||||
//
|
||||
// `lineClamp` rather than `truncate`, and the difference is load-bearing.
|
||||
// `Modal.Content` sizes its scroll container to its children's **max-content**
|
||||
// width, and `truncate` sets `white-space: nowrap`, whose max-content is the
|
||||
// entire sentence — so the band grew the container to 770px inside a 620px
|
||||
// modal and the *form above it* got clipped at the right edge (measured).
|
||||
// Clamped text still wraps internally, so its max-content is merely its
|
||||
// longest word, and the container stays the modal's width; the clamp then
|
||||
// keeps it to the one line this 32px band has room for. `miw: 0` + `flex: 1`
|
||||
// let it shrink to the leftover space rather than to its content.
|
||||
const FooterDetail = Text.withProps({
|
||||
size: "xs",
|
||||
lineClamp: 1,
|
||||
miw: 0,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
|
||||
/**
|
||||
* The permanent "here is where your secrets go" strip at the bottom of the
|
||||
* Client Settings and Server Settings modals (#1950).
|
||||
*
|
||||
* Those two dialogs are the only places the Inspector accepts a secret —
|
||||
* an OAuth client secret, an enterprise IdP client secret, a stdio `env:`
|
||||
* value — so they are where the answer belongs. The alternatives were
|
||||
* considered and rejected: a startup banner is seen once, by whoever
|
||||
* started the process, possibly hours before anyone types anything; a
|
||||
* toast is seen once and then gone; a dismissible banner is, by design,
|
||||
* the thing a user makes disappear before doing the work it describes.
|
||||
* This is always present, is not dismissible, and has no empty state
|
||||
* beyond "the backend didn't say".
|
||||
*
|
||||
* When the store is lossy or unencrypted the caveat is rendered *inline*
|
||||
* rather than left to the tooltip. That is the whole point of the
|
||||
* "plaintext gets a loud warning" decision: a warning you have to hover to
|
||||
* discover is not one. The tooltip carries the full sentence for the case
|
||||
* where a narrow modal truncates it, and — for the quiet stores — the file
|
||||
* path.
|
||||
*
|
||||
* Deliberately read-only for now. Choosing the store from here is the
|
||||
* obvious follow-up, but the gap this closes is that users could not find
|
||||
* out *at all*, and closing that does not require a control.
|
||||
*/
|
||||
export function SecretStorageFooter({ info }: SecretStorageFooterProps) {
|
||||
if (!info) return null;
|
||||
|
||||
const caveat = secretStorageCaveat(info);
|
||||
const warn = secretStorageTone(info) === "warn";
|
||||
const color = warn
|
||||
? "var(--inspector-warning-text)"
|
||||
: "var(--inspector-text-secondary)";
|
||||
// The caveat wins the visible slot when there is one; otherwise the path,
|
||||
// which only the (quiet) encrypted-file case has.
|
||||
const detail = caveat ?? info.path;
|
||||
const Icon = warn
|
||||
? TbAlertTriangle
|
||||
: info.kind === "keyring"
|
||||
? TbDeviceDesktop
|
||||
: TbLock;
|
||||
|
||||
const band = (
|
||||
<FooterBand
|
||||
data-testid="secret-storage-footer"
|
||||
data-tone={warn ? "warn" : "neutral"}
|
||||
>
|
||||
{/* A react-icons glyph, not a Mantine factory component — outside the
|
||||
`.withProps()` rule, and it takes `color` as a prop rather than a
|
||||
style. */}
|
||||
<Icon size={ICON_SIZE} color={color} aria-hidden />
|
||||
<FooterLabel c={color}>Secrets: {secretStorageLabel(info)}</FooterLabel>
|
||||
{detail && <FooterDetail c={color}>{detail}</FooterDetail>}
|
||||
</FooterBand>
|
||||
);
|
||||
|
||||
// Tooltip content: the caveat plus the path when both exist (the unencrypted
|
||||
// file case), since the visible slot can only show one of them.
|
||||
const tip = [caveat, info.path].filter(Boolean).join(" ");
|
||||
if (!tip) return band;
|
||||
|
||||
return (
|
||||
// `multiline` + a width so the sentence wraps instead of forming a tooltip
|
||||
// wider than the modal it explains.
|
||||
<Tooltip label={tip} multiline w={320} position="top" withArrow>
|
||||
{band}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue