fix(auth): read enumerated issuers exactly, and stop the TUI tone going stale (#2144)
Copilot review round 4. - `getTokens(url, issuer)` falls back to the legacy unkeyed slot when that issuer holds none — right for a connect, wrong for `collectGrants`, which labelled the fallback with the issuer it happened to be iterating and paired it with that issuer's credentials. During a partially migrated flow the clear could have sent an old, unbound token to a newly discovered authorization server. `OAuthStorage.getIssuerTokens` is the exact, no-fallback read; only the ctx-less read still falls back, which is the one place it belongs. - The TUI's warning tone was sticky. It lived in its own state while ~30 call sites set ordinary OAuth notes without resetting it, so one revocation failure left every later "Authorization updated" yellow, across server switches. It is now DERIVED — `oauthMessageToneFor(message, warningText)` — so it cannot outlive the message that earned it, with nothing to remember to clear. Both setters stay plain `useState` setters: a `useCallback` wrapper would be a value `react-hooks/exhaustive-deps` demands in seven dependency arrays for an identity that never changes. - Hook-level tests for the web wiring (previously missed): the per-server opt-out and the backend-proxied fetch are now asserted to reach `clearServerOAuthState`, so handing it the page-origin fetch — which a real authorization server's missing CORS headers would reject — fails a test. 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
781aa69d19
commit
6c499f3ea1
3 changed files with 211 additions and 17 deletions
28
clients/tui/__tests__/oauthMessageTone.test.ts
Normal file
28
clients/tui/__tests__/oauthMessageTone.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { oauthMessageToneFor } from "../src/oauthMessageTone.js";
|
||||
|
||||
const WARNING =
|
||||
"Cleared locally, but revoking the grant at the authorization server failed.";
|
||||
|
||||
describe("oauthMessageToneFor", () => {
|
||||
it("warns while the message it was raised for is showing", () => {
|
||||
expect(oauthMessageToneFor(WARNING, WARNING)).toBe("warning");
|
||||
});
|
||||
|
||||
// The bug this replaced (#2144): a stored tone survived the message that
|
||||
// earned it, so every later ordinary note rendered as a warning.
|
||||
it("returns to informational as soon as any other message replaces it", () => {
|
||||
expect(oauthMessageToneFor("Authorization updated.", WARNING)).toBe("info");
|
||||
});
|
||||
|
||||
it("is informational when nothing has ever warned", () => {
|
||||
expect(oauthMessageToneFor("Authorization updated.", null)).toBe("info");
|
||||
});
|
||||
|
||||
// `null === null` must not read as a warning — with no message on screen
|
||||
// there is nothing to colour, and the previous warning is long gone.
|
||||
it("is informational when there is no message", () => {
|
||||
expect(oauthMessageToneFor(null, null)).toBe("info");
|
||||
expect(oauthMessageToneFor(null, WARNING)).toBe("info");
|
||||
});
|
||||
});
|
||||
|
|
@ -65,6 +65,7 @@ import {
|
|||
NodeOAuthStorage,
|
||||
runRunnerInteractiveOAuth,
|
||||
} from "@inspector/core/auth/node/index.js";
|
||||
import { oauthMessageToneFor } from "./oauthMessageTone.js";
|
||||
import { getTuiLogger } from "./logger.js";
|
||||
import { openUrl } from "./utils/openUrl.js";
|
||||
import {
|
||||
|
|
@ -161,13 +162,13 @@ function App({
|
|||
"idle" | "authenticating" | "error"
|
||||
>("idle");
|
||||
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
|
||||
// #2144: a revocation failure is a partial success — the local state really
|
||||
// was cleared — so it is not an `error` status, but it must not read as an
|
||||
// ordinary note either: the grant may still be live at the authorization
|
||||
// server.
|
||||
const [oauthMessageTone, setOauthMessageTone] = useState<"info" | "warning">(
|
||||
"info",
|
||||
);
|
||||
// The tone is derived from the message rather than stored beside it, so it
|
||||
// cannot go stale — see `oauthMessageToneFor` for why that matters here.
|
||||
// Both stay plain `useState` setters on purpose: wrapping `setOauthMessage`
|
||||
// in a `useCallback` would make it a value `react-hooks/exhaustive-deps`
|
||||
// demands in seven dependency arrays, for an identity that never changes.
|
||||
const [oauthWarningText, setOauthWarningText] = useState<string | null>(null);
|
||||
const oauthMessageTone = oauthMessageToneFor(oauthMessage, oauthWarningText);
|
||||
const [oauthRevision, setOauthRevision] = useState(0);
|
||||
const [pendingStepUp, setPendingStepUp] = useState<{
|
||||
serverName: string;
|
||||
|
|
@ -956,12 +957,13 @@ function App({
|
|||
revoke: selectedServerEntry?.settings?.oauthRevokeOnClear !== false,
|
||||
});
|
||||
setOauthStatus("idle");
|
||||
setOauthMessageTone(revocation.status === "failed" ? "warning" : "info");
|
||||
setOauthMessage(
|
||||
revocation.status === "failed"
|
||||
? `Cleared locally, but revoking the grant at the authorization server failed: ${revocation.detail}. It may still be valid there.`
|
||||
: null,
|
||||
);
|
||||
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.`;
|
||||
setOauthWarningText(warning);
|
||||
setOauthMessage(warning);
|
||||
} else {
|
||||
setOauthMessage(null);
|
||||
}
|
||||
setConnectError(null);
|
||||
if (inspectorStatus === "connected" || inspectorStatus === "connecting") {
|
||||
await disconnectInspector();
|
||||
|
|
|
|||
29
clients/tui/src/oauthMessageTone.ts
Normal file
29
clients/tui/src/oauthMessageTone.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* How the Auth tab should colour the OAuth note it is currently showing.
|
||||
*
|
||||
* The tone is **derived** from the message rather than stored beside it
|
||||
* (#2144). `App.tsx` sets an ordinary OAuth note in around thirty places, none
|
||||
* of which would think to reset a tone, so a second piece of state went stale
|
||||
* the moment one message raised it: a single revocation failure left every
|
||||
* later "Authorization updated" rendering as a warning, including after
|
||||
* switching servers.
|
||||
*
|
||||
* Comparing the shown message against the text that was raised as a warning
|
||||
* makes the tone a function of what is on screen, so it cannot outlive it —
|
||||
* the next `setOauthMessage` of anything else is informational by
|
||||
* construction, with nothing to remember to clear.
|
||||
*
|
||||
* `warning` is for a *partial* success: the OAuth 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 would understate that.
|
||||
*/
|
||||
export type OAuthMessageTone = "info" | "warning";
|
||||
|
||||
export function oauthMessageToneFor(
|
||||
message: string | null,
|
||||
warningText: string | null,
|
||||
): OAuthMessageTone {
|
||||
// `null === null` must not read as a warning: with no message there is
|
||||
// nothing to colour.
|
||||
return message !== null && message === warningText ? "warning" : "info";
|
||||
}
|
||||
Loading…
Reference in a new issue