fix: bind CIMD provenance to the issuer it was discovered for
Copilot review (#2287): the active-issuer fallback added last round could copy issuer A's CIMD provenance onto a real dynamic registration for issuer B, since RFC 7591 §3.2 permits B to mint the same opaque URL as its `client_id`. Fix it at the source rather than in the resolver. `ensureCimdClientRegistration` now runs discovery first and records the registration against the issuer it just discovered, having confirmed *that* AS advertises `client_id_metadata_document_supported`. Its "already registered?" check moves after discovery and is keyed by that issuer — read ctx-less it resolved through the active issuer and early-returned for every later one, which is what forced the cross-issuer fallback in the first place. `resolveSdkRegistrationKind` is therefore issuer-scoped again, with no fallback. A second AS behind one resource now gets its own determination: CIMD when it advertises CIMD, `dcr` otherwise — including when it mints the metadata URL as its own `client_id`. The cost is a discovery round trip per connect attempt rather than only the first; noted at the call site. 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:
parent
f3f68c731b
commit
4c6a9fb376
2 changed files with 185 additions and 66 deletions
|
|
@ -62,12 +62,15 @@ describe("ensureCimdClientRegistration", () => {
|
|||
fetchFn,
|
||||
});
|
||||
|
||||
// #2242: the record is bound to the issuer just discovered, so a second AS
|
||||
// behind the same resource gets its own CIMD determination rather than
|
||||
// inheriting this one.
|
||||
expect(storage.saveClientInformation).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
{
|
||||
client_id: METADATA_URL,
|
||||
},
|
||||
{ registrationKind: "cimd" },
|
||||
{ registrationKind: "cimd", issuer: "http://127.0.0.1:9999" },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -154,22 +157,52 @@ describe("ensureCimdClientRegistration", () => {
|
|||
expect(storage.saveClientInformation).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
{ client_id: METADATA_URL },
|
||||
{ registrationKind: "cimd" },
|
||||
{ registrationKind: "cimd", issuer: "http://127.0.0.1:9999" },
|
||||
);
|
||||
});
|
||||
|
||||
it("no-ops when client information is already stored", async () => {
|
||||
storage.getClientInformation = vi.fn(async () => ({
|
||||
client_id: "existing-client",
|
||||
}));
|
||||
it("no-ops when client information is already stored for the discovered issuer", async () => {
|
||||
// Dynamic slot only — a preregistered hit would short-circuit
|
||||
// `clientInformation()` before it ever reaches the issuer-keyed read.
|
||||
storage.getClientInformation = vi.fn(
|
||||
async (_url: string, preregistered?: boolean) =>
|
||||
preregistered ? undefined : { client_id: "existing-client" },
|
||||
);
|
||||
|
||||
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/.well-known/oauth-protected-resource")) {
|
||||
return new Response(JSON.stringify({ resource: SERVER_URL }));
|
||||
}
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer: "http://127.0.0.1:9999",
|
||||
authorization_endpoint: "http://127.0.0.1:9999/oauth/authorize",
|
||||
token_endpoint: "http://127.0.0.1:9999/oauth/token",
|
||||
response_types_supported: ["code"],
|
||||
client_id_metadata_document_supported: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const provider = createProvider(storage);
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl: SERVER_URL,
|
||||
provider,
|
||||
fetchFn: vi.fn(),
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(storage.saveClientInformation).not.toHaveBeenCalled();
|
||||
// #2242: the existing-client check is keyed by the issuer discovery just
|
||||
// resolved, not read ctx-less — a ctx-less read resolves through the
|
||||
// *active* issuer and would early-return for every later issuer.
|
||||
expect(storage.getClientInformation).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
false,
|
||||
"http://127.0.0.1:9999",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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 { ensureCimdClientRegistration } from "@inspector/core/auth/cimd.js";
|
||||
import {
|
||||
BrowserNavigation,
|
||||
BrowserOAuthClientProvider,
|
||||
|
|
@ -778,9 +779,12 @@ 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).
|
||||
// SEP-2352 keys registrations per authorization server, so a second AS
|
||||
// behind one resource is a separate determination. Driven against a real
|
||||
// `OAuthStorageBase` and the real `ensureCimdClientRegistration`, because
|
||||
// the behaviour under test is how the pre-registration binds provenance to
|
||||
// a discovered issuer and how storage promotes and clears slots — neither
|
||||
// of which a mock would express (Copilot).
|
||||
describe("across two authorization servers", () => {
|
||||
const ISSUER_B = "https://as-b.example.com";
|
||||
|
||||
|
|
@ -792,38 +796,71 @@ describe("OAuthNavigation", () => {
|
|||
return new OAuthStorageBase(new OAuthMemoryStore(), backend);
|
||||
}
|
||||
|
||||
async function bindFirstIssuer(storage: OAuthStorage) {
|
||||
/**
|
||||
* Discovery that points the resource at `issuer` as its authorization
|
||||
* server and declares CIMD support per `cimd`. The RFC 9728 document
|
||||
* has to name the AS, so that the RFC 8414 §3.3 issuer echo the SDK
|
||||
* enforces resolves against the AS URL rather than the resource's.
|
||||
*/
|
||||
function discoveryFetch(issuer: string, cimd: boolean) {
|
||||
return (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/.well-known/oauth-protected-resource")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resource: SERVER,
|
||||
authorization_servers: [issuer],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.startsWith(issuer)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
response_types_supported: ["code"],
|
||||
...(cimd && {
|
||||
client_id_metadata_document_supported: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
/** Issuer A pre-registers via CIMD, then the SDK binds it. */
|
||||
async function bindIssuerA(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 ensureCimdClientRegistration({
|
||||
serverUrl: SERVER,
|
||||
provider,
|
||||
fetchFn: discoveryFetch(ISSUER, true),
|
||||
});
|
||||
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();
|
||||
return provider;
|
||||
}
|
||||
|
||||
// The SDK's own CIMD branch, saving under the second issuer.
|
||||
it("keeps cimd when a second CIMD-supporting issuer takes over", async () => {
|
||||
const storage = makeRealStorage();
|
||||
const provider = await bindIssuerA(storage);
|
||||
|
||||
// Issuer B also advertises CIMD, so the pre-registration records it
|
||||
// for B too — it must not early-return on issuer A's client.
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl: SERVER,
|
||||
provider,
|
||||
fetchFn: discoveryFetch(ISSUER_B, true),
|
||||
});
|
||||
await provider.saveClientInformation(
|
||||
{ client_id: METADATA_URL },
|
||||
{ issuer: ISSUER_B },
|
||||
|
|
@ -837,9 +874,35 @@ describe("OAuthNavigation", () => {
|
|||
).toBe("cimd");
|
||||
});
|
||||
|
||||
it("still records dcr for a second issuer that mints its own client_id", async () => {
|
||||
it("records dcr when a second issuer without CIMD mints the same URL as its client_id", async () => {
|
||||
const storage = makeRealStorage();
|
||||
const provider = await bindFirstIssuer(storage);
|
||||
const provider = await bindIssuerA(storage);
|
||||
|
||||
// Issuer B does *not* advertise CIMD, so nothing is recorded for B...
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl: SERVER,
|
||||
provider,
|
||||
fetchFn: discoveryFetch(ISSUER_B, false),
|
||||
});
|
||||
// ...and RFC 7591 §3.2 lets it mint an opaque id that happens to be
|
||||
// the very URL issuer A uses as its CIMD client_id.
|
||||
await provider.saveClientInformation(
|
||||
{ client_id: METADATA_URL },
|
||||
{ issuer: ISSUER_B },
|
||||
);
|
||||
|
||||
expect(
|
||||
await storage.getClientRegistrationKind(SERVER, ISSUER_B),
|
||||
).toBe("dcr");
|
||||
// Issuer A's own provenance is untouched.
|
||||
expect(
|
||||
await storage.getClientRegistrationKind(SERVER, ISSUER),
|
||||
).toBe("cimd");
|
||||
});
|
||||
|
||||
it("records dcr for a second issuer that mints its own client_id", async () => {
|
||||
const storage = makeRealStorage();
|
||||
const provider = await bindIssuerA(storage);
|
||||
|
||||
await provider.saveClientInformation(
|
||||
{ client_id: "b-registered-id" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue