Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 92 additions & 6 deletions clients/web/src/test/core/auth/cimd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("ensureCimdClientRegistration", () => {
storage = {
getClientInformation: vi.fn(async () => undefined),
saveClientInformation: vi.fn(async () => {}),
getDiscoveryState: vi.fn(async () => undefined),
getScope: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn(async () => undefined),
saveTokens: vi.fn(async () => {}),
Expand Down Expand Up @@ -62,12 +63,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" },
);
});

Expand Down Expand Up @@ -154,22 +158,104 @@ 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",
// #2242 (Copilot): the existing-client check moved after discovery, so this
// helper must not turn a well-known outage into a failed reconnect. It reuses
// the discovery state SDK `auth()` persists, and treats a discovery failure as
// "skip pre-registration" rather than an error.
it("reuses persisted discovery state instead of re-fetching", async () => {
storage.getDiscoveryState = vi.fn(async () => ({
authorizationServerUrl: "http://127.0.0.1:9999",
authorizationServerMetadata: {
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,
},
}));
const fetchFn = vi.fn(async () => {
throw new Error("discovery must not run when state is cached");
});

await ensureCimdClientRegistration({
serverUrl: SERVER_URL,
provider: createProvider(storage),
fetchFn,
});

expect(fetchFn).not.toHaveBeenCalled();
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER_URL,
{ client_id: METADATA_URL },
{ registrationKind: "cimd", issuer: "http://127.0.0.1:9999" },
);
});

it("skips pre-registration when discovery fails, rather than throwing", async () => {
const fetchFn = vi.fn(async () => {
throw new Error("well-known endpoint is down");
});

await expect(
ensureCimdClientRegistration({
serverUrl: SERVER_URL,
provider: createProvider(storage),
fetchFn,
}),
).resolves.toBeUndefined();

expect(storage.saveClientInformation).not.toHaveBeenCalled();
});

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" },
);
storage.getClientRegistrationKind = vi.fn(
async (): Promise<"dcr"> => "dcr",
);

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",
);
});
});
50 changes: 47 additions & 3 deletions clients/web/src/test/core/auth/ema/transportProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ interface FakeInner {
clearCapturedAuthUrl: ReturnType<typeof vi.fn>;
saveCodeVerifier: ReturnType<typeof vi.fn>;
codeVerifier: ReturnType<typeof vi.fn>;
saveDiscoveryState: ReturnType<typeof vi.fn>;
discoveryState: ReturnType<typeof vi.fn>;
}

function createInner(): FakeInner {
Expand All @@ -82,6 +84,8 @@ function createInner(): FakeInner {
clearCapturedAuthUrl: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(() => "verifier-xyz"),
saveDiscoveryState: vi.fn(),
discoveryState: vi.fn(),
};
}

Expand Down Expand Up @@ -118,14 +122,54 @@ describe("EmaTransportOAuthProvider", () => {
expect(await provider.codeVerifier()).toBe("verifier-xyz");

await provider.saveClientInformation({ client_id: "new" } as never);
expect(inner.saveClientInformation).toHaveBeenCalledWith({
client_id: "new",
});
expect(inner.saveClientInformation).toHaveBeenCalledWith(
{ client_id: "new" },
undefined,
);

await provider.saveCodeVerifier("cv");
expect(inner.saveCodeVerifier).toHaveBeenCalledWith("cv");
});

// SEP-2352: the wrapper used to drop the SDK's `ctx`, so every EMA read and
// write landed on the unkeyed slot — and, since #2242, the registration-kind
// resolver had no issuer to check and recorded a CIMD registration made over
// an EMA connection as DCR (Copilot).
it("forwards the SDK issuer context on client-information reads and writes", async () => {
const ctx = { issuer: "https://as.example.com" };

await provider.clientInformation(ctx);
expect(inner.clientInformation).toHaveBeenCalledWith(ctx);

await provider.saveClientInformation({ client_id: "new" } as never, ctx);
expect(inner.saveClientInformation).toHaveBeenCalledWith(
{ client_id: "new" },
ctx,
);
});

// Without these the SDK persists no discovery state for an EMA connection, so
// it re-discovers every call, cannot run its callback-leg AS binding check,
// and leaves the registration-kind resolver nothing to read back.
it("delegates discovery state to the inner provider", async () => {
const state = {
authorizationServerUrl: "https://as.example.com",
authorizationServerMetadata: {
issuer: "https://as.example.com",
authorization_endpoint: "https://as.example.com/authorize",
token_endpoint: "https://as.example.com/token",
response_types_supported: ["code"],
},
};

await provider.saveDiscoveryState(state);
expect(inner.saveDiscoveryState).toHaveBeenCalledWith(state);

inner.discoveryState.mockReturnValue(state);
expect(await provider.discoveryState()).toEqual(state);
expect(inner.discoveryState).toHaveBeenCalled();
});

it("tokens() returns stored tokens when the access token is still usable", async () => {
const stored: OAuthTokens = {
access_token: VALID_ACCESS_TOKEN,
Expand Down
Loading