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
3 changed files with 197 additions and 26 deletions
|
|
@ -96,6 +96,34 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
return this.hydrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a `clear*` mutation against a hydrated store: synchronously if
|
||||
* hydration has already landed, otherwise deferred until it does.
|
||||
*
|
||||
* `clear*` are declared synchronous in {@link OAuthStorage} (some feed sync
|
||||
* SDK getters), so unlike the `save*` family they cannot `await this.hydrated`
|
||||
* up front. But a clear applied to the still-empty pre-hydration store is
|
||||
* doubly wrong: (a) the pending `rehydrate()` would merge the persisted
|
||||
* (un-cleared) blob back over it, resurrecting the credential; and worse,
|
||||
* (b) applying it triggers a persist write of the near-empty store, which for
|
||||
* a whole-blob adapter (file / remote HTTP) can clobber the on-disk blob —
|
||||
* every other server's tokens included — before hydration ever reads it.
|
||||
* Deferring the mutation until after hydration avoids both: it merges onto
|
||||
* the real persisted state. The common case (hydration already settled) stays
|
||||
* synchronous and immediately observable. A failed hydration still resolves
|
||||
* {@link hydrated} (see constructor), so a deferred clear runs harmlessly
|
||||
* against the empty store. The effect of a pre-hydration clear is therefore
|
||||
* asynchronous, which is safe: the only callers are fire-and-forget
|
||||
* invalidations that never synchronously read the value straight back.
|
||||
*/
|
||||
private clearAfterHydration(mutate: () => void): void {
|
||||
if (this.store.persist.hasHydrated()) {
|
||||
mutate();
|
||||
} else {
|
||||
void this.hydrated.then(mutate);
|
||||
}
|
||||
}
|
||||
|
||||
async getClientInformation(
|
||||
serverUrl: string,
|
||||
isPreregistered?: boolean,
|
||||
|
|
@ -179,7 +207,9 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
}
|
||||
|
||||
clearTokens(serverUrl: string): void {
|
||||
this.store.getState().setServerState(serverUrl, { tokens: undefined });
|
||||
this.clearAfterHydration(() =>
|
||||
this.store.getState().setServerState(serverUrl, { tokens: undefined }),
|
||||
);
|
||||
}
|
||||
|
||||
async getCodeVerifier(serverUrl: string): Promise<string | undefined> {
|
||||
|
|
@ -197,18 +227,28 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
}
|
||||
|
||||
clearCodeVerifier(serverUrl: string): void {
|
||||
this.store
|
||||
.getState()
|
||||
.setServerState(serverUrl, { codeVerifier: undefined });
|
||||
this.clearAfterHydration(() =>
|
||||
this.store
|
||||
.getState()
|
||||
.setServerState(serverUrl, { codeVerifier: undefined }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intentionally synchronous. The only caller is
|
||||
* {@link BaseOAuthClientProvider.scope}, a sync getter the SDK requires
|
||||
* (it feeds the sync `clientMetadata` getter). Scope is always set via
|
||||
* {@link saveScope} in the same session before it's read (during the
|
||||
* pre-redirect half of the flow), so the in-memory store has the value
|
||||
* regardless of async hydration.
|
||||
* (it feeds the sync `clientMetadata` getter). It is safe without awaiting
|
||||
* hydration because of an ordering invariant the callers uphold: some
|
||||
* awaited read always precedes it, flushing hydration first.
|
||||
* - Pre-redirect half of the flow: `saveScope` (which awaits hydration)
|
||||
* writes the value in the same session, so the in-memory store has it.
|
||||
* - Post-redirect callback path: the in-memory store is reset, so the
|
||||
* value comes from hydration — but `buildOAuthConnectionState`
|
||||
* (`connection-state.ts`) awaits `getClientInformation`/
|
||||
* `getServerMetadata` (both of which await hydration) before it reads
|
||||
* `getScope`, so hydration has already landed by then.
|
||||
* A future refactor that reads `getScope` without a preceding awaited
|
||||
* storage read would break this invariant.
|
||||
*/
|
||||
getScope(serverUrl: string): string | undefined {
|
||||
const state = this.store.getState().getServerState(serverUrl);
|
||||
|
|
@ -221,7 +261,9 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
}
|
||||
|
||||
clearScope(serverUrl: string): void {
|
||||
this.store.getState().setServerState(serverUrl, { scope: undefined });
|
||||
this.clearAfterHydration(() =>
|
||||
this.store.getState().setServerState(serverUrl, { scope: undefined }),
|
||||
);
|
||||
}
|
||||
|
||||
async getServerMetadata(serverUrl: string): Promise<OAuthMetadata | null> {
|
||||
|
|
@ -241,13 +283,17 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
}
|
||||
|
||||
clearServerMetadata(serverUrl: string): void {
|
||||
this.store
|
||||
.getState()
|
||||
.setServerState(serverUrl, { serverMetadata: undefined });
|
||||
this.clearAfterHydration(() =>
|
||||
this.store
|
||||
.getState()
|
||||
.setServerState(serverUrl, { serverMetadata: undefined }),
|
||||
);
|
||||
}
|
||||
|
||||
clear(serverUrl: string): void {
|
||||
this.store.getState().clearServerState(serverUrl);
|
||||
this.clearAfterHydration(() =>
|
||||
this.store.getState().clearServerState(serverUrl),
|
||||
);
|
||||
}
|
||||
|
||||
async getIdpSession(issuer: string): Promise<IdpSessionState | undefined> {
|
||||
|
|
@ -272,10 +318,14 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
}
|
||||
|
||||
clearIdpSession(issuer: string): void {
|
||||
this.store.getState().clearIdpSession(issuer);
|
||||
this.clearAfterHydration(() =>
|
||||
this.store.getState().clearIdpSession(issuer),
|
||||
);
|
||||
}
|
||||
|
||||
clearEnterpriseManagedResourceServers(): void {
|
||||
this.store.getState().clearEnterpriseManagedResourceServers();
|
||||
this.clearAfterHydration(() =>
|
||||
this.store.getState().clearEnterpriseManagedResourceServers(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import type {
|
|||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import type { IdpSessionState, OAuthClientRegistrationKind } from "./storage.js";
|
||||
import type {
|
||||
IdpSessionState,
|
||||
OAuthClientRegistrationKind,
|
||||
} from "./storage.js";
|
||||
|
||||
/**
|
||||
* OAuth state for a single server
|
||||
|
|
@ -92,21 +95,36 @@ export function createOAuthStore(
|
|||
updates: Partial<ServerOAuthState>,
|
||||
) => {
|
||||
const key = normalizeServerUrl(serverUrl);
|
||||
set((state) => ({
|
||||
servers: {
|
||||
...state.servers,
|
||||
[key]: {
|
||||
...state.servers[key],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
}));
|
||||
set((state) => {
|
||||
const servers = { ...state.servers };
|
||||
// Mirror getServerState's canonical→raw fallback for writes: if no
|
||||
// canonical entry exists yet but a pre-normalization blob lives
|
||||
// under the raw key, migrate it onto the canonical key so this
|
||||
// partial write MERGES onto the existing credential instead of
|
||||
// shadowing it with a fresh canonical entry (which would orphan the
|
||||
// raw-key blob's other fields — e.g. a token — behind the new
|
||||
// canonical one that getServerState now prefers).
|
||||
let base = servers[key];
|
||||
if (base === undefined && servers[serverUrl] !== undefined) {
|
||||
// Reaching here implies `serverUrl !== key`: if they were equal,
|
||||
// `servers[serverUrl]` would equal the `servers[key]` we just
|
||||
// found undefined, so the guard above could not have passed.
|
||||
base = servers[serverUrl];
|
||||
delete servers[serverUrl];
|
||||
}
|
||||
servers[key] = { ...base, ...updates };
|
||||
return { servers };
|
||||
});
|
||||
},
|
||||
clearServerState: (serverUrl: string) => {
|
||||
const key = normalizeServerUrl(serverUrl);
|
||||
set((state) => {
|
||||
const rest = { ...state.servers };
|
||||
delete rest[key];
|
||||
// Also drop a pre-normalization blob under the raw key so a clear
|
||||
// fully removes the credential rather than leaving an orphan that
|
||||
// getServerState's raw-key fallback would keep surfacing.
|
||||
if (serverUrl !== key) delete rest[serverUrl];
|
||||
return { servers: rest };
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ export function createRemoteStorageAdapter(
|
|||
// full-page navigation (the OAuth authorize redirect): without it the
|
||||
// browser may abort the request mid-flight and the just-saved
|
||||
// codeVerifier/clientInformation never reaches disk.
|
||||
//
|
||||
// Caveat: the fetch spec caps the *combined* body size of all in-flight
|
||||
// keepalive requests at 64 KB. `value` here is the whole store blob
|
||||
// (every server's tokens + metadata), so a user with a very large
|
||||
// number of authorized servers could in theory exceed that and have the
|
||||
// browser reject this write. That ceiling is well above realistic
|
||||
// inspector usage; if it ever bites, the fix is per-server stores rather
|
||||
// than dropping `keepalive` (which would reintroduce the redirect-abort
|
||||
// bug this guards against). The catch below surfaces such a rejection.
|
||||
const res = await fetchFn(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
|
|
|||
Loading…
Reference in a new issue