Merge pull request #2037 from modelcontextprotocol/v2/feat/1906-auth-token-url-overrides
feat(auth): Auth URL and Token URL overrides in Server Settings
This commit is contained in:
commit
4e4aa49db1
4 changed files with 1558 additions and 6 deletions
|
|
@ -701,6 +701,8 @@ describe("ServerSettingsForm", () => {
|
|||
clientSecret: "",
|
||||
scopes: "",
|
||||
authorizationParams: [],
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
});
|
||||
});
|
||||
|
|
@ -966,6 +968,104 @@ describe("ServerSettingsForm", () => {
|
|||
expect(screen.getByText(/state, scope are set/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #1906 — the endpoint overrides ride the same `onOAuthChange` callback as
|
||||
// the rest of the Authorization section.
|
||||
it("invokes onOAuthChange when an endpoint override is typed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={emptySettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Authorization URL override/i }),
|
||||
"h",
|
||||
);
|
||||
expect(onOAuthChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authorizationUrl: "h" }),
|
||||
);
|
||||
|
||||
onOAuthChange.mockClear();
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Token URL override/i }),
|
||||
"h",
|
||||
);
|
||||
expect(onOAuthChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tokenUrl: "h" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("says the endpoint overrides are unused under enterprise-managed auth", () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...emptySettings, enterpriseManaged: true }}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
/Not applied while Enterprise-managed authorization is on/,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
|
||||
rerender(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...emptySettings, enterpriseManaged: false }}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/Not applied while Enterprise-managed authorization is on/,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flags an endpoint override that is not an absolute http(s) URL", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{
|
||||
...emptySettings,
|
||||
oauthAuthorizationUrl: "/authorize",
|
||||
oauthTokenUrl: "https://staging.test/token",
|
||||
}}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('"/authorize" is not an absolute URL.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/is not an http\(s\) URL/)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears an endpoint override through its clear button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={{
|
||||
...emptySettings,
|
||||
oauthTokenUrl: "https://staging.test/token",
|
||||
}}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
const clearButtons = screen.getAllByRole("button", { name: /clear/i });
|
||||
await user.click(clearButtons[clearButtons.length - 1]);
|
||||
expect(onOAuthChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tokenUrl: "" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
|
|
@ -1007,6 +1107,8 @@ describe("ServerSettingsForm", () => {
|
|||
clientSecret: "",
|
||||
scopes: "",
|
||||
authorizationParams: [],
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
});
|
||||
|
|
@ -1112,6 +1214,8 @@ describe("ServerSettingsForm", () => {
|
|||
clientSecret: "z",
|
||||
scopes: "",
|
||||
authorizationParams: [],
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
authorizationParamKeyError,
|
||||
isReservedAuthorizationParam,
|
||||
} from "@inspector/core/auth/authorizationParams.js";
|
||||
import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js";
|
||||
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
|
||||
import type { Root } from "@modelcontextprotocol/client";
|
||||
|
||||
|
|
@ -478,11 +479,20 @@ export function ServerSettingsForm({
|
|||
clientSecret: settings.oauthClientSecret ?? "",
|
||||
scopes: settings.oauthScopes ?? "",
|
||||
authorizationParams,
|
||||
authorizationUrl: settings.oauthAuthorizationUrl ?? "",
|
||||
tokenUrl: settings.oauthTokenUrl ?? "",
|
||||
enterpriseManaged: settings.enterpriseManaged ?? false,
|
||||
onInsufficientScope: settings.oauthOnInsufficientScope,
|
||||
};
|
||||
}
|
||||
|
||||
// #1906 — the overrides are suppressed under EMA for the same reason the
|
||||
// custom authorization parameters are: that leg authorizes against the
|
||||
// enterprise IdP, a different authorization server.
|
||||
const endpointOverrideEmaNote = settings.enterpriseManaged
|
||||
? " Not applied while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server."
|
||||
: "";
|
||||
|
||||
const rejectedParamKeys = authorizationParams
|
||||
.map((p) => p.key.trim())
|
||||
.filter((key) => isReservedAuthorizationParam(key));
|
||||
|
|
@ -863,6 +873,55 @@ export function ServerSettingsForm({
|
|||
+ Add Parameter
|
||||
</AddButton>
|
||||
</Stack>
|
||||
<ClearableTextInput
|
||||
label="Authorization URL override"
|
||||
description={`Leave blank to use the authorization_endpoint the authorization server's metadata advertises. Set it to point this server at a development or staging authorization server instead. Endpoints only — the discovered issuer is unchanged, so an authorization server advertising a different issuer is rejected on callback (RFC 9207).${endpointOverrideEmaNote}`}
|
||||
placeholder="https://staging.auth.example.com/authorize"
|
||||
value={settings.oauthAuthorizationUrl ?? ""}
|
||||
error={oauthEndpointUrlError(
|
||||
settings.oauthAuthorizationUrl ?? "",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
authorizationUrl: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSection={
|
||||
settings.oauthAuthorizationUrl ? (
|
||||
<ClearButton
|
||||
onClick={() =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
authorizationUrl: "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<ClearableTextInput
|
||||
label="Token URL override"
|
||||
description={`Leave blank to use the token_endpoint the authorization server's metadata advertises. Independent of the authorization URL — either can be overridden alone.${endpointOverrideEmaNote}`}
|
||||
placeholder="https://staging.auth.example.com/token"
|
||||
value={settings.oauthTokenUrl ?? ""}
|
||||
error={oauthEndpointUrlError(settings.oauthTokenUrl ?? "")}
|
||||
onChange={(e) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
tokenUrl: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSection={
|
||||
settings.oauthTokenUrl ? (
|
||||
<ClearButton
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), tokenUrl: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Insufficient-scope response"
|
||||
description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you."
|
||||
|
|
|
|||
|
|
@ -445,6 +445,52 @@ describe("ServerSettingsModal", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #1906 — a typed endpoint override lands on the settings; clearing it
|
||||
// persists `undefined` rather than an empty string, so "cleared" and "never
|
||||
// set" are the same state on disk.
|
||||
it("persists a typed endpoint override, and clears it back to undefined", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
const { rerender } = renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={emptySettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: /Token URL override/i }),
|
||||
"h",
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ oauthTokenUrl: "h" }),
|
||||
);
|
||||
|
||||
onSettingsChange.mockClear();
|
||||
rerender(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={{
|
||||
...emptySettings,
|
||||
oauthTokenUrl: "https://staging.test/token",
|
||||
}}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
const clearButtons = screen.getAllByRole("button", { name: /clear/i });
|
||||
await user.click(clearButtons[clearButtons.length - 1]);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ oauthTokenUrl: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
// #2018 — the authorization-parameter rows ride the same onOAuthChange
|
||||
// callback, so the modal folds them onto the settings object.
|
||||
it("persists an added authorization-parameter row onto the settings", async () => {
|
||||
|
|
|
|||
|
|
@ -183,6 +183,11 @@ export function ServerSettingsModal({
|
|||
// survives a re-render; the drop-blank/omit-empty filtering happens on the
|
||||
// way to disk. An emptied list persists as `[]`, which writes nothing.
|
||||
oauthAuthorizationParams: oauth.authorizationParams,
|
||||
// #1906: an emptied field persists as undefined rather than `""` — the
|
||||
// read side treats a blank override as "not configured", and keeping the
|
||||
// two in step means clearing the input really clears the setting.
|
||||
oauthAuthorizationUrl: oauth.authorizationUrl || undefined,
|
||||
oauthTokenUrl: oauth.tokenUrl || undefined,
|
||||
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
|
||||
// SEP-2350: persist only the non-default ('throw') so unset servers keep
|
||||
// the SDK's `reauthorize` behavior without writing a spurious field.
|
||||
|
|
|
|||
Loading…
Reference in a new issue