fix(ui): resolve SSO and SMTP settings from a typed config object (#33576)

The SSO and Email Server settings pages read only stored config, so a gateway
configured entirely through environment variables rendered every field blank
even though both features were live. Rather than add per-endpoint env fallback,
resolve each setting through one typed config object.

A FieldDescriptor names, for one setting, where it lives in the stored row
(db_key), which process env var carries it (env_var), whether it is a secret,
and its effective default. A pure resolve_fields reconciles a descriptor table
against the stored row and the process environment with a fixed precedence and
reports per-field provenance (db, env, default, or unset). The SSO descriptor
table single-sources the field-to-env mapping that the read and write paths
previously duplicated, so they can no longer drift.

get_sso_settings and the /get/config/callbacks alerting block read through the
resolver instead of their own inline fallbacks. get_sso_settings no longer
decrypts stored values into os.environ; decryption happens once inside the
resolver via the pure helper, so a GET stops mutating the process environment.
The SSO response carries provenance so the UI can distinguish an env-sourced
value from a stored one, and secrets are masked at the endpoint (the resolver
returns them unmasked so the login path could consume them). os.environ remains
the runtime carrier; the SSO login and mail-send paths are unchanged.

The settings pages also submit only fields an admin actually edited, so a
rendered mask or env-sourced value is never written back over a working
secret, and generic_scope is a real SSO form field. Omitting a field from
/update/sso_settings clears it, which provider switching relies on; the deeper
write-path concern that behaviour points at is tracked in LIT-4498.
This commit is contained in:
tin-berri 2026-07-22 14:47:35 -07:00 committed by GitHub
parent c6b2f111a6
commit 9baea68f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 693 additions and 217 deletions

View file

@ -46,6 +46,7 @@ jobs:
tests/test_litellm/proxy/rag_endpoints
tests/test_litellm/proxy/realtime_endpoints
tests/test_litellm/proxy/ui_crud_endpoints
tests/test_litellm/proxy/config_resolvers
tests/test_litellm/proxy/utils
workers: 2
reruns: 2

View file

@ -0,0 +1,9 @@
"""Typed, provenance-aware resolution of proxy settings from DB then env."""
from litellm.proxy.config_resolvers._descriptors import (
FieldDescriptor,
FieldSource,
resolve_fields,
)
__all__ = ["FieldDescriptor", "FieldSource", "resolve_fields"]

View file

@ -0,0 +1,73 @@
"""Shared primitive for resolving a settings value from its sources.
A ``FieldDescriptor`` names, for one setting, where it lives in the stored DB
row (``db_key``), which process env var carries it (``env_var``), whether it is
a secret, and its effective default. ``resolve_fields`` reconciles a set of
descriptors against a decrypted DB row and the process environment with a fixed
precedence, returning the resolved values plus per-field provenance so a caller
can tell whether a value came from the database, the environment, a default, or
is unset.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Literal
FieldSource = Literal["db", "env", "default", "unset"]
@dataclass(frozen=True, slots=True)
class FieldDescriptor:
field_name: str
db_key: str
env_var: str
is_secret: bool = False
default: str | None = None
def _db_is_set(db_value: object, empty_db_is_set: bool) -> bool:
if empty_db_is_set:
# A stored key that is present, even as "", is an explicit admin choice
# (e.g. clearing an alerting webhook) and must win over a stale env var.
return db_value is not None
# A blank stored value is treated as absent, so it falls through to env. This
# fits settings whose clear path also unsets the env var (e.g. SSO).
return isinstance(db_value, str) and bool(db_value.strip())
def _resolve_one(
descriptor: FieldDescriptor,
db_values: Mapping[str, object],
env: Mapping[str, str],
empty_db_is_set: bool,
) -> tuple[str, str | None, FieldSource]:
db_value = db_values.get(descriptor.db_key)
if _db_is_set(db_value, empty_db_is_set):
return descriptor.field_name, db_value if isinstance(db_value, str) else str(db_value), "db"
env_value = env.get(descriptor.env_var)
if isinstance(env_value, str) and env_value.strip():
return descriptor.field_name, env_value, "env"
if descriptor.default is not None:
return descriptor.field_name, descriptor.default, "default"
return descriptor.field_name, None, "unset"
def resolve_fields(
descriptors: Sequence[FieldDescriptor],
db_values: Mapping[str, object],
env: Mapping[str, str],
empty_db_is_set: bool = False,
) -> tuple[dict[str, str | None], dict[str, FieldSource]]:
"""Resolve every descriptor to (values, provenance).
Precedence per field: a set stored value wins, else a non-blank process env
var, else the descriptor default, else unset. ``empty_db_is_set`` selects
how a present-but-empty stored value is read: ``False`` treats it as absent
so it falls back to env (SSO, whose clear path also unsets the env var);
``True`` treats it as an explicit clear that wins over env (alerting, whose
clear path stores "" without unsetting the env var).
"""
resolved = tuple(_resolve_one(descriptor, db_values, env, empty_db_is_set) for descriptor in descriptors)
values = {field_name: value for field_name, value, _ in resolved}
provenance = {field_name: source for field_name, _, source in resolved}
return values, provenance

View file

@ -0,0 +1,25 @@
"""Descriptor tables for the alerting settings surfaced by /get/config/callbacks.
These reconcile the stored ``environment_variables`` blob (keyed by the
uppercase env-var names) with the process environment. SMTP_PORT and SMTP_TLS
carry the same effective defaults the mail-send path applies, so the settings
page shows the config that mail would actually use rather than a blank.
"""
from litellm.proxy.config_resolvers._descriptors import FieldDescriptor
EMAIL_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
FieldDescriptor("SMTP_HOST", "SMTP_HOST", "SMTP_HOST"),
FieldDescriptor("SMTP_PORT", "SMTP_PORT", "SMTP_PORT", default="587"),
FieldDescriptor("SMTP_TLS", "SMTP_TLS", "SMTP_TLS", default="True"),
FieldDescriptor("SMTP_USERNAME", "SMTP_USERNAME", "SMTP_USERNAME", is_secret=True),
FieldDescriptor("SMTP_PASSWORD", "SMTP_PASSWORD", "SMTP_PASSWORD", is_secret=True),
FieldDescriptor("SMTP_SENDER_EMAIL", "SMTP_SENDER_EMAIL", "SMTP_SENDER_EMAIL"),
FieldDescriptor("TEST_EMAIL_ADDRESS", "TEST_EMAIL_ADDRESS", "TEST_EMAIL_ADDRESS"),
FieldDescriptor("EMAIL_LOGO_URL", "EMAIL_LOGO_URL", "EMAIL_LOGO_URL"),
FieldDescriptor("EMAIL_SUPPORT_CONTACT", "EMAIL_SUPPORT_CONTACT", "EMAIL_SUPPORT_CONTACT"),
)
SLACK_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
FieldDescriptor("SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", is_secret=True),
)

View file

@ -0,0 +1,94 @@
"""Resolved SSO config object.
Reconciles the dedicated ``sso_config`` DB row (lowercase, per-value encrypted
keys) with the process environment (uppercase env vars) into a typed
``SSOConfig`` plus per-field provenance. This is the single source of truth for
the SSO field -> env-var mapping, used by both the read-back endpoint and the
save endpoint so the two can never drift.
"""
from collections.abc import Mapping
from dataclasses import dataclass
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.config_resolvers._descriptors import (
FieldDescriptor,
FieldSource,
resolve_fields,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
RoleMappings,
SSOConfig,
TeamMappings,
)
SSO_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
FieldDescriptor("google_client_id", "google_client_id", "GOOGLE_CLIENT_ID"),
FieldDescriptor("google_client_secret", "google_client_secret", "GOOGLE_CLIENT_SECRET", is_secret=True),
FieldDescriptor("microsoft_client_id", "microsoft_client_id", "MICROSOFT_CLIENT_ID"),
FieldDescriptor("microsoft_client_secret", "microsoft_client_secret", "MICROSOFT_CLIENT_SECRET", is_secret=True),
FieldDescriptor("microsoft_tenant", "microsoft_tenant", "MICROSOFT_TENANT"),
FieldDescriptor("generic_client_id", "generic_client_id", "GENERIC_CLIENT_ID"),
FieldDescriptor("generic_client_secret", "generic_client_secret", "GENERIC_CLIENT_SECRET", is_secret=True),
FieldDescriptor(
"generic_authorization_endpoint", "generic_authorization_endpoint", "GENERIC_AUTHORIZATION_ENDPOINT"
),
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("proxy_base_url", "proxy_base_url", "PROXY_BASE_URL"),
)
# Derived from the descriptor table so read (masking) and the field->env mapping
# never diverge from the resolver.
SSO_SECRET_FIELDS: frozenset[str] = frozenset(d.field_name for d in SSO_DESCRIPTORS if d.is_secret)
SSO_FIELD_ENV_VARS: dict[str, str] = {d.field_name: d.env_var for d in SSO_DESCRIPTORS}
# Structured sub-objects stored on the SSO row that are not simple env-backed
# scalars; handled outside the descriptor resolution.
_STRUCTURED_KEYS = ("role_mappings", "team_mappings")
@dataclass(frozen=True, slots=True)
class ResolvedSSOConfig:
config: SSOConfig
provenance: dict[str, FieldSource]
def _decrypt(raw: Mapping[str, object]) -> dict[str, object]:
return {
key: (
decrypt_value_helper(value=value, key=key, return_original_value=True) if isinstance(value, str) else value
)
for key, value in raw.items()
}
def _parse_role_mappings(data: object) -> RoleMappings | None:
# The stored row is JSON, so mappings arrive as a dict (or are absent).
return RoleMappings(**data) if isinstance(data, dict) else None
def _parse_team_mappings(data: object) -> TeamMappings | None:
return TeamMappings(**data) if isinstance(data, dict) else None
def resolve_sso_config(sso_db_settings: Mapping[str, object] | None, env: Mapping[str, str]) -> ResolvedSSOConfig:
"""Resolve the effective SSO config: stored row first, then process env.
Decryption happens here, once, via the pure ``decrypt_value_helper``; this
function never writes ``os.environ`` (unlike the legacy read path). Values
are returned unmasked so the login path could consume them; the read-back
endpoint is responsible for masking secrets before responding to the UI.
"""
raw = dict(sso_db_settings) if sso_db_settings else {}
decrypted = _decrypt({key: value for key, value in raw.items() if key not in _STRUCTURED_KEYS})
values, provenance = resolve_fields(SSO_DESCRIPTORS, decrypted, env)
structured = {
"user_email": decrypted.get("user_email"),
"ui_access_mode": decrypted.get("ui_access_mode"),
"role_mappings": _parse_role_mappings(raw.get("role_mappings")),
"team_mappings": _parse_team_mappings(raw.get("team_mappings")),
}
config = SSOConfig(**{**values, **structured})
return ResolvedSSOConfig(config=config, provenance=provenance)

View file

@ -304,6 +304,11 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
encrypt_value_helper,
)
from litellm.proxy.common_utils.html_forms.ui_login import build_ui_login_form
from litellm.proxy.config_resolvers import resolve_fields
from litellm.proxy.config_resolvers.alerting import (
EMAIL_DESCRIPTORS,
SLACK_DESCRIPTORS,
)
from litellm.proxy.common_utils.http_parsing_utils import (
_read_request_body,
_safe_get_request_headers,
@ -1159,9 +1164,9 @@ _OPENAPI_HTTP_METHODS = {
# Credentials surfaced by `/get/config/callbacks` in the alerting block: the
# full Slack incoming-webhook URL is itself a credential, and the SMTP
# password is a service password. Masked on read so plaintext never reaches
# the UI. Kept here at module scope to match the analogous
# `_SSO_SENSITIVE_FIELDS` / `_CACHE_SENSITIVE_FIELDS` constants in the SSO
# and cache endpoint files.
# the UI. Kept here at module scope to match the analogous descriptor
# `is_secret` flags in litellm.proxy.config_resolvers and the
# `_CACHE_SENSITIVE_FIELDS` constant in the cache endpoint file.
_ALERTING_SENSITIVE_VARS: Set[str] = {"SLACK_WEBHOOK_URL", "SMTP_PASSWORD"}
@ -15491,14 +15496,10 @@ async def get_config(
_alerting = _general_settings.get("alerting", [])
alerting_data = []
if "slack" in _alerting:
_slack_vars = [
"SLACK_WEBHOOK_URL",
]
_slack_env_vars = {
_var: (value if (value := environment_variables.get(_var)) is not None else os.getenv(_var))
for _var in _slack_vars
}
_slack_env_vars = _apply_alerting_env_role_gate(_slack_env_vars, is_full_admin)
_slack_values, _ = resolve_fields(
SLACK_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True
)
_slack_env_vars = _apply_alerting_env_role_gate(_slack_values, is_full_admin)
_alerting_types = proxy_logging_obj.slack_alerting_instance.alert_types
_all_alert_types = proxy_logging_obj.slack_alerting_instance._all_possible_alert_types()
@ -15514,19 +15515,8 @@ async def get_config(
}
)
# pass email alerting vars
_email_vars = [
"SMTP_HOST",
"SMTP_PORT",
"SMTP_USERNAME",
"SMTP_PASSWORD",
"SMTP_SENDER_EMAIL",
"TEST_EMAIL_ADDRESS",
"EMAIL_LOGO_URL",
"EMAIL_SUPPORT_CONTACT",
]
_email_env_vars = _apply_alerting_env_role_gate(
{_var: environment_variables.get(_var) for _var in _email_vars}, is_full_admin
)
_email_values, _ = resolve_fields(EMAIL_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True)
_email_env_vars = _apply_alerting_env_role_gate(_email_values, is_full_admin)
alerting_data.append(
{

View file

@ -15,6 +15,11 @@ from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_keys
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.config_resolvers.sso import (
SSO_FIELD_ENV_VARS,
SSO_SECRET_FIELDS,
resolve_sso_config,
)
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.table_repositories import (
SSOConfigRepository,
@ -27,16 +32,6 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
router = APIRouter()
# SSO secret fields returned by /get/sso_settings. These are masked on read so
# the UI can show "(set)" without ever transporting the plaintext OAuth secret
# off the server, matching the write-once + masked-on-read contract used for
# the HashiCorp Vault config override.
_SSO_SENSITIVE_FIELDS: Set[str] = {
"google_client_secret",
"microsoft_client_secret",
"generic_client_secret",
}
# Maps each UIThemeConfig field to the env var the UI branding path reads it
# from. /update/ui_theme_settings writes both the stored ui_theme_config and
# these env vars, so /get/ui_theme_settings resolves the same env vars to
@ -109,7 +104,8 @@ class SettingsResponse(BaseModel):
class SSOSettingsResponse(SettingsResponse):
"""Response model for SSO settings"""
pass
provenance: Dict[str, str] = Field(default_factory=dict)
"""Per-field source of each value: 'db', 'env', 'default', or 'unset'."""
class InternalUserSettingsResponse(SettingsResponse):
@ -757,7 +753,7 @@ async def get_sso_settings():
Returns a structured object with values and descriptions for UI display.
"""
from litellm.proxy.proxy_server import prisma_client, proxy_config
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
@ -765,59 +761,12 @@ async def get_sso_settings():
detail={"error": "Database not connected. Please connect a database."},
)
# Get SSO config from dedicated table
# Resolve the effective SSO config: the stored row wins, else the process
# environment, else each field's default. Unlike the legacy read path this
# does not write os.environ; a GET has no business mutating the environment.
sso_db_record = await SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"})
# Initialize with defaults
sso_settings_dict = {}
if sso_db_record and sso_db_record.sso_settings:
# Load settings from database
sso_settings_dict = dict(sso_db_record.sso_settings)
role_mappings_data = sso_settings_dict.pop("role_mappings", None)
role_mappings = None
if role_mappings_data:
from litellm.types.proxy.management_endpoints.ui_sso import RoleMappings
if isinstance(role_mappings_data, dict):
role_mappings = RoleMappings(**role_mappings_data)
elif isinstance(role_mappings_data, RoleMappings):
role_mappings = role_mappings_data
team_mappings_data = sso_settings_dict.pop("team_mappings", None)
team_mappings = None
if team_mappings_data:
from litellm.types.proxy.management_endpoints.ui_sso import TeamMappings
if isinstance(team_mappings_data, dict):
team_mappings = TeamMappings(**team_mappings_data)
elif isinstance(team_mappings_data, TeamMappings):
team_mappings = team_mappings_data
decrypted_sso_settings_dict = proxy_config._decrypt_and_set_db_env_variables(
environment_variables=sso_settings_dict
)
# Build SSO config with database values or environment fallback
sso_config = SSOConfig(
google_client_id=decrypted_sso_settings_dict.get("google_client_id", None),
google_client_secret=decrypted_sso_settings_dict.get("google_client_secret", None),
microsoft_client_id=decrypted_sso_settings_dict.get("microsoft_client_id", None),
microsoft_client_secret=decrypted_sso_settings_dict.get("microsoft_client_secret", None),
microsoft_tenant=decrypted_sso_settings_dict.get("microsoft_tenant", None),
generic_client_id=decrypted_sso_settings_dict.get("generic_client_id", None),
generic_client_secret=decrypted_sso_settings_dict.get("generic_client_secret", None),
generic_authorization_endpoint=decrypted_sso_settings_dict.get("generic_authorization_endpoint", None),
generic_token_endpoint=decrypted_sso_settings_dict.get("generic_token_endpoint", None),
generic_userinfo_endpoint=decrypted_sso_settings_dict.get("generic_userinfo_endpoint", None),
proxy_base_url=decrypted_sso_settings_dict.get("proxy_base_url", None),
user_email=decrypted_sso_settings_dict.get("user_email"),
ui_access_mode=decrypted_sso_settings_dict.get("ui_access_mode"),
role_mappings=role_mappings,
team_mappings=team_mappings,
)
sso_db_settings = dict(sso_db_record.sso_settings) if sso_db_record and sso_db_record.sso_settings else None
resolved = resolve_sso_config(sso_db_settings, os.environ)
# Get the schema for UI display
from pydantic import TypeAdapter
@ -826,11 +775,12 @@ async def get_sso_settings():
# Convert to dict for response, masking OAuth client secrets so plaintext
# is never sent to the UI.
sso_dict = mask_sensitive_keys(sso_config.model_dump(), _SSO_SENSITIVE_FIELDS)
sso_dict = mask_sensitive_keys(resolved.config.model_dump(), set(SSO_SECRET_FIELDS))
# Add descriptions to the response
result = {
"values": sso_dict,
"provenance": resolved.provenance,
"field_schema": {
"description": schema.get("description", ""),
"properties": {},
@ -881,21 +831,6 @@ async def update_sso_settings(
detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
)
# Update environment variables
env_var_mapping = {
"google_client_id": "GOOGLE_CLIENT_ID",
"google_client_secret": "GOOGLE_CLIENT_SECRET",
"microsoft_client_id": "MICROSOFT_CLIENT_ID",
"microsoft_client_secret": "MICROSOFT_CLIENT_SECRET",
"microsoft_tenant": "MICROSOFT_TENANT",
"generic_client_id": "GENERIC_CLIENT_ID",
"generic_client_secret": "GENERIC_CLIENT_SECRET",
"generic_authorization_endpoint": "GENERIC_AUTHORIZATION_ENDPOINT",
"generic_token_endpoint": "GENERIC_TOKEN_ENDPOINT",
"generic_userinfo_endpoint": "GENERIC_USERINFO_ENDPOINT",
"proxy_base_url": "PROXY_BASE_URL",
}
# Read the existing SSO row first so the audit log captures a real
# before/after diff. Stored values are encrypted; decrypt them so the
# before-snapshot has the same shape as after_value, and rely on
@ -924,8 +859,8 @@ async def update_sso_settings(
# Update environment variables in config and in memory
sso_data = sso_config.model_dump()
for field_name, value in sso_data.items():
if field_name in env_var_mapping:
env_var_name = env_var_mapping[field_name]
if field_name in SSO_FIELD_ENV_VARS:
env_var_name = SSO_FIELD_ENV_VARS[field_name]
if value:
os.environ[env_var_name] = value
else:
@ -975,7 +910,7 @@ async def update_sso_settings(
else:
environment_variables = {}
env_vars_to_remove = set(env_var_mapping.values())
env_vars_to_remove = set(SSO_FIELD_ENV_VARS.values())
filtered_env_vars = {
key: value for key, value in environment_variables.items() if key not in env_vars_to_remove
}

View file

@ -148,6 +148,10 @@ class SSOConfig(LiteLLMPydanticObjectBase):
default=None,
description="User info endpoint URL for generic OAuth provider",
)
generic_scope: Optional[str] = Field(
default=None,
description="Space-separated OAuth scopes requested from the generic provider, e.g. 'openid email profile'",
)
# Common settings
proxy_base_url: Optional[str] = Field(

View file

@ -0,0 +1,105 @@
import os
from litellm.proxy.config_resolvers._descriptors import FieldDescriptor, resolve_fields
from litellm.proxy.config_resolvers.sso import (
SSO_FIELD_ENV_VARS,
SSO_SECRET_FIELDS,
resolve_sso_config,
)
_D = (
FieldDescriptor("client_id", "client_id", "CLIENT_ID"),
FieldDescriptor("scope", "scope", "SCOPE", default="openid"),
)
def test_resolve_fields_db_wins_over_env():
values, provenance = resolve_fields(_D, {"client_id": "from-db"}, {"CLIENT_ID": "from-env"})
assert values["client_id"] == "from-db"
assert provenance["client_id"] == "db"
def test_resolve_fields_blank_db_falls_back_to_env():
values, provenance = resolve_fields(_D, {"client_id": " "}, {"CLIENT_ID": "from-env"})
assert values["client_id"] == "from-env"
assert provenance["client_id"] == "env"
def test_resolve_fields_blank_everywhere_falls_to_default():
values, provenance = resolve_fields(_D, {}, {"SCOPE": ""})
assert values["scope"] == "openid"
assert provenance["scope"] == "default"
def test_resolve_fields_unset_everywhere():
values, provenance = resolve_fields(_D, {}, {})
assert values["client_id"] is None
assert provenance["client_id"] == "unset"
def test_resolve_fields_empty_db_absent_by_default_falls_to_env():
# SSO semantics: a present-but-empty stored value is absent, so env wins.
values, provenance = resolve_fields(_D, {"client_id": ""}, {"CLIENT_ID": "from-env"})
assert values["client_id"] == "from-env"
assert provenance["client_id"] == "env"
def test_resolve_fields_empty_db_is_explicit_clear_when_flag_set():
# Alerting semantics: a present-but-empty stored value is an explicit clear
# that must win over a stale env var.
values, provenance = resolve_fields(
_D, {"client_id": ""}, {"CLIENT_ID": "stale-env"}, empty_db_is_set=True
)
assert values["client_id"] == ""
assert provenance["client_id"] == "db"
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_SECRET_FIELDS == frozenset(
{"google_client_secret", "microsoft_client_secret", "generic_client_secret"}
)
def test_resolve_sso_config_returns_unmasked_secret_and_provenance():
# The resolver hands back plaintext; masking is the endpoint's job. If the
# resolver masked, the login path would consume a masked secret and fail.
resolved = resolve_sso_config(
{"generic_client_secret": "super-secret-value"},
{"GENERIC_CLIENT_ID": "env-id"},
)
assert resolved.config.generic_client_secret == "super-secret-value"
assert resolved.provenance["generic_client_secret"] == "db"
assert resolved.config.generic_client_id == "env-id"
assert resolved.provenance["generic_client_id"] == "env"
def test_resolve_sso_config_parses_structured_mappings():
resolved = resolve_sso_config(
{
"generic_client_id": "id",
"role_mappings": {
"provider": "generic",
"group_claim": "groups",
"default_role": "internal_user",
"roles": {},
},
"team_mappings": {"team_ids_jwt_field": "teams"},
},
{},
)
assert resolved.config.role_mappings is not None
assert resolved.config.role_mappings.group_claim == "groups"
assert resolved.config.team_mappings is not None
assert resolved.config.team_mappings.team_ids_jwt_field == "teams"
def test_resolve_sso_config_does_not_mutate_os_environ(monkeypatch):
# Unlike the legacy read path, resolving must not write os.environ.
monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)
before = dict(os.environ)
resolve_sso_config({"generic_client_id": "id-from-db"}, os.environ)
assert dict(os.environ) == before
assert "GENERIC_CLIENT_ID" not in os.environ

View file

@ -1174,6 +1174,113 @@ def test_get_config_returns_email_settings(monkeypatch):
assert "*" in variables["SMTP_PASSWORD"]
def _get_email_alert_variables(monkeypatch, config_data):
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
mock_router = MagicMock()
mock_router.get_settings.return_value = {}
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data))
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234"
)
client = TestClient(app)
try:
response = client.get("/get/config/callbacks")
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 200
email_alert = next((a for a in response.json()["alerts"] if a["name"] == "email"), None)
assert email_alert is not None
return email_alert["variables"]
def test_get_config_returns_email_settings_set_only_in_process_env(monkeypatch):
"""
Regression for LIT-4165.
SMTP supplied purely as process env vars (helm/terraform, no UI writes) is
live at runtime because litellm/proxy/utils.py::send_email resolves every
field from os.getenv. The /get/config/callbacks email block only read the
config/DB environment_variables overlay though, so those deployments saw an
empty Email Server Settings page and could not tell SMTP was configured.
The slack block one branch above already fell back to os.getenv.
"""
smtp_password = "env-only-app-password"
monkeypatch.setenv("SMTP_HOST", "smtp.env-host.com")
monkeypatch.setenv("SMTP_PORT", "2525")
monkeypatch.setenv("SMTP_TLS", "False")
monkeypatch.setenv("SMTP_USERNAME", "env-user")
monkeypatch.setenv("SMTP_PASSWORD", smtp_password)
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@env-host.com")
monkeypatch.setenv("TEST_EMAIL_ADDRESS", "admin@env-host.com")
variables = _get_email_alert_variables(
monkeypatch,
{
"litellm_settings": {},
"general_settings": {"alerting": ["email"]},
"environment_variables": {},
},
)
# Every one of these was None before the fix, despite SMTP working.
assert variables["SMTP_HOST"] == "smtp.env-host.com"
assert variables["SMTP_PORT"] == "2525"
assert variables["SMTP_TLS"] == "False"
assert variables["SMTP_USERNAME"] == "env-user"
assert variables["SMTP_SENDER_EMAIL"] == "alerts@env-host.com"
assert variables["TEST_EMAIL_ADDRESS"] == "admin@env-host.com"
# An env-sourced secret is masked exactly like a stored one.
assert variables["SMTP_PASSWORD"] not in (None, smtp_password)
assert "*" in variables["SMTP_PASSWORD"]
def test_get_config_email_settings_prefer_stored_over_process_env(monkeypatch):
"""
Stored environment_variables win over the process environment, matching the
load order in ProxyConfig.get_config, which pushes stored values into
os.environ. Only a field with no stored entry falls back to os.getenv.
"""
monkeypatch.setenv("SMTP_HOST", "smtp.env-host.com")
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@env-host.com")
variables = _get_email_alert_variables(
monkeypatch,
{
"litellm_settings": {},
"general_settings": {"alerting": ["email"]},
"environment_variables": {"SMTP_HOST": "smtp.stored-host.com"},
},
)
assert variables["SMTP_HOST"] == "smtp.stored-host.com"
assert variables["SMTP_SENDER_EMAIL"] == "alerts@env-host.com"
def test_get_config_email_settings_absent_everywhere_stay_none(monkeypatch):
"""A field set in neither source is reported unset rather than invented."""
for var in ("SMTP_HOST", "SMTP_PORT", "SMTP_TLS", "SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_SENDER_EMAIL"):
monkeypatch.delenv(var, raising=False)
variables = _get_email_alert_variables(
monkeypatch,
{
"litellm_settings": {},
"general_settings": {"alerting": ["email"]},
"environment_variables": {},
},
)
assert variables["SMTP_HOST"] is None
assert variables["SMTP_PASSWORD"] is None
def test_get_config_returns_slack_webhook(monkeypatch):
"""
Same double-decryption regression as the email block (issue #19221): the

View file

@ -396,6 +396,146 @@ class TestProxySettingEndpoints:
call_args = mock_prisma.db.litellm_ssoconfig.find_unique.call_args
assert call_args.kwargs["where"]["id"] == "sso_config"
def _mock_sso_db_record(self, monkeypatch, sso_settings):
"""Point /get/sso_settings at a stored SSO row (or None for no row)."""
from unittest.mock import AsyncMock, MagicMock
mock_prisma = MagicMock()
if sso_settings is None:
mock_db_record = None
else:
mock_db_record = MagicMock()
mock_db_record.sso_settings = sso_settings
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=mock_db_record)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
# The resolver decrypts stored values via decrypt_value_helper; make it an
# identity so the plaintext fixtures round-trip.
monkeypatch.setattr(
"litellm.proxy.config_resolvers.sso.decrypt_value_helper",
lambda value, key, exception_type="error", return_original_value=False: value,
)
def test_get_sso_settings_falls_back_to_process_env(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""
Regression for LIT-4165.
SSO configured purely as process env vars (helm/terraform, no UI writes)
logs users in successfully, because ui_sso.py resolves every setting from
os.environ. /get/sso_settings read only the sso_config table though, so
the Admin UI showed "not configured" for a working SSO deployment and hid
the Edit/Delete controls behind an empty-state placeholder.
"""
self._mock_sso_db_record(monkeypatch, None)
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
monkeypatch.setenv("GENERIC_CLIENT_SECRET", "env-client-secret-value")
monkeypatch.setenv("GENERIC_AUTHORIZATION_ENDPOINT", "https://idp.example.com/authorize")
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
monkeypatch.setenv("GENERIC_USERINFO_ENDPOINT", "https://idp.example.com/userinfo")
monkeypatch.setenv("GENERIC_SCOPE", "openid email profile groups")
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
response = client.get("/get/sso_settings")
assert response.status_code == 200
values = response.json()["values"]
# Every one of these was None before the fix, despite SSO working.
assert values["generic_client_id"] == "env-client-id"
assert values["generic_authorization_endpoint"] == "https://idp.example.com/authorize"
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
assert values["generic_userinfo_endpoint"] == "https://idp.example.com/userinfo"
assert values["generic_scope"] == "openid email profile groups"
assert values["proxy_base_url"] == "https://gateway.example.com"
# An env-sourced secret is masked exactly like a stored one.
assert values["generic_client_secret"] not in (None, "env-client-secret-value")
assert "*" in values["generic_client_secret"]
def test_get_sso_settings_does_not_mutate_os_environ(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""A GET must not write os.environ. The legacy read path decrypted DB
values straight into the environment, so opening the settings page
repopulated env and masked any consumer that stopped reading it."""
self._mock_sso_db_record(monkeypatch, {"generic_client_id": "db-only-id"})
monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)
response = client.get("/get/sso_settings")
assert response.status_code == 200
assert response.json()["values"]["generic_client_id"] == "db-only-id"
# The DB value must NOT have leaked into the process environment.
assert "GENERIC_CLIENT_ID" not in os.environ
def test_get_sso_settings_prefers_stored_over_process_env(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""A stored value wins; only fields absent from the row fall back to env."""
self._mock_sso_db_record(monkeypatch, {"generic_client_id": "stored-client-id"})
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
response = client.get("/get/sso_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["generic_client_id"] == "stored-client-id"
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
def test_get_sso_settings_blank_stored_value_falls_back_to_process_env(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""
Blank means absent. update_sso_settings clears the env var for a blank
field, so a blank row entry cannot describe a live setting; os.environ is
the effective config and is what the UI must report.
"""
self._mock_sso_db_record(monkeypatch, {"generic_client_id": " ", "generic_token_endpoint": ""})
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
response = client.get("/get/sso_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["generic_client_id"] == "env-client-id"
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
def test_get_sso_settings_unset_everywhere_reports_source(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""A field set in neither source is unset (or its effective default),
and provenance reports which."""
self._mock_sso_db_record(monkeypatch, None)
for env_var in (
"GENERIC_CLIENT_ID",
"GENERIC_CLIENT_SECRET",
"GENERIC_TOKEN_ENDPOINT",
"GENERIC_SCOPE",
"GOOGLE_CLIENT_ID",
"MICROSOFT_CLIENT_ID",
"PROXY_BASE_URL",
):
monkeypatch.delenv(env_var, raising=False)
response = client.get("/get/sso_settings")
assert response.status_code == 200
body = response.json()
values = body["values"]
provenance = body["provenance"]
assert values["generic_client_id"] is None
assert provenance["generic_client_id"] == "unset"
assert values["generic_client_secret"] is None
assert values["google_client_id"] is None
# generic_scope carries the same effective default the login path applies,
# so the settings page shows the scope logins would actually request.
assert values["generic_scope"] == "openid email profile"
assert provenance["generic_scope"] == "default"
def test_update_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
"""Test updating the SSO settings to the dedicated database table"""
import json
@ -1463,19 +1603,20 @@ class TestProxySettingEndpoints:
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
# Mock the decryption method to return decrypted values
def mock_decrypt_and_set(environment_variables):
return {
"google_client_id": "decrypted_google_id",
"google_client_secret": "decrypted_google_secret",
"microsoft_client_id": "decrypted_microsoft_id",
"proxy_base_url": "https://decrypted.example.com",
}
# The resolver decrypts each stored value via decrypt_value_helper; map
# the ciphertext fixtures to their plaintext.
decrypted_by_ciphertext = {
"encrypted_google_id": "decrypted_google_id",
"encrypted_google_secret": "decrypted_google_secret",
"encrypted_microsoft_id": "decrypted_microsoft_id",
"encrypted_proxy_url": "https://decrypted.example.com",
}
from litellm.proxy.proxy_server import proxy_config
def mock_decrypt(value, key, exception_type="error", return_original_value=False):
return decrypted_by_ciphertext.get(value, value)
monkeypatch.setattr(
proxy_config, "_decrypt_and_set_db_env_variables", mock_decrypt_and_set
"litellm.proxy.config_resolvers.sso.decrypt_value_helper", mock_decrypt
)
response = client.get("/get/sso_settings")

View file

@ -24,6 +24,7 @@ export interface SSOSettingsValues {
generic_authorization_endpoint: string | null;
generic_token_endpoint: string | null;
generic_userinfo_endpoint: string | null;
generic_scope: string | null;
proxy_base_url: string | null;
user_email: string | null;
ui_access_mode: string | null;

View file

@ -462,6 +462,7 @@ describe("SSOModals", () => {
generic_authorization_endpoint: null,
generic_token_endpoint: null,
generic_userinfo_endpoint: null,
generic_scope: null,
proxy_base_url: null,
user_email: null,
sso_provider: null,

View file

@ -1,11 +1,12 @@
import React, { useEffect, useState } from "react";
import { Modal, Form, Input, Button as Button2, Select, Checkbox } from "antd";
import { Modal, Form, Button as Button2, Select, Checkbox } from "antd";
import { Text, TextInput } from "@tremor/react";
import { getSSOSettings, updateSSOSettings } from "./networking";
import NotificationsManager from "./molecules/notifications_manager";
import { parseErrorMessage } from "./shared/errorUtils";
import { Logo } from "@/components/molecules/logo/Logo";
import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./Settings/AdminSettings/SSOSettings/constants";
import { renderProviderFields } from "./Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm";
interface SSOModalsProps {
isAddSSOModalVisible: boolean;
@ -20,82 +21,6 @@ interface SSOModalsProps {
ssoConfigured?: boolean; // Add optional prop to indicate if SSO is configured
}
// Define the SSO provider configuration type
interface SSOProviderConfig {
envVarMap: Record<string, string>;
fields: Array<{
label: string;
name: string;
placeholder?: string;
}>;
}
// Define configurations for each SSO provider
const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
google: {
envVarMap: {
google_client_id: "GOOGLE_CLIENT_ID",
google_client_secret: "GOOGLE_CLIENT_SECRET",
},
fields: [
{ label: "Google Client ID", name: "google_client_id" },
{ label: "Google Client Secret", name: "google_client_secret" },
],
},
microsoft: {
envVarMap: {
microsoft_client_id: "MICROSOFT_CLIENT_ID",
microsoft_client_secret: "MICROSOFT_CLIENT_SECRET",
microsoft_tenant: "MICROSOFT_TENANT",
},
fields: [
{ label: "Microsoft Client ID", name: "microsoft_client_id" },
{ label: "Microsoft Client Secret", name: "microsoft_client_secret" },
{ label: "Microsoft Tenant", name: "microsoft_tenant" },
],
},
okta: {
envVarMap: {
generic_client_id: "GENERIC_CLIENT_ID",
generic_client_secret: "GENERIC_CLIENT_SECRET",
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
{ label: "Generic Client Secret", name: "generic_client_secret" },
{
label: "Authorization Endpoint",
name: "generic_authorization_endpoint",
placeholder: "https://your-domain/authorize",
},
{ label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
{
label: "Userinfo Endpoint",
name: "generic_userinfo_endpoint",
placeholder: "https://your-domain/userinfo",
},
],
},
generic: {
envVarMap: {
generic_client_id: "GENERIC_CLIENT_ID",
generic_client_secret: "GENERIC_CLIENT_SECRET",
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
{ label: "Generic Client Secret", name: "generic_client_secret" },
{ label: "Authorization Endpoint", name: "generic_authorization_endpoint" },
{ label: "Token Endpoint", name: "generic_token_endpoint" },
{ label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" },
],
},
};
const SSOModals: React.FC<SSOModalsProps> = ({
isAddSSOModalVisible,
isInstructionsModalVisible,
@ -266,6 +191,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
generic_authorization_endpoint: null,
generic_token_endpoint: null,
generic_userinfo_endpoint: null,
generic_scope: null,
proxy_base_url: null,
user_email: null,
sso_provider: null,
@ -291,22 +217,6 @@ const SSOModals: React.FC<SSOModalsProps> = ({
};
// Helper function to render provider fields
const renderProviderFields = (provider: string) => {
const config = ssoProviderConfigs[provider];
if (!config) return null;
return config.fields.map((field) => (
<Form.Item
key={field.name}
label={field.label}
name={field.name}
rules={[{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }]}
>
{field.name.includes("client") ? <Input.Password /> : <TextInput placeholder={field.placeholder} />}
</Form.Item>
));
};
return (
<>
<Modal
@ -540,5 +450,4 @@ const SSOModals: React.FC<SSOModalsProps> = ({
);
};
export { ssoProviderConfigs }; // Export for use in other components
export default SSOModals;

View file

@ -2,7 +2,7 @@ import { Form } from "antd";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../../../../../tests/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import BaseSSOSettingsForm, { renderProviderFields } from "./BaseSSOSettingsForm";
import BaseSSOSettingsForm, { renderProviderFields, ssoProviderConfigs } from "./BaseSSOSettingsForm";
describe("BaseSSOSettingsForm", () => {
afterEach(() => {
@ -285,13 +285,70 @@ describe("renderProviderFields", () => {
it("should return fields for okta provider", () => {
const result = renderProviderFields("okta");
expect(result).not.toBeNull();
expect(result?.length).toBe(5);
expect(result?.length).toBe(6);
});
it("should return fields for generic provider", () => {
const result = renderProviderFields("generic");
expect(result).not.toBeNull();
expect(result?.length).toBe(5);
expect(result?.length).toBe(6);
});
it.each(["okta", "generic"])(
"renders an optional generic_scope field for %s so editing cannot clear it",
(provider) => {
const scopeField = ssoProviderConfigs[provider].fields.find((field) => field.name === "generic_scope");
expect(scopeField).toBeDefined();
expect(scopeField?.required).toBe(false);
expect(ssoProviderConfigs[provider].envVarMap.generic_scope).toBe("GENERIC_SCOPE");
},
);
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
// present is what stops an unrelated edit from downgrading a custom scope
// to the provider default. Dropping the field from ssoProviderConfigs must
// fail here rather than silently in production.
const handleSubmit = vi.fn();
let form: any;
const TestWrapper = () => {
const [formInstance] = Form.useForm();
form = formInstance;
return <BaseSSOSettingsForm form={formInstance} onFormSubmit={handleSubmit} />;
};
renderWithProviders(<TestWrapper />);
// Mirror EditSSOSettingsModal hydrating the form from the GET response.
await act(async () => {
form.setFieldsValue({
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_scope: "openid email profile groups",
proxy_base_url: "https://gateway.example.com",
user_email: "admin@example.com",
});
});
// The admin edits something else entirely and saves.
await act(async () => {
form.setFieldsValue({ generic_token_endpoint: "https://idp.example.com/token/v2" });
form.submit();
});
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining({
generic_token_endpoint: "https://idp.example.com/token/v2",
generic_scope: "openid email profile groups",
}),
);
});
});
it("renders provider logos in the dropdown and falls back to a letter avatar on load error", async () => {

View file

@ -18,6 +18,7 @@ export interface SSOProviderConfig {
label: string;
name: string;
placeholder?: string;
required?: boolean;
}>;
}
@ -52,6 +53,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
generic_scope: "GENERIC_SCOPE",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
@ -67,6 +69,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
name: "generic_userinfo_endpoint",
placeholder: "https://your-domain/userinfo",
},
{ label: "Scopes", name: "generic_scope", placeholder: "openid email profile", required: false },
],
},
generic: {
@ -76,6 +79,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
generic_scope: "GENERIC_SCOPE",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
@ -83,6 +87,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
{ label: "Authorization Endpoint", name: "generic_authorization_endpoint" },
{ 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 },
],
},
};
@ -97,7 +102,7 @@ export const renderProviderFields = (provider: string) => {
key={field.name}
label={field.label}
name={field.name}
rules={[{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }]}
rules={[{ required: field.required !== false, message: `Please enter the ${field.label.toLowerCase()}` }]}
>
{field.name.includes("client") ? <Input.Password /> : <TextInput placeholder={field.placeholder} />}
</Form.Item>

View file

@ -111,6 +111,7 @@ export default function SSOSettings() {
label: "User Info Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
},
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
isTeamMappingsEnabled
? {
@ -143,6 +144,7 @@ export default function SSOSettings() {
label: "User Info Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
},
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
isTeamMappingsEnabled
? {

View file

@ -26,9 +26,17 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
.forEach((alert) => {
Object.entries(alert.variables ?? {}).forEach(([key, value]) => {
const inputElement = document.querySelector(`input[name="${key}"]`) as HTMLInputElement;
if (inputElement && inputElement.value) {
updatedVariables[key] = inputElement?.value;
if (!inputElement || !inputElement.value) {
return;
}
// Only send fields the admin actually edited. Values rendered from the
// server are masked (SMTP_PASSWORD) or sourced from the process
// environment, so re-submitting an untouched field would persist a mask
// or copy env-managed config into the database.
if (inputElement.value === (value == null ? "" : String(value))) {
return;
}
updatedVariables[key] = inputElement.value;
});
});

View file

@ -30686,6 +30686,11 @@ export interface components {
* @description Generic OAuth Client Secret for SSO authentication
*/
generic_client_secret?: string | null;
/**
* Generic Scope
* @description Space-separated OAuth scopes requested from the generic provider, e.g. 'openid email profile'
*/
generic_scope?: string | null;
/**
* Generic Token Endpoint
* @description Token endpoint URL for generic OAuth provider
@ -30750,6 +30755,10 @@ export interface components {
field_schema: {
[key: string]: unknown;
};
/** Provenance */
provenance?: {
[key: string]: string;
};
/** Values */
values: {
[key: string]: unknown;