feat(sso): add GENERIC_AUTHORIZATION_PARAMS for extra authorize query params

Generic OIDC providers such as AD FS only attach a Web API's issuance rules (email, first_name, last_name, display_name) when the authorization request names that API through a resource parameter; LiteLLM sent none, so AD FS fell back to urn:microsoft:userinfo and every GENERIC_USER_*_ATTRIBUTE resolved to None. GENERIC_AUTHORIZATION_PARAMS takes a query string (resource=https://litellm.example.com/api) that is parsed with parse_qsl, filtered of the keys the OAuth flow sets itself, and passed to fastapi_sso get_login_redirect(params=...). The setting is exposed through the SSO config resolver, the admin UI SSO form and the generated dashboard schema, with redirect-URL, state, PKCE, parser and form-submit tests.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-10-02 01:42:43 +00:00
parent 2b51f2f941
commit 95f78a0072
13 changed files with 242 additions and 4 deletions

View file

@ -37,6 +37,7 @@ SSO_DESCRIPTORS: Final[tuple[FieldDescriptor, ...]] = (
FieldDescriptor("generic_token_endpoint", "generic_token_endpoint", "GENERIC_TOKEN_ENDPOINT"),
FieldDescriptor("generic_userinfo_endpoint", "generic_userinfo_endpoint", "GENERIC_USERINFO_ENDPOINT"),
FieldDescriptor("generic_scope", "generic_scope", "GENERIC_SCOPE", default="openid email profile"),
FieldDescriptor("generic_authorization_params", "generic_authorization_params", "GENERIC_AUTHORIZATION_PARAMS"),
FieldDescriptor("saml_idp_metadata_url", "saml_idp_metadata_url", "SAML_IDP_METADATA_URL"),
FieldDescriptor("saml_idp_metadata_xml", "saml_idp_metadata_xml", "SAML_IDP_METADATA_XML"),
FieldDescriptor("saml_sp_entity_id", "saml_sp_entity_id", "SAML_SP_ENTITY_ID"),

View file

@ -33,7 +33,7 @@ from typing import (
cast,
overload,
)
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.parse import parse_qs, parse_qsl, urlencode, urlparse
if TYPE_CHECKING:
import httpx
@ -1414,6 +1414,21 @@ def _parse_generic_sso_headers() -> dict[str, str]:
return result
_GENERIC_SSO_RESERVED_AUTHORIZE_PARAMS: Final = frozenset(
{"client_id", "redirect_uri", "response_type", "scope", "state", "code_challenge", "code_challenge_method"}
)
def _parse_generic_sso_authorization_params() -> dict[str, str]:
"""Parse the query-string GENERIC_AUTHORIZATION_PARAMS env var (``resource=https://api.example.com``) into
extra parameters for the authorization request. Keys the OAuth flow sets itself are ignored."""
raw: Final = os.getenv("GENERIC_AUTHORIZATION_PARAMS", "")
pairs: Final = parse_qsl(raw.strip(), keep_blank_values=False)
for key in sorted({key for key, _ in pairs if key in _GENERIC_SSO_RESERVED_AUTHORIZE_PARAMS}):
verbose_proxy_logger.warning("Ignoring GENERIC_AUTHORIZATION_PARAMS key %s, the SSO flow sets it", key)
return {key: value for key, value in pairs if key not in _GENERIC_SSO_RESERVED_AUTHORIZE_PARAMS}
def _handle_generic_sso_error(
e: Exception,
generic_authorization_endpoint: str | None,
@ -3099,7 +3114,9 @@ class SSOAuthenticationHandler:
state_only_params[key] = value
# Get the redirect response from fastapi-sso with only state param
redirect_response: Final = await generic_sso.get_login_redirect(**state_only_params)
redirect_response: Final = await generic_sso.get_login_redirect(
params=_parse_generic_sso_authorization_params(), **state_only_params
)
# If PKCE is enabled, add PKCE parameters to the redirect URL
if code_verifier and "state" in redirect_params:

View file

@ -152,6 +152,13 @@ class SSOConfig(LiteLLMPydanticObjectBase):
default=None,
description="Space-separated OAuth scopes requested from the generic provider, e.g. 'openid email profile'",
)
generic_authorization_params: str | None = Field(
default=None,
description=(
"Extra query parameters for the generic provider's authorization request, in query-string form, "
"e.g. 'resource=https://litellm.example.com' so AD FS issues tokens for that Web API"
),
)
# SAML SSO
saml_idp_metadata_url: str | None = Field(

View file

@ -58,6 +58,7 @@ def test_sso_descriptor_mapping_is_single_sourced():
# The write path and read path both consume this mapping; it must cover every
# env-backed SSO field and map to the uppercase env var.
assert SSO_FIELD_ENV_VARS["generic_client_id"] == "GENERIC_CLIENT_ID"
assert SSO_FIELD_ENV_VARS["generic_authorization_params"] == "GENERIC_AUTHORIZATION_PARAMS"
assert SSO_SECRET_FIELDS == frozenset(
{"google_client_secret", "microsoft_client_secret", "generic_client_secret"}
)

View file

@ -5,6 +5,7 @@ import os
from contextlib import ExitStack, asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
@ -4730,6 +4731,132 @@ class TestGenericResponseConvertorUserRole:
assert result.user_role is None
class TestGenericSSOAuthorizationParams:
"""GENERIC_AUTHORIZATION_PARAMS adds extra query parameters (AD FS ``resource``) to the authorize redirect."""
@staticmethod
def _generic_sso(scope: list[str]) -> object:
from fastapi_sso.sso.generic import create_provider
provider = create_provider(
name="oidc",
discovery_document={
"authorization_endpoint": "https://idp.example.com/adfs/oauth2/authorize",
"token_endpoint": "https://idp.example.com/adfs/oauth2/token",
"userinfo_endpoint": "https://idp.example.com/adfs/userinfo",
},
)
return provider(
client_id="litellm-client",
client_secret="secret",
redirect_uri="http://localhost:4000/sso/callback",
allow_insecure_http=True,
scope=scope,
)
def test_parse_is_empty_when_unset(self, monkeypatch):
from litellm.proxy.management_endpoints.ui_sso import (
_parse_generic_sso_authorization_params,
)
monkeypatch.delenv("GENERIC_AUTHORIZATION_PARAMS", raising=False)
assert _parse_generic_sso_authorization_params() == {}
monkeypatch.setenv("GENERIC_AUTHORIZATION_PARAMS", " ")
assert _parse_generic_sso_authorization_params() == {}
def test_parse_reads_query_string_form(self, monkeypatch):
from litellm.proxy.management_endpoints.ui_sso import (
_parse_generic_sso_authorization_params,
)
monkeypatch.setenv(
"GENERIC_AUTHORIZATION_PARAMS",
"resource=https://litellm.example.com/api&prompt=login",
)
assert _parse_generic_sso_authorization_params() == {
"resource": "https://litellm.example.com/api",
"prompt": "login",
}
def test_parse_drops_keys_the_flow_sets_itself(self, monkeypatch):
from litellm.proxy.management_endpoints.ui_sso import (
_parse_generic_sso_authorization_params,
)
monkeypatch.setenv(
"GENERIC_AUTHORIZATION_PARAMS",
"resource=https://litellm.example.com/api&state=attacker&redirect_uri=https://evil.example"
"&client_id=other&scope=openid&response_type=token&code_challenge=x&code_challenge_method=plain",
)
assert _parse_generic_sso_authorization_params() == {"resource": "https://litellm.example.com/api"}
@pytest.mark.asyncio
async def test_redirect_location_carries_resource(self, monkeypatch):
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
monkeypatch.delenv("GENERIC_CLIENT_USE_PKCE", raising=False)
monkeypatch.setenv("GENERIC_AUTHORIZATION_PARAMS", "resource=https://litellm.example.com/api")
response = await SSOAuthenticationHandler.get_generic_sso_redirect_response(
generic_sso=self._generic_sso(["openid", "profile", "email", "allatclaims"]),
state="cli-state-123",
generic_authorization_endpoint="https://idp.example.com/adfs/oauth2/authorize",
)
assert response is not None
location = urlparse(str(response.headers["location"]))
query = parse_qs(location.query)
assert location.path == "/adfs/oauth2/authorize"
assert query["resource"] == ["https://litellm.example.com/api"]
assert query["state"] == ["cli-state-123"]
assert query["client_id"] == ["litellm-client"]
assert query["redirect_uri"] == ["http://localhost:4000/sso/callback"]
assert query["scope"] == ["openid profile email allatclaims"]
assert query["response_type"] == ["code"]
@pytest.mark.asyncio
async def test_redirect_location_unchanged_when_unset(self, monkeypatch):
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
monkeypatch.delenv("GENERIC_CLIENT_USE_PKCE", raising=False)
monkeypatch.delenv("GENERIC_AUTHORIZATION_PARAMS", raising=False)
response = await SSOAuthenticationHandler.get_generic_sso_redirect_response(
generic_sso=self._generic_sso(["openid"]),
state="cli-state-123",
generic_authorization_endpoint="https://idp.example.com/adfs/oauth2/authorize",
)
assert response is not None
query = parse_qs(urlparse(str(response.headers["location"])).query)
assert "resource" not in query
assert set(query) == {"response_type", "client_id", "redirect_uri", "scope", "state"}
@pytest.mark.asyncio
async def test_redirect_location_keeps_pkce_alongside_resource(self, monkeypatch):
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
monkeypatch.setenv("GENERIC_CLIENT_USE_PKCE", "true")
monkeypatch.setenv("GENERIC_AUTHORIZATION_PARAMS", "resource=https://litellm.example.com/api")
mock_cache = MagicMock(redis_cache=None)
mock_cache.async_set_cache = AsyncMock()
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
):
response = await SSOAuthenticationHandler.get_generic_sso_redirect_response(
generic_sso=self._generic_sso(["openid"]),
state="cli-state-123",
generic_authorization_endpoint="https://idp.example.com/adfs/oauth2/authorize",
)
assert response is not None
query = parse_qs(urlparse(str(response.headers["location"])).query)
assert query["resource"] == ["https://litellm.example.com/api"]
assert query["code_challenge_method"] == ["S256"]
assert len(query["code_challenge"]) == 1
assert query["state"] == ["cli-state-123"]
mock_cache.async_set_cache.assert_called_once()
class TestGetGenericSSORedirectParams:
"""Test _get_generic_sso_redirect_params state parameter priority handling"""

View file

@ -29,6 +29,7 @@ export interface SSOSettingsValues {
saml_sp_entity_id: string | null;
saml_allow_unsolicited: string | null;
generic_scope: string | null;
generic_authorization_params: string | null;
proxy_base_url: string | null;
user_email: string | null;
ui_access_mode: string | null;

View file

@ -531,6 +531,7 @@ describe("SSOModals", () => {
saml_sp_entity_id: null,
saml_allow_unsolicited: null,
generic_scope: null,
generic_authorization_params: null,
proxy_base_url: null,
user_email: null,
sso_provider: null,

View file

@ -108,6 +108,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
generic_token_endpoint: ssoData.values.generic_token_endpoint,
generic_userinfo_endpoint: ssoData.values.generic_userinfo_endpoint,
generic_scope: ssoData.values.generic_scope,
generic_authorization_params: ssoData.values.generic_authorization_params,
saml_idp_metadata_url: ssoData.values.saml_idp_metadata_url,
saml_idp_metadata_xml: ssoData.values.saml_idp_metadata_xml,
saml_sp_entity_id: ssoData.values.saml_sp_entity_id,
@ -220,6 +221,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
saml_sp_entity_id: null,
saml_allow_unsolicited: null,
generic_scope: null,
generic_authorization_params: null,
proxy_base_url: null,
user_email: null,
sso_provider: null,

View file

@ -289,13 +289,13 @@ describe("renderProviderFields", () => {
it("should return fields for okta provider", () => {
const result = renderProviderFields("okta");
expect(result).not.toBeNull();
expect(result?.length).toBe(6);
expect(result?.length).toBe(7);
});
it("should return fields for generic provider", () => {
const result = renderProviderFields("generic");
expect(result).not.toBeNull();
expect(result?.length).toBe(6);
expect(result?.length).toBe(7);
});
it.each(["okta", "generic"])(
@ -308,6 +308,16 @@ describe("renderProviderFields", () => {
},
);
it.each(["okta", "generic"])(
"renders an optional generic_authorization_params field for %s mapped to GENERIC_AUTHORIZATION_PARAMS",
(provider) => {
const field = ssoProviderConfigs[provider].fields.find((f) => f.name === "generic_authorization_params");
expect(field).toBeDefined();
expect(field?.required).toBe(false);
expect(ssoProviderConfigs[provider].envVarMap.generic_authorization_params).toBe("GENERIC_AUTHORIZATION_PARAMS");
},
);
it("submits generic_scope untouched, so saving an unrelated edit cannot clear GENERIC_SCOPE", async () => {
// update_sso_settings clears the env var for any mapped field its payload
// omits, and antd only submits mounted fields. So the Scopes field being
@ -356,6 +366,48 @@ describe("renderProviderFields", () => {
});
});
it("submits generic_authorization_params untouched, so saving an unrelated edit cannot clear GENERIC_AUTHORIZATION_PARAMS", async () => {
const handleSubmit = vi.fn();
let form!: ReturnType<typeof useSSOSettingsForm>;
const TestWrapper = () => {
const formInstance = useSSOSettingsForm("sso-settings");
form = formInstance;
return <BaseSSOSettingsForm form={formInstance} onFormSubmit={handleSubmit} />;
};
renderWithProviders(<TestWrapper />);
const savedValues = {
...emptySSOSettingsFormValues,
sso_provider: "generic",
generic_client_id: "client-id",
generic_client_secret: "client-secret",
generic_authorization_endpoint: "https://idp.example.com/authorize",
generic_token_endpoint: "https://idp.example.com/token",
generic_userinfo_endpoint: "https://idp.example.com/userinfo",
generic_authorization_params: "resource=https://litellm.example.com/api",
proxy_base_url: "https://gateway.example.com",
user_email: "admin@example.com",
};
await act(async () => {
form.reset(savedValues);
});
await act(async () => {
form.setValue("generic_token_endpoint", "https://idp.example.com/token/v2");
submitMountedSSOValues(form, "sso-settings", handleSubmit)();
});
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining({
generic_token_endpoint: "https://idp.example.com/token/v2",
generic_authorization_params: "resource=https://litellm.example.com/api",
}),
);
});
});
it("renders provider logos in the dropdown and falls back to a letter avatar on load error", async () => {
const TestWrapper = () => {
const form = useSSOSettingsForm("sso-settings");

View file

@ -27,6 +27,7 @@ export interface SSOSettingsFormValues {
generic_token_endpoint?: string;
generic_userinfo_endpoint?: string;
generic_scope?: string;
generic_authorization_params?: string;
saml_idp_metadata_url?: string;
saml_idp_metadata_xml?: string;
saml_sp_entity_id?: string;
@ -91,6 +92,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
generic_scope: "GENERIC_SCOPE",
generic_authorization_params: "GENERIC_AUTHORIZATION_PARAMS",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
@ -107,6 +109,12 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
placeholder: "https://your-domain/userinfo",
},
{ label: "Scopes", name: "generic_scope", placeholder: "openid email profile", required: false },
{
label: "Extra Authorization Params",
name: "generic_authorization_params",
placeholder: "resource=https://your-api-identifier",
required: false,
},
],
},
generic: {
@ -117,6 +125,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
generic_scope: "GENERIC_SCOPE",
generic_authorization_params: "GENERIC_AUTHORIZATION_PARAMS",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
@ -125,6 +134,12 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
{ label: "Token Endpoint", name: "generic_token_endpoint" },
{ label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" },
{ label: "Scopes", name: "generic_scope", placeholder: "openid email profile", required: false },
{
label: "Extra Authorization Params",
name: "generic_authorization_params",
placeholder: "resource=https://your-api-identifier",
required: false,
},
],
},
saml: {

View file

@ -45,6 +45,7 @@ export const toSSOFormValues = (values: SSOSettingsValues): SSOSettingsFormValue
generic_token_endpoint: values.generic_token_endpoint ?? "",
generic_userinfo_endpoint: values.generic_userinfo_endpoint ?? "",
generic_scope: values.generic_scope ?? undefined,
generic_authorization_params: values.generic_authorization_params ?? undefined,
saml_idp_metadata_url: values.saml_idp_metadata_url ?? undefined,
saml_idp_metadata_xml: values.saml_idp_metadata_xml ?? undefined,
saml_sp_entity_id: values.saml_sp_entity_id ?? undefined,

View file

@ -130,6 +130,10 @@ export default function SSOSettings() {
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_userinfo_endpoint} />,
},
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
{
label: "Extra Authorization Params",
render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_authorization_params),
},
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
isTeamMappingsEnabled
? { label: "Team IDs JWT Field", render: (values: SSOSettingsValues) => renderTeamMappingsField(values) }
@ -160,6 +164,10 @@ export default function SSOSettings() {
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_userinfo_endpoint} />,
},
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
{
label: "Extra Authorization Params",
render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_authorization_params),
},
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
isTeamMappingsEnabled
? { label: "Team IDs JWT Field", render: (values: SSOSettingsValues) => renderTeamMappingsField(values) }

View file

@ -44246,6 +44246,11 @@ export interface components {
* @description Authorization endpoint URL for generic OAuth provider
*/
generic_authorization_endpoint?: string | null;
/**
* Generic Authorization Params
* @description Extra query parameters for the generic provider's authorization request, in query-string form, e.g. 'resource=https://litellm.example.com' so AD FS issues tokens for that Web API
*/
generic_authorization_params?: string | null;
/**
* Generic Client Id
* @description Generic OAuth Client ID for SSO authentication (used for Okta and other providers)