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:
parent
c8c050b51e
commit
f3f68c731b
2 changed files with 109 additions and 18 deletions
|
|
@ -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(), {
|
||||
|
|
|
|||
|
|
@ -326,9 +326,9 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
*
|
||||
* 1. CIMD is configured for this connection right now, and
|
||||
* 2. the incoming `client_id` is exactly that metadata-document URL, and
|
||||
* 3. the registration already stored under that same `client_id` is recorded
|
||||
* as `cimd` — written by `ensureCimdClientRegistration`, which reaches that
|
||||
* line only after confirming the AS advertises
|
||||
* 3. a registration stored for this server under that same `client_id` is
|
||||
* recorded as `cimd` — written by `ensureCimdClientRegistration`, which
|
||||
* reaches that line only after confirming the AS advertises
|
||||
* `client_id_metadata_document_supported`.
|
||||
*
|
||||
* RFC 7591 §3.2 makes a dynamically issued `client_id` opaque, so a client may
|
||||
|
|
@ -338,6 +338,15 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
* to already hold a CIMD registration recorded under it. Anything else — a
|
||||
* fresh DCR, a different id, CIMD switched off, no prior CIMD registration —
|
||||
* falls through to `"dcr"`.
|
||||
*
|
||||
* ⚠️ (3) is deliberately **not** scoped to the incoming issuer alone. SEP-2352
|
||||
* keys registrations per authorization server, so a resource that resolves to a
|
||||
* second issuer legitimately has no record under it yet: the first binding
|
||||
* promotes the unkeyed CIMD entry into issuer A's slot and clears the fallback,
|
||||
* `ensureCimdClientRegistration` then early-returns on the ctx-less read, and
|
||||
* the SDK's own CIMD branch saves under issuer B with nothing stored for B.
|
||||
* Checking the issuer slot and then the server's active registration keeps that
|
||||
* second issuer labeled CIMD (Copilot).
|
||||
*/
|
||||
private async resolveSdkRegistrationKind(
|
||||
clientInformation: OAuthClientInformation,
|
||||
|
|
@ -350,21 +359,26 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
|
|||
) {
|
||||
return "dcr";
|
||||
}
|
||||
// Reads through to the unkeyed slot our own pre-registration wrote, since
|
||||
// the issuer slot does not exist yet on the save that creates it.
|
||||
const stored = await this.storage.getClientInformation(
|
||||
this.serverUrl,
|
||||
false,
|
||||
issuer,
|
||||
);
|
||||
if (stored?.client_id !== clientMetadataUrl) return "dcr";
|
||||
const storedKind = await this.storage.getClientRegistrationKind(
|
||||
this.serverUrl,
|
||||
issuer,
|
||||
);
|
||||
// `"static"` lives in the preregistered slot, never this one, so `"cimd"`
|
||||
// is the only kind worth carrying forward.
|
||||
return storedKind === "cimd" ? "cimd" : "dcr";
|
||||
// `undefined` resolves to the server's active issuer, falling back to the
|
||||
// unkeyed slot our own pre-registration wrote — which is where the record
|
||||
// still lives on the save that first binds an issuer.
|
||||
const lookupKeys = issuer === undefined ? [undefined] : [issuer, undefined];
|
||||
for (const key of lookupKeys) {
|
||||
const stored = await this.storage.getClientInformation(
|
||||
this.serverUrl,
|
||||
false,
|
||||
key,
|
||||
);
|
||||
if (stored?.client_id !== clientMetadataUrl) continue;
|
||||
const storedKind = await this.storage.getClientRegistrationKind(
|
||||
this.serverUrl,
|
||||
key,
|
||||
);
|
||||
// `"static"` lives in the preregistered slot, never this one, so `"cimd"`
|
||||
// is the only kind worth carrying forward.
|
||||
if (storedKind === "cimd") return "cimd";
|
||||
}
|
||||
return "dcr";
|
||||
}
|
||||
|
||||
async saveScope(scope: string | undefined): Promise<void> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue