fix(auth): make clear* hydration-safe, migrate raw-key blobs, doc keepalive/memo caveats

Address round-1 @claude review of #1592:

- OAuthStorageBase.clear* no longer mutate the pre-hydration (empty) store —
  a clear issued before hydration is deferred until it lands, so it merges
  onto the real persisted state instead of (a) being resurrected by the late
  rehydrate merge or (b) persisting a near-empty blob that clobbers every
  other server's on-disk credential. Already-hydrated clears stay synchronous.
- store.setServerState migrates a pre-normalization raw-key blob onto the
  canonical key on first partial write (was: shadowed it with a fresh
  canonical entry, orphaning the raw blob's other fields). clearServerState
  now also drops the raw-key orphan.
- Document the keepalive 64KB combined-body ceiling on the remote-storage
  write, the getRemoteOAuthStorage memo key intentionally omitting fetchFn,
  and the real ordering invariant behind the sync getScope.
- Tests for the clear-before-hydration and raw-key-migration paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
cliffhall 2026-07-01 19:20:49 -04:00
commit 8c10b8eb8e
2 changed files with 197 additions and 26 deletions

View file

@ -30,7 +30,18 @@ export function getWebOAuthBaseUrl(): string {
export const webOAuthFetch: typeof fetch = (...args) =>
globalThis.fetch(...args);
/** Memoized `RemoteOAuthStorage` for the given backend + auth token. */
/**
* Memoized `RemoteOAuthStorage` for the given backend + auth token.
*
* The cache key is intentionally `{baseUrl, authToken}` only — `fetchFn` is NOT
* part of it. Whichever call constructs an instance first wins, so a later
* caller's `fetchFn` is ignored. This is safe today because every call site
* either omits `fetchFn` (defaulting to {@link webOAuthFetch}) or passes an
* `environmentFactory` wrapper that is functionally identical (`globalThis.fetch`),
* so the shared-instance goal (one whole-blob writer per backend) matters more
* than which wrapper is used. A future caller needing a genuinely different
* transport must key on it (or bypass the cache).
*/
export function getRemoteOAuthStorage(
baseUrl: string,
authToken: string | undefined,

View file

@ -141,6 +141,48 @@ describe("OAuthStorageBase — async hydration", () => {
expect(await storage.getCodeVerifier(SERVER)).toBe("new");
});
it("clear() before hydration is not resurrected by the late merge", async () => {
// A clear issued while hydration is still gated must survive the pending
// rehydrate() that would otherwise merge the persisted (un-cleared) blob
// back on top and silently restore the credential.
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { tokens: { access_token: "t", token_type: "Bearer" } },
});
storage.clear(SERVER);
release();
await storage.ready();
// Give the deferred re-apply (scheduled off `hydrated`) a microtask to run.
await Promise.resolve();
expect(await storage.getTokens(SERVER)).toBeUndefined();
});
it("clearTokens before hydration clears only tokens and survives the merge", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: {
tokens: { access_token: "t", token_type: "Bearer" },
codeVerifier: "keep-me",
},
});
storage.clearTokens(SERVER);
release();
await storage.ready();
await Promise.resolve();
// Tokens cleared, but the sibling field from the persisted blob remains.
expect(await storage.getTokens(SERVER)).toBeUndefined();
expect(await storage.getCodeVerifier(SERVER)).toBe("keep-me");
});
it("clear() after hydration does not schedule a deferred re-apply", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { tokens: { access_token: "t", token_type: "Bearer" } },
});
release();
await storage.ready();
// Hydration already settled → synchronous clear, immediately observable.
storage.clear(SERVER);
expect(await storage.getTokens(SERVER)).toBeUndefined();
});
it("normalizeServerUrl canonicalizes host case, bare-origin trailing slash, default port, and whitespace", () => {
expect(normalizeServerUrl("https://Example.COM/mcp")).toBe(
"https://example.com/mcp",
@ -175,6 +217,47 @@ describe("OAuthStorageBase — async hydration", () => {
});
});
it("a partial write migrates a pre-normalization raw-key blob onto the canonical key (no orphaned fields)", async () => {
// Persisted under the bare-origin raw key (no trailing slash), the form a
// pre-normalization writer used; canonical form is `https://api.partner.dev/`.
// The caller consistently addresses the endpoint with that same raw string
// (matching getServerState's raw-key fallback).
const RAW = "https://api.partner.dev";
const { storage, release } = makeAsyncBackedStorage({
[RAW]: { tokens: { access_token: "existing", token_type: "Bearer" } },
});
release();
await storage.ready();
// A partial write must MERGE onto the existing blob, not shadow it with a
// fresh canonical entry — the pre-existing token must remain reachable.
await storage.saveCodeVerifier(RAW, "v");
expect(await storage.getCodeVerifier(RAW)).toBe("v");
expect(await storage.getTokens(RAW)).toEqual({
access_token: "existing",
token_type: "Bearer",
});
// The blob now lives under the canonical key, so the canonical form finds it
// too and the raw-key orphan no longer shadows anything.
expect(await storage.getTokens("https://api.partner.dev/")).toEqual({
access_token: "existing",
token_type: "Bearer",
});
});
it("clear() removes a pre-normalization raw-key blob, not just the canonical key", async () => {
const RAW = "https://api.partner.dev";
const { storage, release } = makeAsyncBackedStorage({
[RAW]: { tokens: { access_token: "existing", token_type: "Bearer" } },
});
release();
await storage.ready();
storage.clear(RAW);
// Both the canonical and raw keys must be gone — no orphan resurfacing via
// getServerState's raw-key fallback.
expect(await storage.getTokens(RAW)).toBeUndefined();
expect(await storage.getTokens("https://api.partner.dev/")).toBeUndefined();
});
it("getServerState falls back to a pre-normalization persisted key", async () => {
// A blob persisted before normalization existed may carry a raw key that
// the canonical form differs from (here: bare-origin without slash).