fix(auth): revoke with the registration bound to the grant, not the current one (#2144)

Copilot review round 17.

`resolveClientInformation` mirrored `BaseOAuthClientProvider`, preferring the
preregistered entry. That order answers "who should I authenticate as now";
revocation asks "who minted this token", and the two diverge — the store lets a
preregistered client and an issuer-bound dynamic registration coexist, so after
a server is switched from DCR to a configured `oauth.clientId` an older DCR
grant was revoked with the configured client's credentials. RFC 7009 §2.2
answers 200 for a token the server does not recognise as the caller's, so that
reported `revoked` while the grant stayed live and the local record was already
gone — the worst available outcome.

The registration bound to the grant's issuer now wins, with the preregistered
entry as the fallback, which covers both directions: a token minted with the
configured client leaves the issuer slot empty, since the SDK writes it only
after DCR.

Stated in the comment rather than implied: this is best available evidence, not
proof. The store records client information per issuer, not per token, so
binding identity at save time remains the real fix and is a storage-shape
change beyond this PR.

Also fixed a CLI test comment still describing the pre-round-13 ordering.

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 07:01:20 -04:00
commit 0ded02b34c

View file

@ -435,12 +435,41 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
expect(body(init!).get("token")).toBe("r");
});
// A server configured with `oauth.clientId` stores its credentials in the
// preregistered slot, which is issuer-independent and is *not* what a plain
// `getClientInformation(serverUrl)` returns. Reading only the dynamic slot
// would send no client authentication at all for exactly the confidential
// clients most likely to require it.
it("authenticates with a preconfigured client, not just a dynamically registered one", async () => {
// A grant minted under DCR must be revoked with the registration that minted
// it, even after the server has since been switched to a configured
// `oauth.clientId`. RFC 7009 §2.2 answers 200 for a token the server does not
// recognise as the caller's, so using the wrong client reports `revoked`
// while the grant stays live — and the local record is already gone.
it("authenticates with the registration bound to the grant's issuer", async () => {
await seed();
await storage.saveClientInformation(
SERVER_URL,
{ client_id: "dcr-cid", client_secret: "dcr-sec" },
{ registrationKind: "dcr", issuer: ISSUER },
);
// A configured client id was added later; it must NOT win here.
await storage.savePreregisteredClientInformation(SERVER_URL, {
client_id: "static-cid",
client_secret: "static-sec",
});
const fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 }),
);
await revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn });
const [, init] = fetchFn.mock.calls[0]!;
expect(headerOf(init!, "Authorization")).toBe(
`Basic ${btoa("dcr-cid:dcr-sec")}`,
);
});
// The other direction: a token minted with the configured client leaves the
// issuer slot empty (the SDK writes it only after DCR), so the preregistered
// entry is the right fallback — and dropping it would send no client
// authentication at all for exactly the confidential clients most likely to
// require it.
it("falls back to a preconfigured client when the issuer slot has none", async () => {
await seed();
await storage.savePreregisteredClientInformation(SERVER_URL, {
client_id: "static-cid",