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:
cliffhall 2026-08-28 04:38:38 -04:00
commit 5170cc484b
2 changed files with 250 additions and 40 deletions

View file

@ -138,6 +138,55 @@ describe("AuthTab", () => {
expect(lastFrame() ?? "").toContain("OAuth state cleared");
});
// A state-based guard is not effective until React re-renders, so two `s`
// events in the SAME input turn both read the last-rendered "idle" and start
// concurrent clears against one store entry. No tick between the writes.
it("ignores a repeat delivered in the same input turn", async () => {
const onClearOAuth = vi.fn(() => new Promise<void>(() => {}));
const { stdin } = render(
<AuthTab
{...baseProps}
onClearOAuth={onClearOAuth}
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
focused
/>,
);
await tick();
stdin.write("s");
stdin.write("s");
await tick();
expect(onClearOAuth).toHaveBeenCalledTimes(1);
});
// A rejection releases the lock, so the user can retry.
it("allows a retry after a rejected clear", async () => {
const onClearOAuth = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("nope"))
.mockResolvedValue(undefined);
const { stdin } = render(
<AuthTab
{...baseProps}
onClearOAuth={onClearOAuth}
inspectorClient={null}
oauthStatus="idle"
oauthMessage={null}
focused
/>,
);
await tick();
stdin.write("s");
await tick();
stdin.write("s");
await tick();
expect(onClearOAuth).toHaveBeenCalledTimes(2);
});
// 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 reporting success would be a plain lie.

View file

@ -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");