feat(auth): per-server authorization and token URL overrides
Adds two optional per-server fields — `oauth.authorizationUrl` and `oauth.tokenUrl` — that override the endpoints authorization-server metadata discovery resolved, so a server can be pointed at a development or staging authorization server without changing what it advertises. SDK v2 routes both endpoints through the discovered metadata document and neither through OAuthClientProvider, so the override is applied by wrapping the client's base fetch and patching the metadata document in flight (core/auth/endpointOverrides.ts). Wrapping the base fetch — rather than only the auth fetch — also covers the discovery the SDK runs from inside the transport on the 401/refresh path. The fields round-trip through mcp.json, the /api/servers routes, the runner (CLI/TUI), and the Server Settings → Authorization panel, where a value that is not an absolute http(s) URL is flagged inline; at runtime such a value is dropped with a single warning rather than failing the connection. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
6bc355df63
commit
364d8c9dbc
2 changed files with 1212 additions and 6 deletions
|
|
@ -701,6 +701,8 @@ describe("ServerSettingsForm", () => {
|
|||
clientSecret: "",
|
||||
scopes: "",
|
||||
authorizationParams: [],
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
enterpriseManaged: false,
|
||||
});
|
||||
});
|
||||
|
|
@ -966,6 +968,76 @@ 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("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 +1079,8 @@ describe("ServerSettingsForm", () => {
|
|||
clientSecret: "",
|
||||
scopes: "",
|
||||
authorizationParams: [],
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
});
|
||||
|
|
@ -1112,6 +1186,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,6 +479,8 @@ export function ServerSettingsForm({
|
|||
clientSecret: settings.oauthClientSecret ?? "",
|
||||
scopes: settings.oauthScopes ?? "",
|
||||
authorizationParams,
|
||||
authorizationUrl: settings.oauthAuthorizationUrl ?? "",
|
||||
tokenUrl: settings.oauthTokenUrl ?? "",
|
||||
enterpriseManaged: settings.enterpriseManaged ?? false,
|
||||
onInsufficientScope: settings.oauthOnInsufficientScope,
|
||||
};
|
||||
|
|
@ -863,6 +866,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."
|
||||
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."
|
||||
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."
|
||||
|
|
|
|||
Loading…
Reference in a new issue