mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +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 { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||||
import DcrBridgeToggle from "./DcrBridgeToggle";
|
import DcrBridgeToggle from "./DcrBridgeToggle";
|
||||||
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
import { MountedFormField } from "@/components/common_components/MountedFormField";
|
||||||
import { textControl } from "./mcpFieldRules";
|
import { tagsControl, textControl } from "./mcpFieldRules";
|
||||||
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||||
|
|
||||||
interface PassthroughOAuthFlow {
|
interface PassthroughOAuthFlow {
|
||||||
|
|
@ -69,6 +71,7 @@ export default function PassthroughAuthorizeSection({
|
||||||
const clientSecretPlaceholder = classUnchanged
|
const clientSecretPlaceholder = classUnchanged
|
||||||
? "Leave blank to keep the currently saved secret (if any)"
|
? "Leave blank to keep the currently saved secret (if any)"
|
||||||
: "Leave blank for public clients / PKCE";
|
: "Leave blank for public clients / PKCE";
|
||||||
|
const fieldClassName = "rounded-lg border-border focus:border-info focus:ring-ring";
|
||||||
const clientIdExtra = classUnchanged
|
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)."
|
? "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.";
|
: "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>
|
||||||
|
<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} />
|
<DcrBridgeToggle authType={authType} initialChecked={dcrBridgeInitialChecked} />
|
||||||
{isEditing && onRemoveStoredAppChange && (
|
{isEditing && onRemoveStoredAppChange && (
|
||||||
<Label className="items-start leading-normal font-normal text-foreground">
|
<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", () => {
|
it("merges the DCR-minted client into an oauth2 payload", () => {
|
||||||
const payload = payloadOf(
|
const payload = payloadOf(
|
||||||
build(
|
build(
|
||||||
|
|
|
||||||
|
|
@ -295,11 +295,6 @@ export const CASES: readonly DifferentialCase[] = [
|
||||||
ui: {},
|
ui: {},
|
||||||
},
|
},
|
||||||
{ label: "credentials absent entirely", values: { ...ROOT, auth_type: "oauth2" }, 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",
|
label: "removeStoredApp ignored outside a client-forwarded mode",
|
||||||
values: { ...ROOT, auth_type: "api_key", credentials: { ...CREDS } },
|
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?.client_secret).toBe(useDcr ? null : "configured-secret");
|
||||||
expect(result.payload.credentials?.dcr_issuer).toBe(useDcr ? "https://new.example" : undefined);
|
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);
|
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 {
|
interface CredentialsEntryInput {
|
||||||
readonly dcrClient?: McpDcrCredentials | null;
|
readonly dcrClient?: McpDcrCredentials | null;
|
||||||
readonly oauthFlow?: string;
|
readonly oauthFlow?: string;
|
||||||
|
|
@ -239,7 +254,7 @@ const resolveCredentialsEntry = ({
|
||||||
return { credentials: { ...credentials, ...dcrClient } };
|
return { credentials: { ...credentials, ...dcrClient } };
|
||||||
}
|
}
|
||||||
if (removeStoredApp && isClientForwardedTokenMode(authType)) {
|
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) {
|
if (includeCredentials && credentials && Object.keys(credentials).length > 0) {
|
||||||
return { credentials };
|
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
|
// 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.
|
// form (e.g. from a prior oauth2 authorize this session) so it can never reach the row.
|
||||||
const submitCredentials = isClientForwardedTokenMode(restValues.auth_type)
|
const submitCredentials = isClientForwardedTokenMode(restValues.auth_type)
|
||||||
? preservedAdminCredentials(credentialsPayload)
|
? withClearedScopes(preservedAdminCredentials(credentialsPayload), credentialValues)
|
||||||
: credentialsPayload;
|
: credentialsPayload;
|
||||||
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
|
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,
|
stdio_config: undefined,
|
||||||
env_json: undefined,
|
env_json: undefined,
|
||||||
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2 && restValues.auth_type !== AUTH_TYPE.OAUTH2
|
...(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 &&
|
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE &&
|
||||||
restValues.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,
|
...EXPECTED_BASE,
|
||||||
auth_type: "true_passthrough",
|
auth_type: "true_passthrough",
|
||||||
credentials: { client_id: "cid", client_secret: "csec" },
|
credentials: { client_id: "cid", client_secret: "csec" },
|
||||||
|
authorization_url: undefined,
|
||||||
|
token_url: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -273,6 +275,8 @@ const CASES: readonly Case[] = [
|
||||||
...EXPECTED_BASE,
|
...EXPECTED_BASE,
|
||||||
auth_type: "oauth_delegate",
|
auth_type: "oauth_delegate",
|
||||||
credentials: { client_id: "cid", client_secret: "csec" },
|
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",
|
label: "dcr_bridge stays true for a client-forwarded mode",
|
||||||
server: { ...BASE, auth_type: "true_passthrough", dcr_bridge: true },
|
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",
|
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" });
|
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
|
// 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
|
// OAuth app" gates the app-may-not-match-upstream warning, so a resource-only server must read as
|
||||||
// having no declared app.
|
// 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
|
// 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,
|
// 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.
|
// 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 = (
|
export const preservedAdminCredentials = (
|
||||||
credentials: Record<string, unknown> | null | undefined,
|
credentials: Record<string, unknown> | null | undefined,
|
||||||
): Record<string, string> | undefined =>
|
): Record<string, string | string[]> | undefined => {
|
||||||
pickStringCredentials(credentials, [...DECLARED_APP_CREDENTIAL_KEYS, ...ADMIN_CONFIG_CREDENTIAL_KEYS]);
|
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).
|
// Drop minted token keys, keeping everything else (the declared app plus any non-token config).
|
||||||
export const withoutMintedTokenCredentials = (
|
export const withoutMintedTokenCredentials = (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue