fix: keep CIMD provenance when a resource resolves to a second issuer

Copilot review (#2287): scoping the stored-CIMD check to the incoming issuer
alone mislabels the SDK's own CIMD write for a second authorization server.
SEP-2352 keys registrations per AS, so the first binding promotes the unkeyed
CIMD entry into issuer A's slot and clears the fallback;
`ensureCimdClientRegistration` then early-returns on its ctx-less read, and the
save under issuer B finds nothing recorded for B.

Check the issuer slot and then the server's active registration, so a second
issuer stays CIMD while a `client_id` the AS minted itself still falls through
to `dcr`. Covered by two tests driving a real `OAuthStorageBase` through the
A → B sequence, since the behaviour under test is how storage promotes and
clears slots rather than anything a mock would express.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lmj6X2epWpPE7ScdSKQmtA
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-07 00:44:49 -04:00
commit f3f68c731b

View file

@ -7,6 +7,9 @@ import {
type OAuthProviderConfig,
} from "@inspector/core/auth/providers.js";
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
import { OAuthStorageBase } from "@inspector/core/auth/oauth-storage.js";
import { OAuthMemoryStore } from "@inspector/core/auth/store.js";
import type { OAuthPersistBackend } from "@inspector/core/auth/oauth-persist.js";
import {
BrowserNavigation,
BrowserOAuthClientProvider,
@ -775,6 +778,80 @@ describe("OAuthNavigation", () => {
);
});
// SEP-2352 keys registrations per authorization server. Driven against a
// real `OAuthStorageBase` rather than mocks, because the bug is in how
// the *storage* promotes and clears slots across issuers (Copilot).
describe("across two authorization servers", () => {
const ISSUER_B = "https://as-b.example.com";
function makeRealStorage(): OAuthStorage {
const backend: OAuthPersistBackend = {
read: async () => null,
write: async () => {},
};
return new OAuthStorageBase(new OAuthMemoryStore(), backend);
}
async function bindFirstIssuer(storage: OAuthStorage) {
const provider = makeProvider(storage, vi.fn(), {
clientMetadataUrl: METADATA_URL,
});
// Our own pre-registration writes the unkeyed slot...
await provider.saveClientInformation(
{ client_id: METADATA_URL },
{ registrationKind: "cimd" },
);
// ...which the SDK's first issuer-stamped save promotes into
// issuer A's slot, clearing the unkeyed fallback.
await provider.saveClientInformation(
{ client_id: METADATA_URL },
{ issuer: ISSUER },
);
return provider;
}
it("keeps cimd when the resource resolves to a second issuer", async () => {
const storage = makeRealStorage();
const provider = await bindFirstIssuer(storage);
expect(
await storage.getClientRegistrationKind(SERVER, ISSUER),
).toBe("cimd");
// The precondition that made this go wrong: nothing is stored for
// issuer B, and the unkeyed fallback is gone.
expect(
await storage.getClientInformation(SERVER, false, ISSUER_B),
).toBeUndefined();
// The SDK's own CIMD branch, saving under the second issuer.
await provider.saveClientInformation(
{ client_id: METADATA_URL },
{ issuer: ISSUER_B },
);
expect(
await storage.getClientRegistrationKind(SERVER, ISSUER_B),
).toBe("cimd");
expect(
await storage.getClientRegistrationKind(SERVER, ISSUER),
).toBe("cimd");
});
it("still records dcr for a second issuer that mints its own client_id", async () => {
const storage = makeRealStorage();
const provider = await bindFirstIssuer(storage);
await provider.saveClientInformation(
{ client_id: "b-registered-id" },
{ issuer: ISSUER_B },
);
expect(
await storage.getClientRegistrationKind(SERVER, ISSUER_B),
).toBe("dcr");
});
});
it("an explicit registrationKind wins and consults no storage reads", async () => {
const storage = makeCimdStorage();
const provider = makeProvider(storage, vi.fn(), {