inspector/core/auth/node/secret-store.ts
Cliff Hall bb219dc001
fix: construct AsyncEntry inside the try so keychain degradation engages (#1848) (#1945)
* fix: construct AsyncEntry inside the try so keychain degradation engages

`KeyringSecretStore` built its `AsyncEntry` outside the `try` in `get`,
`set`, and `delete`. `AsyncEntry::new` performs the platform-store setup
(on Linux, the Secret Service connect with a keyutils fallback) and
throws when no backend is reachable, so the documented degradation
contract never engaged for a construction-time failure: the raw keyring
error escaped, 500ing every `GET /api/servers` on a box without a Secret
Service (the published container, which has no D-Bus session).

`expectedSecretFields` always includes the OAuth slot, so `rehydrateConfig`
constructs an entry for every server — the default seeded catalog was
enough to 500 the first list load, before any server was added. And
because the escaping error wasn't a `KeychainUnavailableError`, it
bypassed both the routes' 503 translation and the `migratePlaintextSecrets`
skip branch, yielding a generic 500 instead of the actionable message.

Moving construction inside the existing `try` restores the contract for
that failure mode: `get` returns null, `delete` no-ops, and `set` is the
only operation that throws — as `KeychainUnavailableError`, which is what
the 503 translation and the migration skip both match on.

The test stub's constructor could not fail, which is why the coverage
gate never saw the gap. Adds a `constructorThrows` hook alongside the
existing method-level `failures` flags and covers all three methods plus
`deleteAllForServer` under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F

* test: pin why the OAuth secret field is unconditional

`expectedSecretFields` always includes the OAuth slot, and #1848's report
frames that as why the 500 fired so broadly — which invites a follow-up
that skips the keychain read for servers with "no OAuth config". That
change would lose data.

`extractSecretsFromStored` deletes the `oauth` block outright when
`clientSecret` was its only property, so such a server carries NO marker
on disk that a secret exists — the keychain is the sole record, and the
unconditional slot is what finds it again. Gating the read on a
disk-visible `oauth` block would silently stop rehydrating exactly that
shape.

The existing tests do fail if the slot is made conditional (verified by
applying the change: 4 red), but only under names that explain nothing
about the consequence — `always lists the OAuth slot first` reads like a
tautology worth updating rather than a trap. Add a round-trip case that
states the consequence, so the next reader sees the field is load-bearing
rather than defensive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:30:16 -07:00

186 lines
7.5 KiB
TypeScript

/**
* Per-server secret storage backed by the OS keychain.
*
* Service name `mcp-inspector`; account `${serverId}:${field}`. Fields used
* by the current code: `oauth-client-secret` and `env:${KEY}` (one per
* stdio env variable). Keeping the account namespaced by `serverId` lets
* us drop every entry for a server in one sweep when DELETE
* /api/servers/:id runs, and lets `findCredentials(SERVICE)` enumerate
* everything we own for migration / debugging.
*
* Node-only — `@napi-rs/keyring` uses native bindings (Keychain Services
* on macOS, Credential Manager on Windows, libsecret on Linux). The
* browser side never imports this; it gets values rehydrated into the
* `/api/servers` response by the Hono handler.
*/
import { AsyncEntry, findCredentialsAsync } from "@napi-rs/keyring";
const SERVICE_NAME = "mcp-inspector";
export {
SECRET_FIELD_OAUTH_CLIENT_SECRET,
SECRET_FIELD_IDP_CLIENT_SECRET,
envSecretField,
} from "../secret-fields.js";
/** Parse a stored account key back into its server id and field. */
export function parseAccount(
account: string,
): { serverId: string; field: string } | null {
const idx = account.indexOf(":");
if (idx <= 0 || idx === account.length - 1) return null;
return {
serverId: account.slice(0, idx),
field: account.slice(idx + 1),
};
}
const buildAccount = (serverId: string, field: string): string =>
`${serverId}:${field}`;
/**
* Thrown when the OS keychain is unavailable — typically Linux without
* libsecret / gnome-keyring installed. Surfaced as a 503 by the API
* handlers so the UI can show an actionable error rather than a generic
* 500. macOS and Windows always have a working keychain, so this only
* realistically fires on minimal Linux installs.
*/
export class KeychainUnavailableError extends Error {
constructor(cause: unknown) {
super(
`OS keychain is not available. On Linux, install libsecret / gnome-keyring. ` +
`Underlying error: ${cause instanceof Error ? cause.message : String(cause)}`,
);
this.name = "KeychainUnavailableError";
}
}
/**
* Storage interface for the per-server secrets we lift off
* `~/.mcp-inspector/mcp.json`. Implemented by `KeyringSecretStore` (the
* production impl) and `InMemorySecretStore` (used in tests so the suite
* doesn't require libsecret in CI).
*/
export interface SecretStore {
get(serverId: string, field: string): Promise<string | null>;
set(serverId: string, field: string, value: string): Promise<void>;
/** No-op if no entry exists. */
delete(serverId: string, field: string): Promise<void>;
/** Remove every secret stored for this server id (called on DELETE /api/servers/:id). */
deleteAllForServer(serverId: string): Promise<void>;
}
/**
* Default implementation. Each operation constructs a fresh `AsyncEntry`;
* the native side is cheap and the alternative (caching entries by
* (serverId, field)) just trades native-handle bookkeeping for an
* allocation that's measured in microseconds. `getPassword` returns
* `undefined` for a missing entry — we normalize to `null` so callers
* can use `=== null` rather than truthiness (an empty-string secret is
* a real value and must round-trip).
*
* **Availability behavior.** When the keychain is unavailable (the
* typical case is Linux without libsecret / gnome-keyring, or a
* container with no D-Bus session), `set` is the only operation that
* throws `KeychainUnavailableError` — that's the moment where data
* would actually be lost. `get` returns `null` (as if no entry
* existed) and the destructive operations silently no-op (there's
* nothing to delete anyway). This keeps non-secret flows working on a
* stock CI runner / minimal Linux box; the user only hits a hard error
* when they actually try to save a secret.
*
* The `AsyncEntry` construction is deliberately **inside** each
* method's `try`: `AsyncEntry::new` performs the platform-store setup
* (on Linux, the Secret Service connect with a keyutils fallback) and
* throws when no backend is reachable. Constructing it outside the
* `try` let that raw error escape, 500ing every `GET /api/servers`
* before any secret was involved — the contract above only holds if
* construction failures are funneled through the same handlers (#1848).
*/
export class KeyringSecretStore implements SecretStore {
async get(serverId: string, field: string): Promise<string | null> {
try {
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
const v = await entry.getPassword();
return v ?? null;
} catch {
// Tolerate keychain unavailability on reads: there's no value to
// surface either way. Hard-failing here would break GET flows
// that don't touch any secret material (most of the test suite,
// and most user sessions on a Linux box without libsecret).
return null;
}
}
async set(serverId: string, field: string, value: string): Promise<void> {
try {
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
await entry.setPassword(value);
} catch (err) {
// The only operation that hard-fails — if we can't persist the
// secret, the user needs to know now rather than discover later
// that their value disappeared. Routes translate this to a 503.
throw new KeychainUnavailableError(err);
}
}
async delete(serverId: string, field: string): Promise<void> {
try {
const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field));
await entry.deleteCredential();
} catch {
// Every reason for a throw collapses to the same desired outcome
// ("the entry isn't there anymore"): `deleteCredential` raises
// NoEntry for a missing credential, and both the constructor and
// the native binding raise a runtime error when the keychain
// itself is unavailable. We treat all of them as success — there's
// no value to lose either way, and `set` is the operation that
// hard-fails when the keychain is actually down.
}
}
async deleteAllForServer(serverId: string): Promise<void> {
let creds: Array<{ account: string; password: string }>;
try {
creds = await findCredentialsAsync(SERVICE_NAME);
} catch {
// Same reasoning as `delete`: nothing was written, nothing to sweep.
return;
}
const prefix = `${serverId}:`;
for (const c of creds) {
if (!c.account.startsWith(prefix)) continue;
const parsed = parseAccount(c.account);
if (!parsed || parsed.serverId !== serverId) continue;
await this.delete(serverId, parsed.field);
}
}
}
/**
* Test double — substituted via the `secretStore` option on the remote
* server factory. Mirrors the keyring contract exactly so swapping it
* in/out doesn't change behavior beyond persistence.
*/
export class InMemorySecretStore implements SecretStore {
private readonly map = new Map<string, string>();
async get(serverId: string, field: string): Promise<string | null> {
return this.map.get(buildAccount(serverId, field)) ?? null;
}
async set(serverId: string, field: string, value: string): Promise<void> {
this.map.set(buildAccount(serverId, field), value);
}
async delete(serverId: string, field: string): Promise<void> {
this.map.delete(buildAccount(serverId, field));
}
async deleteAllForServer(serverId: string): Promise<void> {
const prefix = `${serverId}:`;
for (const key of [...this.map.keys()]) {
if (key.startsWith(prefix)) this.map.delete(key);
}
}
}