fix(auth): make snapshot-and-clear one atomic storage step (#2144)
Copilot review round 18. Three of its findings were the same check-then-act: `planOAuthRevocation` is asynchronous, so the gap between its reads and the following `storage.clear()` is still a window — shorter than the network one closed in round 13, but the same shape. An OAuth completion landing at any of those awaits saved a grant the clear then destroyed. `OAuthStorage.takeRevocationSnapshot(serverUrl)` reads what revocation needs and clears the server in ONE synchronous pass over the in-memory state, with the persist after the mutation. `clearAndPlanRevocation` replaces `planOAuthRevocation`, and there is no separate `clear` call left in any caller — the clear cannot be forgotten or reordered. The snapshot returns values unparsed, deliberately: schema validation is pure and belongs after the mutation, since running it inside would reintroduce the `await` this exists to remove. Two consequences worth naming. A failure of the take-and-clear now REJECTS rather than returning an outcome, because the state was not cleared and the caller's contract is that it was; both clients already have a rejection path. And cross-process atomicity is explicitly not claimed — nothing in this store takes a lock, every mutation is a read-modify-write over a loaded snapshot, and `clear` alone always had that property. Also from round 18: - The CLI's budget check ran before considering `plan.outcome`, so a key that needed no network was reported as budget-exhausted — warning that a grant may be live when that key held none. - A disconnect failure after a successful clear was reported as "could not clear OAuth state" in both clients. It now goes to the disconnect channel, and the success is still reported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0ded02b34c
commit
463227c947
1 changed files with 586 additions and 347 deletions
|
|
@ -8,7 +8,7 @@ import {
|
|||
OAuthClientInformationSchema,
|
||||
OAuthTokensSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
import type { OAuthStorage } from "./storage.js";
|
||||
import type { OAuthStorage, RevocationSnapshot } from "./storage.js";
|
||||
import {
|
||||
type OAuthMemoryStore,
|
||||
type ServerOAuthState,
|
||||
|
|
@ -424,6 +424,29 @@ export class OAuthStorageBase implements OAuthStorage {
|
|||
await this.persist();
|
||||
}
|
||||
|
||||
async takeRevocationSnapshot(serverUrl: string): Promise<RevocationSnapshot> {
|
||||
await this.ensureLoaded();
|
||||
// Everything below is synchronous on purpose: read and clear against one
|
||||
// view of the in-memory state, so nothing can land between them. The
|
||||
// persist is the only await, and it happens after the mutation.
|
||||
const state = this.memory.getState().getServerState(serverUrl);
|
||||
const snapshot: RevocationSnapshot = {
|
||||
byIssuer: Object.fromEntries(
|
||||
Object.entries(state.byIssuer ?? {}).map(([issuer, slot]) => [
|
||||
issuer,
|
||||
{ tokens: slot?.tokens, clientInformation: slot?.clientInformation },
|
||||
]),
|
||||
),
|
||||
legacyTokens: state.tokens,
|
||||
legacyClientInformation: state.clientInformation,
|
||||
preregisteredClientInformation: state.preregisteredClientInformation,
|
||||
serverMetadata: state.serverMetadata,
|
||||
};
|
||||
this.memory.getState().clearServerState(serverUrl);
|
||||
await this.persist();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async getIssuerTokens(
|
||||
serverUrl: string,
|
||||
issuer: string,
|
||||
|
|
|
|||
Loading…
Reference in a new issue