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
|
|
@ -32,6 +32,26 @@ export interface SaveClientInformationOptions {
|
|||
issuer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's revocation-relevant state, taken and deleted in one step by
|
||||
* {@link OAuthStorage.takeRevocationSnapshot}. Values are as stored — unparsed
|
||||
* — so validation can happen after the mutation rather than inside it.
|
||||
*/
|
||||
export interface RevocationSnapshot {
|
||||
/** Credentials per authorization-server issuer (SEP-2352). */
|
||||
byIssuer: Record<
|
||||
string,
|
||||
{ tokens?: unknown; clientInformation?: unknown } | undefined
|
||||
>;
|
||||
/** The legacy unkeyed slot, for entries predating issuer binding. */
|
||||
legacyTokens?: unknown;
|
||||
legacyClientInformation?: unknown;
|
||||
/** The static/preconfigured registration, if any. */
|
||||
preregisteredClientInformation?: unknown;
|
||||
/** Cached authorization-server metadata, if discovery ever ran. */
|
||||
serverMetadata?: unknown;
|
||||
}
|
||||
|
||||
export interface OAuthStorage {
|
||||
/**
|
||||
* Optional preload of persisted state into memory. Getters and setters load
|
||||
|
|
@ -182,6 +202,27 @@ export interface OAuthStorage {
|
|||
*/
|
||||
clearDiscoveryState(serverUrl: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Atomically read everything RFC 7009 revocation needs, and clear the
|
||||
* server's state in the same step (#2144).
|
||||
*
|
||||
* Split reads followed by a separate `clear` are a check-then-act: each
|
||||
* `await` yields, and an OAuth completion landing in one of those gaps saves
|
||||
* a fresh grant that the clear then deletes. Doing both against one
|
||||
* synchronous view of the in-memory state closes that window, and the network
|
||||
* requests are sent afterwards from the returned snapshot.
|
||||
*
|
||||
* The values are returned **unparsed**, exactly as stored. Schema validation
|
||||
* is pure and belongs after the mutation — running it in between would
|
||||
* reintroduce the `await` this exists to remove.
|
||||
*
|
||||
* ⚠️ In-process only. Nothing in this store takes a cross-process lock —
|
||||
* every mutation here is a read-modify-write over a loaded snapshot — so a
|
||||
* second Inspector writing concurrently can still lose an update. That is a
|
||||
* property of the store, not of this method; `clear` alone has always had it.
|
||||
*/
|
||||
takeRevocationSnapshot(serverUrl: string): Promise<RevocationSnapshot>;
|
||||
|
||||
/**
|
||||
* Tokens bound to **exactly** `issuer`, with no legacy-unkeyed fallback.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue