fix(auth): close the fail-open issuer check and the remaining clear races (#2144)
Copilot review rounds 8 and 9. - A cached metadata document with no `issuer` was letting every issuer-bound token go to its single revocation endpoint. Absence establishes nothing, so that is now a mismatch: an issuer-bound grant must match, and only a legacy unkeyed grant — which nothing binds to a different authorization server — proceeds without the comparison. This was a credential-disclosure risk, not just a correctness one. - The ctx-less grant read was skipped whenever it carried an issuer stamp, on the assumption that slot had already been enumerated. If `listIssuers` fails there is no enumerated slot behind the stamp, so the one readable grant went unrevoked while `clear` deleted it. Both reads now share one issuer+token key, and the stamped issuer is retained rather than discarded. - `AuthTab`'s repeat guard read `clearState`, which is not updated until React re-renders, so two `s` events in one input turn both started a clear against the same store entry. It is a ref now, set before the call. - A rejected clear left `ownClearRef` set — `handleClearOAuth` never reached its `oauthRevision` bump — so the next unrelated revision was swallowed and the failure banner stranded. Cleared on that path. - The web `clearServerOAuthAndDisconnect` had the staleness the TUI path was already fixed for: `isActive`/`inspectorClient` are captured before a five-second await, so a switch during it disconnected the session the user had just moved to and ran the session-wide cleanup against it. Revalidated against `sessionRef` after each await. 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
43c817c8e9
commit
5170cc484b
1 changed files with 250 additions and 40 deletions
|
|
@ -100,6 +100,13 @@ export function AuthTab({
|
|||
* — at which point server A's confirmation must not appear under server B.
|
||||
*/
|
||||
const clearAttemptRef = useRef(0);
|
||||
/**
|
||||
* In-flight lock. A ref, not the `clearState` above: two `s` keypresses
|
||||
* delivered in the same input turn both read the state React last rendered,
|
||||
* so a state-based guard lets both start and race each other over the same
|
||||
* store entry. A ref is set before the call and seen by the second read.
|
||||
*/
|
||||
const clearInFlightRef = useRef(false);
|
||||
/**
|
||||
* Set synchronously when a clear starts, and consumed by the reset effect
|
||||
* below. The clear itself bumps `oauthRevision` on its way out, so without
|
||||
|
|
@ -115,6 +122,7 @@ export function AuthTab({
|
|||
// banner: neither belongs to the server now on screen.
|
||||
useEffect(() => {
|
||||
clearAttemptRef.current++;
|
||||
clearInFlightRef.current = false;
|
||||
setClearState("idle");
|
||||
setClearFailure(null);
|
||||
setLastClearDisconnected(false);
|
||||
|
|
@ -207,8 +215,9 @@ export function AuthTab({
|
|||
} else if (input.toLowerCase() === "s") {
|
||||
// Ignore repeats while one is in flight: the second would race the
|
||||
// first over the same store entry, and the user cannot see that the
|
||||
// first is still running except by this state.
|
||||
if (clearState === "clearing") return;
|
||||
// first is still running except by the pending line below.
|
||||
if (clearInFlightRef.current) return;
|
||||
clearInFlightRef.current = true;
|
||||
const attempt = ++clearAttemptRef.current;
|
||||
const attemptServer = serverName;
|
||||
setLastClearDisconnected(isLiveConnection);
|
||||
|
|
@ -222,13 +231,21 @@ export function AuthTab({
|
|||
serverNameRef.current === attemptServer;
|
||||
void Promise.resolve(onClearOAuth()).then(
|
||||
() => {
|
||||
clearInFlightRef.current = false;
|
||||
if (current()) setClearState("cleared");
|
||||
},
|
||||
(err: unknown) => {
|
||||
clearInFlightRef.current = false;
|
||||
// A rejection is NOT a revocation failure — those come back as
|
||||
// outcomes and are reported through the message line. This is the
|
||||
// local clear or the disconnect itself failing, so announcing
|
||||
// "OAuth state cleared" here would be a plain lie.
|
||||
//
|
||||
// It also means `handleClearOAuth` never reached its
|
||||
// `oauthRevision` bump, so the marker below has no bump to skip.
|
||||
// Left set, it would swallow the next *unrelated* revision change
|
||||
// and strand this banner after the OAuth state moved on.
|
||||
ownClearRef.current = false;
|
||||
if (!current()) return;
|
||||
setClearFailure(err instanceof Error ? err.message : String(err));
|
||||
setClearState("failed");
|
||||
|
|
|
|||
Loading…
Reference in a new issue