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:
cliffhall 2026-08-28 03:40:40 -04:00
commit 56ac83051b

View file

@ -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