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
4 changed files with 193 additions and 35 deletions
|
|
@ -100,6 +100,64 @@ describe("AuthTab", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// #2144 — the clear is now a bounded network request, so announcing "cleared"
|
||||
// on the keypress would say it while the work was still in flight.
|
||||
it("shows a pending state until the clear settles, and ignores repeats", async () => {
|
||||
let settle: () => void = () => {};
|
||||
const onClearOAuth = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
settle = resolve;
|
||||
}),
|
||||
);
|
||||
const { lastFrame, stdin } = render(
|
||||
<AuthTab
|
||||
{...baseProps}
|
||||
onClearOAuth={onClearOAuth}
|
||||
inspectorClient={null}
|
||||
oauthStatus="idle"
|
||||
oauthMessage={null}
|
||||
focused
|
||||
/>,
|
||||
);
|
||||
await tick();
|
||||
|
||||
stdin.write("s");
|
||||
await tick();
|
||||
expect(lastFrame() ?? "").toContain("Clearing OAuth state");
|
||||
expect(lastFrame() ?? "").not.toContain("OAuth state cleared");
|
||||
|
||||
// A second press while one is in flight would race the first over the same
|
||||
// store entry, and nothing on screen tells the user the first is running.
|
||||
stdin.write("s");
|
||||
await tick();
|
||||
expect(onClearOAuth).toHaveBeenCalledTimes(1);
|
||||
|
||||
settle();
|
||||
await tick();
|
||||
expect(lastFrame() ?? "").toContain("OAuth state cleared");
|
||||
});
|
||||
|
||||
// A failed revocation still cleared local state, and the failure is reported
|
||||
// through the message line — so the confirmation must not hang forever.
|
||||
it("settles the confirmation even when the clear rejects", async () => {
|
||||
const onClearOAuth = vi.fn(() => Promise.reject(new Error("nope")));
|
||||
const { lastFrame, stdin } = render(
|
||||
<AuthTab
|
||||
{...baseProps}
|
||||
onClearOAuth={onClearOAuth}
|
||||
inspectorClient={null}
|
||||
oauthStatus="idle"
|
||||
oauthMessage={null}
|
||||
focused
|
||||
/>,
|
||||
);
|
||||
await tick();
|
||||
stdin.write("s");
|
||||
await tick();
|
||||
expect(lastFrame() ?? "").toContain("OAuth state cleared");
|
||||
});
|
||||
|
||||
// #2144 — a revocation failure is a *partial* success: the local state really
|
||||
// was cleared, so it is not an `error` status, but the grant may still be
|
||||
// live at the authorization server and the informational tone understates
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -191,12 +191,11 @@ describe("buildRevocationRequest", () => {
|
|||
clientInformation: { client_id: "id one", client_secret: "s/ecret" },
|
||||
supportedAuthMethods: ["client_secret_basic"],
|
||||
});
|
||||
// Byte-identical to the SDK's `applyBasicAuth`: the raw `id:secret`, not the
|
||||
// form-urlencoded pair RFC 6749 §2.3.1 asks for. Presenting the credential
|
||||
// differently here than at the token endpoint is what would let an
|
||||
// authorization server accept one request and reject the other.
|
||||
// RFC 6749 §2.3.1 form-urlencodes each half before the colon. That is what
|
||||
// keeps an id containing `:` unambiguous, and what stops `btoa` throwing on
|
||||
// a non-Latin-1 secret.
|
||||
expect(headerOf(init, "Authorization")).toBe(
|
||||
`Basic ${btoa("id one:s/ecret")}`,
|
||||
`Basic ${btoa("id%20one:s%2Fecret")}`,
|
||||
);
|
||||
expect(body(init).has("client_secret")).toBe(false);
|
||||
});
|
||||
|
|
@ -471,6 +470,43 @@ describe("revokeStoredOAuthTokens", () => {
|
|||
).toBe("legacy-r");
|
||||
});
|
||||
|
||||
// The active issuer slot can hold client information without a token, in
|
||||
// which case the ctx-less read falls back to the LEGACY grant — a distinct
|
||||
// grant, even if some other issuer happens to hold the same opaque value.
|
||||
// Suppressing on the value would drop it unrevoked and unreported, which is
|
||||
// why the suppression keys off the store's issuer stamp instead.
|
||||
it("keeps the legacy grant when another issuer holds the same token value", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: undefined }),
|
||||
);
|
||||
// A legacy unkeyed grant...
|
||||
await storage.saveTokens(SERVER_URL, {
|
||||
access_token: "shared",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "shared-r",
|
||||
});
|
||||
// ...and an issuer slot that coincidentally holds the same token value.
|
||||
// Saved via the client-information path so it does not clear the legacy
|
||||
// slot, then given tokens through a stubbed exact read.
|
||||
vi.spyOn(storage, "listIssuers").mockResolvedValue([
|
||||
"https://as-a.example.com",
|
||||
]);
|
||||
vi.spyOn(storage, "getIssuerTokens").mockResolvedValue({
|
||||
access_token: "shared",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "shared-r",
|
||||
});
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
|
||||
await revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn });
|
||||
|
||||
// Two grants, two requests — the issuer-bound one and the legacy one.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A grant bound to an issuer the cached metadata does not describe cannot be
|
||||
// revoked — that endpoint belongs to a different authorization server, and
|
||||
// sending it another AS's token would hand a credential to a server that
|
||||
|
|
|
|||
Loading…
Reference in a new issue