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:
parent
c299b6e9d8
commit
8c10b8eb8e
2 changed files with 197 additions and 26 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue