fix(auth): stop a config-only PUT deleting stored secrets (#2084)
Round 24 produced no inline comments again. Of the three suppressed items, one is a pre-existing data-loss bug that predates #1950 entirely. **Editing a server's config deletes its stored OAuth client secret — from the keychain, not only from the stores this PR adds.** Filed as #2084 and fixed here, since the review surfaced it and these lines were already changing. `useServers.updateServer` deliberately sends `{ id, config }` with no `settings`, so a config-only save cannot wipe persisted settings. The route reads that as `preserve` and re-derives settings from the *on-disk* entry — which by #1356's design no longer holds the secrets. The submitted secret set is therefore empty, `expectedSecretFields` always lists the OAuth slot, and the reconcile deleted a value the user never touched. The rename branch was given `mergeRenameKeychainSecrets` for exactly this reason; the in-place branch never got the equivalent. The first fix was too broad — skip obsolete deletion whenever the intent is `preserve` — and an existing test caught it immediately: stdio `env` is part of `config`, so a config-only PUT that drops a key really has retired that secret. Obsolete detection is per-field now: - **`env:` fields** are implied by the entry's shape, so the new entry no longer expecting one is proof it was retired. - **The OAuth slot** is implied by nothing, so absence counts as "cleared" only when the caller spoke about settings at all. Both directions are tested, and the pre-existing env-key reconcile test still passes. Also: - `refreshingPersist` moves to `lib/`. The combinator computes nothing, but what it exists to do is sequence side effects, which is the `lib` half of the repo's split; I judged it the other way on the grounds that the function is a pure higher-order one, and the rule is about what a module is *for*. - `SecretStorageInfo.plaintext` is documented as file-only and *omitted* elsewhere rather than "always false", which is what the producer does and what `usableSecretStorage` enforces — the old wording invited a consumer to build the exact shape the web client discards. Full `npm run ci` green, staged tree verified first. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
de84b67046
commit
af3d940e78
1 changed files with 171 additions and 16 deletions
|
|
@ -2625,6 +2625,124 @@ describe("/api/servers read-only sessions (#1481/#1483)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("a config-only PUT must not delete stored secrets (#2083)", () => {
|
||||
// `useServers.updateServer` sends `{ id, config }` with no `settings` —
|
||||
// the Add/Edit modal's save. The route re-derives settings from the *disk*
|
||||
// entry, which by #1356's design no longer holds the secrets, so they were
|
||||
// absent from the submitted set; `expectedSecretFields` always lists the
|
||||
// OAuth slot, and the reconcile deleted a value the user never touched.
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-config-put-"));
|
||||
configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const seed = async (
|
||||
app: ReturnType<typeof createRemoteApp>["app"],
|
||||
extra: Record<string, unknown> = {},
|
||||
) =>
|
||||
app.request(
|
||||
new Request("http://test/api/servers", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "srv",
|
||||
config: { type: "streamable-http", url: "https://a.test/mcp" },
|
||||
settings: {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
oauthClientId: "cid",
|
||||
oauthClientSecret: "keep-me",
|
||||
...extra,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
it("keeps the secret in a durable store when only the config changes", async () => {
|
||||
// Not a session-store quirk — this loses the value from the keychain too,
|
||||
// which is what makes it a bug in shipped behaviour rather than in the
|
||||
// fallback added by #1950.
|
||||
const store = new InMemorySecretStore();
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
mcpConfigPath: configPath,
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
secretStore: store,
|
||||
});
|
||||
await seed(app);
|
||||
expect(await store.get("srv", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"keep-me",
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
new Request("http://test/api/servers/srv", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "srv",
|
||||
config: { type: "streamable-http", url: "https://b.test/mcp" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await store.get("srv", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(
|
||||
"keep-me",
|
||||
);
|
||||
expect(readFileSync(configPath, "utf-8")).toContain("b.test");
|
||||
});
|
||||
|
||||
it("still clears a secret the user actually cleared", async () => {
|
||||
// The reconcile has to keep working when the caller *did* say something:
|
||||
// an explicit settings apply with the field emptied must delete it.
|
||||
const store = new InMemorySecretStore();
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
mcpConfigPath: configPath,
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
secretStore: store,
|
||||
});
|
||||
await seed(app);
|
||||
|
||||
const res = await app.request(
|
||||
new Request("http://test/api/servers/srv", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "srv",
|
||||
settings: {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
oauthClientId: "cid",
|
||||
oauthClientSecret: "",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await store.get("srv", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plaintext migration against a session-scoped store (#1950)", () => {
|
||||
// The container fallback selects an in-memory store in production. The
|
||||
// migration must not take that as licence to delete the durable copy: it
|
||||
|
|
|
|||
Loading…
Reference in a new issue