fix: preserve CIMD registration provenance across SDK issuer binding (#2242)

`BaseOAuthClientProvider.saveClientInformation` treated every save without an
explicit `registrationKind` as DCR. The SDK reaches it from three places in
`auth()` and only one is a dynamic registration, so the issuer-binding write
overwrote the `cimd` provenance our own pre-registration had just stored — and
Connection Info reported `Dynamic (DCR)` for a connection that never issued a
`POST /oauth/register`.

Recover the kind instead: a `client_id` equal to the configured
`clientMetadataUrl` is CIMD (which also covers the SDK's own CIMD write), and
otherwise a stored registration with the same `client_id` carries its recorded
kind forward. Matching on `client_id` keeps stale CIMD provenance from leaking
onto a later DCR registration for the same server.

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:04:40 -04:00
commit 4789cb954e
2 changed files with 204 additions and 4 deletions

View file

@ -216,6 +216,7 @@ describe("OAuthNavigation", () => {
load: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn().mockResolvedValue(undefined),
getClientInformation: vi.fn(async () => undefined),
getClientRegistrationKind: vi.fn(async () => undefined),
saveClientInformation: vi.fn(async () => undefined),
savePreregisteredClientInformation: vi.fn(async () => undefined),
saveScope: vi.fn(async () => undefined),
@ -238,11 +239,13 @@ describe("OAuthNavigation", () => {
function makeProvider(
storage: OAuthStorage,
navCallback = vi.fn(),
extraConfig: Partial<OAuthProviderConfig> = {},
): BaseOAuthClientProvider {
const config: OAuthProviderConfig = {
storage,
redirectUrlProvider: new MutableRedirectUrlProvider(),
navigation: new CallbackNavigation(navCallback),
...extraConfig,
};
return new BaseOAuthClientProvider(SERVER, config);
}
@ -625,6 +628,147 @@ describe("OAuthNavigation", () => {
);
});
// #2242: the SDK binds an existing registration to its issuer by calling
// `saveClientInformation(info, { issuer })` with no registration kind.
// Treating every such save as DCR relabeled a CIMD registration
// "Dynamic (DCR)" in Connection Info, even though no `POST /register`
// ever happened.
describe("registration kind on an unstamped (SDK) save", () => {
const ISSUER = "https://as.example.com";
const METADATA_URL = "https://app.example.com/client-metadata.json";
it("keeps cimd when the client_id is the configured metadata document URL", async () => {
const storage = makeStorage();
const provider = makeProvider(storage, vi.fn(), {
clientMetadataUrl: METADATA_URL,
});
await provider.saveClientInformation(
{ client_id: METADATA_URL },
{ issuer: ISSUER },
);
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER,
{ client_id: METADATA_URL },
{ registrationKind: "cimd", issuer: ISSUER },
);
});
it("still records dcr for a server-minted client_id while CIMD is configured", async () => {
const storage = makeStorage();
const provider = makeProvider(storage, vi.fn(), {
clientMetadataUrl: METADATA_URL,
});
await provider.saveClientInformation(
{ client_id: "dcr-minted-id" },
{ issuer: ISSUER },
);
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER,
{ client_id: "dcr-minted-id" },
{ registrationKind: "dcr", issuer: ISSUER },
);
});
it("preserves the stored kind when the stored client_id matches", async () => {
const storage = makeStorage();
vi.mocked(storage.getClientInformation).mockImplementation(
async (_url: string, preregistered?: boolean) =>
preregistered ? undefined : { client_id: METADATA_URL },
);
vi.mocked(storage.getClientRegistrationKind).mockResolvedValue(
"cimd",
);
// No `clientMetadataUrl` on the provider — the provenance comes from
// storage alone, so a config cleared since the registration was made
// does not silently demote it.
const provider = makeProvider(storage);
await provider.saveClientInformation(
{ client_id: METADATA_URL },
{ issuer: ISSUER },
);
expect(storage.getClientInformation).toHaveBeenCalledWith(
SERVER,
false,
ISSUER,
);
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER,
{ client_id: METADATA_URL },
{ registrationKind: "cimd", issuer: ISSUER },
);
});
it("does not leak a stored cimd kind onto a different client_id", async () => {
const storage = makeStorage();
vi.mocked(storage.getClientInformation).mockImplementation(
async (_url: string, preregistered?: boolean) =>
preregistered ? undefined : { client_id: METADATA_URL },
);
vi.mocked(storage.getClientRegistrationKind).mockResolvedValue(
"cimd",
);
const provider = makeProvider(storage);
await provider.saveClientInformation(
{ client_id: "freshly-registered" },
{ issuer: ISSUER },
);
expect(storage.getClientRegistrationKind).not.toHaveBeenCalled();
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER,
{ client_id: "freshly-registered" },
{ registrationKind: "dcr", issuer: ISSUER },
);
});
it("falls back to dcr when storage has a matching id but no recorded kind", async () => {
const storage = makeStorage();
vi.mocked(storage.getClientInformation).mockImplementation(
async (_url: string, preregistered?: boolean) =>
preregistered ? undefined : { client_id: "legacy-id" },
);
const provider = makeProvider(storage);
await provider.saveClientInformation(
{ client_id: "legacy-id" },
{ issuer: ISSUER },
);
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER,
{ client_id: "legacy-id" },
{ registrationKind: "dcr", issuer: ISSUER },
);
});
it("an explicit registrationKind wins and consults no storage reads", async () => {
const storage = makeStorage();
const provider = makeProvider(storage, vi.fn(), {
clientMetadataUrl: METADATA_URL,
});
await provider.saveClientInformation(
{ client_id: METADATA_URL },
{ registrationKind: "cimd" },
);
expect(storage.getClientInformation).not.toHaveBeenCalled();
expect(storage.getClientRegistrationKind).not.toHaveBeenCalled();
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER,
{ client_id: METADATA_URL },
{ registrationKind: "cimd", issuer: undefined },
);
});
});
it("round-trips discovery state to storage", async () => {
const storage = makeStorage();
const provider = makeProvider(storage);

View file

@ -370,6 +370,15 @@ describe("InspectorClient OAuth E2E", () => {
// Connection should now be successful
expect(client.getStatus()).toBe("connected");
// #2242: the metadata-document URL is the client_id, and the stored
// provenance still says CIMD after the SDK bound the registration to
// the issuer — no `POST /register` ever happened.
const oauthState = await client.getOAuthState();
expect(oauthState?.client).toMatchObject({
clientId: metadataUrl,
registrationKind: "cimd",
});
});
it("should retry original request after OAuth completion with CIMD", async () => {