fix(oauth): stop claiming the opt-out removes every offline_access
Copilot review round 2 on #2114. The setting was described as guaranteeing "no offline_access, no forced prompt", which is not what the SDK does. `startAuthorization` appends `prompt=consent` for any `offline_access` in the effective scope and never consults `grant_types`, and `determineScope` skips its augmentation when the scope already carries it. So the prompt still goes out when the user configured `offline_access` in Scopes, or left Scopes blank and the resource advertises it in `scopes_supported`. The flag removes the SDK's *automatic* augmentation and nothing more; the docs, the help text and the type say that now. The form also flags the case a user can act on: opted out while `offline_access` is still listed in Scopes. Whole-token match, so `offline_access_extra` does not trip it. Corrects the Clear stored OAuth state claim too. It clears the local tokens and client information — it is not a revocation and cannot reach the AS. A dynamically registered client re-registers on the next connect (the new registration declaring only `authorization_code`); a preconfigured `oauth.clientId` is reused unchanged. Adds the two missing seam tests, both confirmed to fail without the code they cover: App's connect wiring (deleting the spread fails two of the three) and an /api/servers POST followed by a read-back, since a 200 alone never proved the opt-out persisted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
f6fca59624
commit
fca3f1ce67
4 changed files with 230 additions and 4 deletions
|
|
@ -696,6 +696,104 @@ const fetchLogInstances = (
|
|||
FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] }
|
||||
).__fetchLogInstances;
|
||||
|
||||
// #2068 — the web connection seam for the refresh-token opt-out. The provider
|
||||
// and manager tests start from an already-built OAuth config and the runner test
|
||||
// covers only the CLI/TUI leg, so without this case deleting the
|
||||
// `requestRefreshToken` spread from App's `oauthFromServer` would leave the
|
||||
// checkbox persisted but inert with every other new test still green.
|
||||
describe("App wires the refresh-token opt-out into the client (#2068)", () => {
|
||||
const HTTP_SERVER = {
|
||||
id: "A",
|
||||
name: "PlotRocket",
|
||||
config: { type: "streamable-http", url: "https://api.example.com/mcp" },
|
||||
connection: { status: "disconnected" },
|
||||
};
|
||||
|
||||
function mockServersWith(settings?: Partial<InspectorServerSettings>) {
|
||||
vi.mocked(useServers).mockReturnValue({
|
||||
servers: [
|
||||
{
|
||||
...HTTP_SERVER,
|
||||
...(settings ? { settings } : {}),
|
||||
} as unknown as ServerEntry,
|
||||
],
|
||||
loading: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
addServer: addServerSpy,
|
||||
updateServer: updateServerSpy,
|
||||
updateServerSettings: updateServerSettingsSpy,
|
||||
removeServer: vi.fn(),
|
||||
reorderServers: vi.fn(),
|
||||
importSource: vi.fn().mockResolvedValue({ servers: {} }),
|
||||
});
|
||||
}
|
||||
|
||||
/** The `oauth` option the mocked InspectorClient constructor was given. */
|
||||
function constructedOAuth(): { requestRefreshToken?: boolean } | undefined {
|
||||
const call = vi.mocked(McpIndex.InspectorClient).mock.calls[0];
|
||||
const options = call?.[1] as
|
||||
| { oauth?: { requestRefreshToken?: boolean } }
|
||||
| undefined;
|
||||
return options?.oauth;
|
||||
}
|
||||
|
||||
let previousUseServers: typeof useServers | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientInstances.length = 0;
|
||||
previousUseServers = vi.mocked(useServers).getMockImplementation();
|
||||
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousUseServers) {
|
||||
vi.mocked(useServers).mockImplementation(previousUseServers);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes requestRefreshToken: false when the server opted out", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockServersWith({ oauthRequestRefreshToken: false });
|
||||
renderWithMantine(<App />);
|
||||
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
expect(constructedOAuth()?.requestRefreshToken).toBe(false);
|
||||
});
|
||||
|
||||
// The default must not send the key at all — the provider's own default is
|
||||
// what declares the grant, and a stray `true` here would mask its removal.
|
||||
it("omits requestRefreshToken when the setting is on", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockServersWith({ oauthScopes: "openid" });
|
||||
renderWithMantine(<App />);
|
||||
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
const oauth = constructedOAuth();
|
||||
expect(oauth).toBeDefined();
|
||||
expect(oauth).not.toHaveProperty("requestRefreshToken");
|
||||
});
|
||||
|
||||
// The opt-out is the only OAuth field set, so it must be enough on its own to
|
||||
// materialize the `oauth` option — otherwise it is dropped for any server
|
||||
// without credentials or scopes, which is the common case here.
|
||||
it("builds the oauth option from the opt-out alone", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockServersWith({ oauthRequestRefreshToken: false });
|
||||
renderWithMantine(<App />);
|
||||
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
expect(constructedOAuth()).toEqual({ requestRefreshToken: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("App failed-connection card border (#1621)", () => {
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
|
|
|
|||
|
|
@ -751,6 +751,73 @@ describe("ServerSettingsForm", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("warns when opting out while offline_access is still in Scopes", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{
|
||||
...emptySettings,
|
||||
oauthRequestRefreshToken: false,
|
||||
oauthScopes: "openid offline_access",
|
||||
}}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("offline_access is still requested"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not warn when opted out and Scopes omits offline_access", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{
|
||||
...emptySettings,
|
||||
oauthRequestRefreshToken: false,
|
||||
oauthScopes: "openid",
|
||||
}}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("offline_access is still requested"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The scope alone is not a problem — with the grant declared, requesting
|
||||
// offline_access is exactly what the default configuration does.
|
||||
it("does not warn when offline_access is in Scopes but the grant is on", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...emptySettings, oauthScopes: "openid offline_access" }}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("offline_access is still requested"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A substring must not trip it — `offline_access_extra` is a different scope.
|
||||
it("matches offline_access as a whole scope token, not a substring", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{
|
||||
...emptySettings,
|
||||
oauthRequestRefreshToken: false,
|
||||
oauthScopes: "openid offline_access_extra",
|
||||
}}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("offline_access is still requested"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #2018 — custom authorization-request parameters.
|
||||
it("shows the empty hint for authorization parameters when none are set", () => {
|
||||
renderWithMantine(
|
||||
|
|
|
|||
|
|
@ -530,6 +530,15 @@ export function ServerSettingsForm({
|
|||
? " Not applied while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server."
|
||||
: "";
|
||||
|
||||
// #2068 — the opt-out only removes the SDK's *automatic* `offline_access`.
|
||||
// `prompt=consent` keys off the scope, not the grant, so an `offline_access`
|
||||
// the user typed here still forces the prompt the checkbox claims to avoid.
|
||||
// Flag that combination rather than letting it read as a fixed configuration.
|
||||
const scopesIncludeOfflineAccess = (settings.oauthScopes ?? "")
|
||||
.split(/\s+/)
|
||||
.includes("offline_access");
|
||||
const refreshTokenOptedOut = settings.oauthRequestRefreshToken === false;
|
||||
|
||||
const rejectedParamKeys = authorizationParams
|
||||
.map((p) => p.key.trim())
|
||||
.filter((key) => isReservedAuthorizationParam(key));
|
||||
|
|
@ -883,7 +892,7 @@ export function ServerSettingsForm({
|
|||
/>
|
||||
<Checkbox
|
||||
label="Request refresh token"
|
||||
description="When checked, the Inspector registers with the refresh_token grant so it can renew access tokens without a new sign-in. Uncheck it for authorization servers where that grant forces an unwanted consent prompt — the SDK adds offline_access to the requested scope whenever the grant is declared, and then prompt=consent to the authorization request. On Microsoft Entra ID that routes a non-admin user into the admin-consent workflow (AADSTS90094) even after a tenant admin has consented. Applies on the next connect. Unchecking it does not revoke a refresh token already issued, nor the grant already registered at the authorization server — use Clear stored OAuth state below for that."
|
||||
description="When checked, the Inspector registers with the refresh_token grant so it can renew access tokens without a new sign-in. Unchecking it stops the SDK adding offline_access to the requested scope, and so stops the prompt=consent it forces — on Microsoft Entra ID that prompt routes a non-admin user into the admin-consent workflow (AADSTS90094) even after a tenant admin has consented. It removes only that automatic scope: an offline_access you configure in Scopes still forces the prompt. Applies on the next connect, and does not revoke a refresh token already issued or reach a registration the authorization server already holds."
|
||||
checked={settings.oauthRequestRefreshToken ?? true}
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
|
|
@ -892,6 +901,14 @@ export function ServerSettingsForm({
|
|||
})
|
||||
}
|
||||
/>
|
||||
{refreshTokenOptedOut && scopesIncludeOfflineAccess ? (
|
||||
<Alert color="yellow" title="offline_access is still requested">
|
||||
Scopes above lists offline_access, and prompt=consent is added
|
||||
whenever that scope is requested — regardless of this setting.
|
||||
Remove it from Scopes as well, or the consent prompt this
|
||||
checkbox is meant to avoid still goes out.
|
||||
</Alert>
|
||||
) : null}
|
||||
<Stack gap="xs">
|
||||
<FieldLabel>Additional authorization parameters</FieldLabel>
|
||||
<FieldDescription>
|
||||
|
|
|
|||
|
|
@ -675,6 +675,39 @@ describe("server.ts supplemental coverage", () => {
|
|||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
// #2068 — a 200 only proves the payload validated. Without reading the
|
||||
// saved entry back, deleting the `oauthRequestRefreshToken` line from
|
||||
// `normalizeSettings` leaves every other test green while saves through
|
||||
// this route silently revert to the default.
|
||||
it("persists the refresh-token opt-out through a save", async () => {
|
||||
expect(
|
||||
(await postSettings({ ...base, oauthRequestRefreshToken: false }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||
const body = (await res.json()) as {
|
||||
mcpServers: Record<
|
||||
string,
|
||||
{ oauth?: { requestRefreshToken?: boolean } }
|
||||
>;
|
||||
};
|
||||
expect(body.mcpServers.srv?.oauth?.requestRefreshToken).toBe(false);
|
||||
});
|
||||
|
||||
it("writes no refresh-token field when the setting is on", async () => {
|
||||
expect(
|
||||
(await postSettings({ ...base, oauthRequestRefreshToken: true }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||
const body = (await res.json()) as {
|
||||
mcpServers: Record<string, { oauth?: Record<string, unknown> }>;
|
||||
};
|
||||
expect(body.mcpServers.srv?.oauth?.requestRefreshToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a fully-populated valid settings payload", async () => {
|
||||
const res = await postSettings({
|
||||
...base,
|
||||
|
|
|
|||
Loading…
Reference in a new issue