mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
feat(ui): add Authorization URL, Token URL and Scopes fields for OAuth Delegate and True Passthrough MCP servers (#46941)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
6530b329f4
commit
3637dd711f
8 changed files with 163 additions and 12 deletions
|
|
@ -3,9 +3,11 @@ import { Button } from "@/components/ui/button";
|
|||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import DcrBridgeToggle from "./DcrBridgeToggle";
|
||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||
import { textControl } from "./mcpFieldRules";
|
||||
import { tagsControl, textControl } from "./mcpFieldRules";
|
||||
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
|
||||
interface PassthroughOAuthFlow {
|
||||
|
|
@ -69,6 +71,7 @@ export default function PassthroughAuthorizeSection({
|
|||
const clientSecretPlaceholder = classUnchanged
|
||||
? "Leave blank to keep the currently saved secret (if any)"
|
||||
: "Leave blank for public clients / PKCE";
|
||||
const fieldClassName = "rounded-lg border-border focus:border-info focus:ring-ring";
|
||||
const clientIdExtra = classUnchanged
|
||||
? "Set this to make everyone authorize through a specific app; required for upstreams without dynamic client registration (e.g. a pre-registered Slack app)."
|
||||
: "Switching the auth type discards the previously saved app; enter a client ID here or leave blank to use dynamic client registration.";
|
||||
|
|
@ -113,6 +116,39 @@ export default function PassthroughAuthorizeSection({
|
|||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">Authorization URL (optional)</span>}
|
||||
name="authorization_url"
|
||||
help="Set this when the upstream publishes no OAuth metadata, for example an internal server behind Microsoft Entra. Leave blank to discover it from the upstream."
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">Token URL (optional)</span>}
|
||||
name="token_url"
|
||||
help="Token endpoint of the same authorization server. Leave blank to discover it from the upstream."
|
||||
>
|
||||
{(control) => (
|
||||
<Input
|
||||
{...textControl(control)}
|
||||
placeholder="https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token"
|
||||
className={fieldClassName}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<MountedFormField
|
||||
label={<span className="text-sm font-medium text-foreground">Scopes (optional)</span>}
|
||||
name={["credentials", "scopes"]}
|
||||
help="Scopes requested from the upstream when a user authorizes. Press enter or comma between scopes."
|
||||
>
|
||||
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
|
||||
</MountedFormField>
|
||||
<DcrBridgeToggle authType={authType} initialChecked={dcrBridgeInitialChecked} />
|
||||
{isEditing && onRemoveStoredAppChange && (
|
||||
<Label className="items-start leading-normal font-normal text-foreground">
|
||||
|
|
|
|||
|
|
@ -201,6 +201,22 @@ describe("buildCreateServerPayload credentials", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([["true_passthrough"], ["oauth_delegate"]])(
|
||||
"carries the admin-entered authorization URL, token URL and scopes for %s",
|
||||
(authType) => {
|
||||
const entraServer = {
|
||||
auth_type: authType,
|
||||
authorization_url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
|
||||
token_url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
|
||||
credentials: { client_id: "org-app", scopes: ["api://mcp/.default", "offline_access", ""] },
|
||||
};
|
||||
const payload = payloadOf(build(entraServer));
|
||||
expect(payload.authorization_url).toBe("https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize");
|
||||
expect(payload.token_url).toBe("https://login.microsoftonline.com/tenant/oauth2/v2.0/token");
|
||||
expect(payload.credentials).toEqual({ client_id: "org-app", scopes: ["api://mcp/.default", "offline_access"] });
|
||||
},
|
||||
);
|
||||
|
||||
it("merges the DCR-minted client into an oauth2 payload", () => {
|
||||
const payload = payloadOf(
|
||||
build(
|
||||
|
|
|
|||
|
|
@ -295,11 +295,6 @@ export const CASES: readonly DifferentialCase[] = [
|
|||
ui: {},
|
||||
},
|
||||
{ label: "credentials absent entirely", values: { ...ROOT, auth_type: "oauth2" }, ui: {} },
|
||||
{
|
||||
label: "removeStoredApp forces an explicit-null app write",
|
||||
values: { ...ROOT, auth_type: "true_passthrough", credentials: { ...CREDS } },
|
||||
ui: { removeStoredApp: true, mcpServer: { ...SERVER, auth_type: "true_passthrough" } },
|
||||
},
|
||||
{
|
||||
label: "removeStoredApp ignored outside a client-forwarded mode",
|
||||
values: { ...ROOT, auth_type: "api_key", credentials: { ...CREDS } },
|
||||
|
|
|
|||
|
|
@ -398,3 +398,61 @@ it.each([
|
|||
expect(result.payload.credentials?.client_secret).toBe(useDcr ? null : "configured-secret");
|
||||
expect(result.payload.credentials?.dcr_issuer).toBe(useDcr ? "https://new.example" : undefined);
|
||||
});
|
||||
|
||||
it.each([AUTH_TYPE.OAUTH_DELEGATE, AUTH_TYPE.TRUE_PASSTHROUGH])(
|
||||
"keeps the endpoints typed for %s when the server was saved as oauth2",
|
||||
(authType) => {
|
||||
const authorizationUrl = "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize";
|
||||
const tokenUrl = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token";
|
||||
const values: EditServerFormValues = {
|
||||
auth_type: authType,
|
||||
transport: "http",
|
||||
authorization_url: authorizationUrl,
|
||||
token_url: tokenUrl,
|
||||
};
|
||||
const ui: EditServerUiState = { ...baseUi, mcpServer: { ...baseUi.mcpServer, auth_type: AUTH_TYPE.OAUTH2 } };
|
||||
const result = buildEditServerPayload(values, ui);
|
||||
expect(result.kind).toBe("ok");
|
||||
if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
|
||||
expect(result.payload.authorization_url).toBe(authorizationUrl);
|
||||
expect(result.payload.token_url).toBe(tokenUrl);
|
||||
expect(result.payload.issuer).toBeNull();
|
||||
expect(result.payload.registration_url).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([AUTH_TYPE.OAUTH_DELEGATE, AUTH_TYPE.TRUE_PASSTHROUGH])(
|
||||
"writes an explicit null when the admin removes every scope on a %s server",
|
||||
(authType) => {
|
||||
const values: EditServerFormValues = {
|
||||
auth_type: authType,
|
||||
transport: "http",
|
||||
credentials: { client_id: "org-app", scopes: [""] },
|
||||
};
|
||||
const ui: EditServerUiState = { ...baseUi, mcpServer: { ...baseUi.mcpServer, auth_type: authType } };
|
||||
const result = buildEditServerPayload(values, ui);
|
||||
expect(result.kind).toBe("ok");
|
||||
if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
|
||||
expect(result.payload.credentials).toEqual({ client_id: "org-app", scopes: null });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[["read"], ["read"]],
|
||||
[[""], null],
|
||||
])("keeps the scope edit %j when the saved app is removed on a client-forwarded server", (typed, expected) => {
|
||||
const values: EditServerFormValues = {
|
||||
auth_type: AUTH_TYPE.TRUE_PASSTHROUGH,
|
||||
transport: "http",
|
||||
credentials: { client_id: "cid", client_secret: "csec", scopes: typed },
|
||||
};
|
||||
const ui: EditServerUiState = {
|
||||
...baseUi,
|
||||
removeStoredApp: true,
|
||||
mcpServer: { ...baseUi.mcpServer, auth_type: AUTH_TYPE.TRUE_PASSTHROUGH },
|
||||
};
|
||||
const result = buildEditServerPayload(values, ui);
|
||||
expect(result.kind).toBe("ok");
|
||||
if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
|
||||
expect(result.payload.credentials).toEqual({ scopes: expected, client_id: null, client_secret: null });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -214,6 +214,21 @@ const buildCredentials = (credentialValues: unknown): Readonly<Record<string, un
|
|||
return Object.fromEntries(kept);
|
||||
};
|
||||
|
||||
const unmountedOauth2Endpoints = (authType: string | undefined) =>
|
||||
isClientForwardedTokenMode(authType) ? {} : { authorization_url: null, token_url: null };
|
||||
|
||||
const scopesCleared = (credentialValues: unknown): boolean => {
|
||||
if (!credentialValues || typeof credentialValues !== "object") return false;
|
||||
const scopes: unknown = (credentialValues as { scopes?: unknown }).scopes;
|
||||
return Array.isArray(scopes) && scopes.every((scope: unknown) => scope == null || scope === "");
|
||||
};
|
||||
|
||||
const withClearedScopes = (
|
||||
credentials: Readonly<Record<string, unknown>> | undefined,
|
||||
credentialValues: unknown,
|
||||
): Readonly<Record<string, unknown>> | undefined =>
|
||||
scopesCleared(credentialValues) ? { ...(credentials ?? {}), scopes: null } : credentials;
|
||||
|
||||
interface CredentialsEntryInput {
|
||||
readonly dcrClient?: McpDcrCredentials | null;
|
||||
readonly oauthFlow?: string;
|
||||
|
|
@ -239,7 +254,7 @@ const resolveCredentialsEntry = ({
|
|||
return { credentials: { ...credentials, ...dcrClient } };
|
||||
}
|
||||
if (removeStoredApp && isClientForwardedTokenMode(authType)) {
|
||||
return { credentials: { client_id: null, client_secret: null } };
|
||||
return { credentials: { ...(credentials ?? {}), client_id: null, client_secret: null } };
|
||||
}
|
||||
if (includeCredentials && credentials && Object.keys(credentials).length > 0) {
|
||||
return { credentials };
|
||||
|
|
@ -334,7 +349,7 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer
|
|||
// Client-forwarded rows persist ONLY the declared app; strip any token material lingering in the
|
||||
// form (e.g. from a prior oauth2 authorize this session) so it can never reach the row.
|
||||
const submitCredentials = isClientForwardedTokenMode(restValues.auth_type)
|
||||
? preservedAdminCredentials(credentialsPayload)
|
||||
? withClearedScopes(preservedAdminCredentials(credentialsPayload), credentialValues)
|
||||
: credentialsPayload;
|
||||
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
|
||||
|
||||
|
|
@ -355,7 +370,7 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer
|
|||
stdio_config: undefined,
|
||||
env_json: undefined,
|
||||
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2 && restValues.auth_type !== AUTH_TYPE.OAUTH2
|
||||
? { issuer: null, authorization_url: null, token_url: null, registration_url: null }
|
||||
? { issuer: null, ...unmountedOauth2Endpoints(restValues.auth_type), registration_url: null }
|
||||
: {}),
|
||||
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE &&
|
||||
restValues.auth_type !== AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE
|
||||
|
|
|
|||
|
|
@ -264,6 +264,8 @@ const CASES: readonly Case[] = [
|
|||
...EXPECTED_BASE,
|
||||
auth_type: "true_passthrough",
|
||||
credentials: { client_id: "cid", client_secret: "csec" },
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -273,6 +275,8 @@ const CASES: readonly Case[] = [
|
|||
...EXPECTED_BASE,
|
||||
auth_type: "oauth_delegate",
|
||||
credentials: { client_id: "cid", client_secret: "csec" },
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -283,7 +287,13 @@ const CASES: readonly Case[] = [
|
|||
{
|
||||
label: "dcr_bridge stays true for a client-forwarded mode",
|
||||
server: { ...BASE, auth_type: "true_passthrough", dcr_bridge: true },
|
||||
expected: { ...EXPECTED_BASE, auth_type: "true_passthrough", dcr_bridge: true },
|
||||
expected: {
|
||||
...EXPECTED_BASE,
|
||||
auth_type: "true_passthrough",
|
||||
dcr_bridge: true,
|
||||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "an existing allowed_tools list enforces the allowlist and round-trips",
|
||||
|
|
|
|||
|
|
@ -333,6 +333,14 @@ describe("preservedAdminCredentials vs preservedDeclaredAppCredentials", () => {
|
|||
expect(preservedAdminCredentials(credentials)).toEqual({ client_id: "cid", upstream_resource: "auto" });
|
||||
});
|
||||
|
||||
it("preserves admin-typed scopes and drops blank ones", () => {
|
||||
expect(preservedAdminCredentials({ client_id: "cid", scopes: ["api://mcp/.default", ""] })).toEqual({
|
||||
client_id: "cid",
|
||||
scopes: ["api://mcp/.default"],
|
||||
});
|
||||
expect(preservedAdminCredentials({ client_id: "cid", scopes: [] })).toEqual({ client_id: "cid" });
|
||||
});
|
||||
|
||||
// The two helpers answer different questions and must not be collapsed: "has the admin declared an
|
||||
// OAuth app" gates the app-may-not-match-upstream warning, so a resource-only server must read as
|
||||
// having no declared app.
|
||||
|
|
|
|||
|
|
@ -181,10 +181,23 @@ export const preservedDeclaredAppCredentials = (
|
|||
// Everything the admin typed into `credentials` and nothing minted: the declared app plus the config
|
||||
// keys. This is what must survive the invalidation reset and what a client-forwarded row may persist,
|
||||
// so dropping a key from here silently discards admin input on an unrelated edit.
|
||||
const adminScopes = (value: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const kept = value.filter((scope): scope is string => typeof scope === "string" && scope !== "");
|
||||
return kept.length > 0 ? kept : undefined;
|
||||
};
|
||||
|
||||
export const preservedAdminCredentials = (
|
||||
credentials: Record<string, unknown> | null | undefined,
|
||||
): Record<string, string> | undefined =>
|
||||
pickStringCredentials(credentials, [...DECLARED_APP_CREDENTIAL_KEYS, ...ADMIN_CONFIG_CREDENTIAL_KEYS]);
|
||||
): Record<string, string | string[]> | undefined => {
|
||||
const strings = pickStringCredentials(credentials, [
|
||||
...DECLARED_APP_CREDENTIAL_KEYS,
|
||||
...ADMIN_CONFIG_CREDENTIAL_KEYS,
|
||||
]);
|
||||
const scopes = adminScopes(credentials?.scopes);
|
||||
if (scopes === undefined) return strings;
|
||||
return { ...(strings ?? {}), scopes };
|
||||
};
|
||||
|
||||
// Drop minted token keys, keeping everything else (the declared app plus any non-token config).
|
||||
export const withoutMintedTokenCredentials = (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue