fix(auth): RFC-encode the Basic credential; make the TUI clear await its work (#2144)
Copilot review round 6. - The Basic credential is now form-urlencoded per RFC 6749 §2.3.1. Round 1 matched the SDK's raw `id:secret` on the theory that a credential should be presented the same way here as at the token endpoint; that does not hold — a client authenticated with `client_secret_post` never exercised the SDK's Basic path, so there is no precedent to match, and the raw form is ambiguous for an id containing `:` and makes `btoa` throw on a non-Latin-1 secret. The fixture decodes each half. - The ctx-less grant read is suppressed on the store's issuer *stamp* rather than on the token's value. The active slot can hold client information without a token, in which case the read falls back to the legacy grant — and a value collision with any other issuer would have dropped that grant unrevoked and unreported. - The TUI announced "OAuth state cleared" on the keypress, so with a bounded revocation request it said so while the work was still in flight and then removed it when the work finished. `onClearOAuth` returns a promise; `AuthTab` shows "Clearing OAuth state…" until it settles, ignores repeats while one is in flight, and skips exactly the revision bump its own clear caused so the confirmation is not immediately reset by it. - `handleClearOAuth` now claims an attempt and retires a superseded one, the way `handleDisconnect` does: the await can keep it suspended for seconds, long enough for the user to switch servers and have server A's outcome — including its disconnect — land on server B. 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
e9cd678cd0
commit
56ac83051b
2 changed files with 193 additions and 35 deletions
|
|
@ -183,6 +183,8 @@ function App({
|
|||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
// Monotonic token for in-flight disconnects — see handleDisconnect.
|
||||
const disconnectAttemptRef = useRef(0);
|
||||
/** Retires a superseded "clear OAuth state" the way disconnects are (#2144). */
|
||||
const clearOAuthAttemptRef = useRef(0);
|
||||
// A failed disconnect is deliberately NOT folded into `connectError`. That
|
||||
// one is only rendered by InfoTab when the status is "error", which a
|
||||
// rejected disconnect leaves untouched (the status stays "connected"), so
|
||||
|
|
@ -949,13 +951,27 @@ function App({
|
|||
|
||||
const handleClearOAuth = useCallback(async () => {
|
||||
if (!selectedInspectorClient) return;
|
||||
// RFC 7009 (#2144). Best-effort: the outcome is reported in the status
|
||||
// line, never thrown, so clearing always completes. The per-server
|
||||
// `oauthRevokeOnClear` opt-out is honored here the same way the web client
|
||||
// honors it.
|
||||
// Claim this attempt before awaiting, exactly as `handleDisconnect` does.
|
||||
// The RFC 7009 leg is a bounded network request, so this callback can now
|
||||
// stay suspended for seconds — long enough for the user to switch servers,
|
||||
// at which point publishing this result would put server A's outcome on
|
||||
// server B (and would race a second clear over the same state). (#2144)
|
||||
const attempt = ++clearOAuthAttemptRef.current;
|
||||
const attemptServer = selectedServer;
|
||||
// RFC 7009. Best-effort: the outcome is reported in the status line, never
|
||||
// thrown, so clearing always completes. The per-server `oauthRevokeOnClear`
|
||||
// opt-out is honored here the same way the web client honors it.
|
||||
const revocation = await selectedInspectorClient.clearOAuthTokens({
|
||||
revoke: selectedServerEntry?.settings?.oauthRevokeOnClear !== false,
|
||||
});
|
||||
// The local clear has happened either way — only the *reporting* is
|
||||
// retired, so a superseded attempt leaves no trace on the new selection.
|
||||
if (
|
||||
clearOAuthAttemptRef.current !== attempt ||
|
||||
selectedServerRef.current !== attemptServer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOauthStatus("idle");
|
||||
if (revocation.status === "failed") {
|
||||
const warning = `Cleared locally, but revoking the grant at the authorization server failed: ${revocation.detail}. It may still be valid there.`;
|
||||
|
|
@ -971,6 +987,7 @@ function App({
|
|||
setOauthRevision((n) => n + 1);
|
||||
}, [
|
||||
selectedInspectorClient,
|
||||
selectedServer,
|
||||
selectedServerEntry,
|
||||
inspectorStatus,
|
||||
disconnectInspector,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,15 @@ interface AuthTabProps {
|
|||
width: number;
|
||||
height: number;
|
||||
focused?: boolean;
|
||||
onClearOAuth: () => void;
|
||||
/**
|
||||
* Clears (and, unless opted out, revokes) this server's OAuth state.
|
||||
*
|
||||
* Returns a promise so the confirmation below can wait for it. The RFC 7009
|
||||
* revocation leg is a network request with a five-second bound (#2144), so
|
||||
* this is no longer instantaneous, and announcing "cleared" on the keypress
|
||||
* would say it while the work was still in flight.
|
||||
*/
|
||||
onClearOAuth: () => void | Promise<void>;
|
||||
connectionStatus: ConnectionStatus;
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +90,16 @@ export function AuthTab({
|
|||
const [oauthState, setOauthState] = useState<
|
||||
OAuthConnectionState | undefined
|
||||
>(undefined);
|
||||
const [clearedConfirmation, setClearedConfirmation] = useState(false);
|
||||
const [clearState, setClearState] = useState<"idle" | "clearing" | "cleared">(
|
||||
"idle",
|
||||
);
|
||||
/**
|
||||
* Set synchronously when a clear starts, and consumed by the reset effect
|
||||
* below. The clear itself bumps `oauthRevision` on its way out, so without
|
||||
* this the reset would race the confirmation it is meant to outlive — and
|
||||
* which of the two lands first is not something the ordering guarantees.
|
||||
*/
|
||||
const ownClearRef = useRef(false);
|
||||
const [lastClearDisconnected, setLastClearDisconnected] = useState(false);
|
||||
const [stepUpChoiceIndex, setStepUpChoiceIndex] = useState(0);
|
||||
|
||||
|
|
@ -100,7 +117,12 @@ export function AuthTab({
|
|||
}, [refreshOAuthState, oauthRevision, connectionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setClearedConfirmation(false);
|
||||
// Skip exactly the revision bump our own clear caused; reset on the next.
|
||||
if (ownClearRef.current) {
|
||||
ownClearRef.current = false;
|
||||
return;
|
||||
}
|
||||
setClearState("idle");
|
||||
setLastClearDisconnected(false);
|
||||
}, [oauthRevision]);
|
||||
|
||||
|
|
@ -163,9 +185,17 @@ export function AuthTab({
|
|||
const h = scrollViewRef.current.getViewportHeight() || 1;
|
||||
scrollViewRef.current.scrollBy(h);
|
||||
} 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;
|
||||
setLastClearDisconnected(isLiveConnection);
|
||||
onClearOAuth();
|
||||
setClearedConfirmation(true);
|
||||
ownClearRef.current = true;
|
||||
setClearState("clearing");
|
||||
const settle = () => setClearState("cleared");
|
||||
// Settled either way: a failed revocation still cleared local state,
|
||||
// and `handleClearOAuth` reports the failure through the message line.
|
||||
void Promise.resolve(onClearOAuth()).then(settle, settle);
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
|
|
@ -331,7 +361,10 @@ export function AuthTab({
|
|||
Clear OAuth <Text underline>S</Text>tate
|
||||
{isLiveConnection && " and disconnect"}
|
||||
</SelectableItem>
|
||||
{clearedConfirmation && (
|
||||
{clearState === "clearing" && (
|
||||
<Text color="yellow">Clearing OAuth state…</Text>
|
||||
)}
|
||||
{clearState === "cleared" && (
|
||||
<Text color="green">
|
||||
{lastClearDisconnected
|
||||
? "OAuth state cleared. Disconnected."
|
||||
|
|
|
|||
Loading…
Reference in a new issue