fix(auth): key grant dedup by issuer, and isolate per-slot read failures (#2144)

Copilot review round 5.

- Deduplicating grants by token string alone could conflate two grants: a token
  is only meaningful to the authorization server that minted it, so two issuers
  minting the same opaque value are two grants. The second was dropped before
  the issuer-mismatch check could report it, and `clear` then deleted it. The
  key is now issuer+token; the ctx-less read stays suppressed only when its
  token already came from a slot, which is exactly the active-issuer duplicate
  and never the legacy one.
- A read failure in one issuer slot aborted `collectGrants`, so one corrupt
  slot left every other revocable grant untouched while the clear deleted them
  all. Each slot is now read independently and its failure carried as an
  outcome alongside the grants that could still be revoked; the no-metadata and
  no-endpoint returns carry them too, so a failure is never swallowed by a skip.
- The CLI no longer deduplicates its two key spellings. That could only be done
  by pre-reading a single token, and `revokeStoredOAuthTokens` now enumerates
  every issuer slot under a key — so a shared active token skipped the second
  key entirely, taking any other issuer-bound grant under it with the local
  delete. A duplicate RFC 7009 request is harmless (§2.2 makes an unknown token
  a success); a missed one is the leak.

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:17:39 -04:00
commit e9cd678cd0

View file

@ -287,10 +287,11 @@ describe("clearStoredAuthForRelogin", () => {
}
});
// A token the AS never accepted is not spent, so the other key — which may
// carry the credentials or metadata that would have worked — must still be
// tried rather than skipped as a duplicate.
it("does not treat a failed attempt as having ended the grant", async () => {
// Keys are revoked from independently. Deduplicating them could only be
// done by pre-reading one token, which would skip the second key's OTHER
// issuer-bound grants along with it; a duplicate RFC 7009 request is
// harmless (§2.2 makes an unknown token a success), a missed one is not.
it("revokes from both keys even when they hold the same token", async () => {
seedBothSpellings("same-r", "same-r");
const fetchSpy = vi
.spyOn(globalThis, "fetch")