From b2b3630ed5a60430193db43012954f036685abfd Mon Sep 17 00:00:00 2001 From: Harshit1259 Date: Wed, 26 Aug 2026 11:12:16 +0530 Subject: [PATCH 1/3] fix: preserve SSO client secret on partial settings update get_sso_settings masks secret fields before returning them to the UI, so a client editing only unrelated fields (e.g. the redirect URL) sends the masked placeholder back unchanged. update_sso_settings stored that value verbatim, overwriting the real OAuth client_secret with `abcd****wxyz` and breaking SSO login for every integrated system. Keep the effective secret (stored row value, falling back to the process environment, matching get_sso_settings' own precedence) whenever the incoming value still carries the mask. An empty value is an intentional clear and a genuinely new secret has no mask, so both pass through. The fix is server-side so it protects the API path as well as the UI. Fixes #38177 --- .../proxy_setting_endpoints.py | 32 +++- .../test_sso_secret_backfill.py | 142 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/proxy/ui_crud_endpoints/test_sso_secret_backfill.py diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index a1eb7ed06eb..52633c10baa 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -3,7 +3,7 @@ import asyncio import json import os from collections import Counter -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping from typing import ( Any, Final, @@ -958,6 +958,34 @@ async def get_sso_settings(): return result +def _restore_masked_sso_secrets( + sso_data: MutableMapping[str, object], # mutable-ok: stored secrets are restored into the caller's config in place + before_sso_data: Mapping[str, object] | None, +) -> None: + """Keep a stored SSO secret when the client round-trips its masked value. + + Secret fields are masked before they are sent to the UI (see + get_sso_settings), so a client that edits only unrelated fields sends the + masked placeholder back unchanged. A plaintext secret never contains the + mask character, so an incoming secret that still carries the mask is treated + as "unchanged" and the previously effective secret is restored. This stops a + partial edit from overwriting e.g. the OAuth client_secret with + `abcd****wxyz` and breaking SSO login. An empty value is an intentional + clear and a genuinely new secret has no mask, so both pass through unchanged. + + The effective secret is the stored row value, falling back to the process + environment -- the same precedence get_sso_settings uses when it masks the + field -- so a secret configured via an environment variable is preserved + too, not only database-stored ones. See #38177. + """ + for secret_field in SSO_SECRET_FIELDS: + db_secret = before_sso_data.get(secret_field) if before_sso_data else None + stored_secret = db_secret or os.environ.get(SSO_FIELD_ENV_VARS.get(secret_field, "")) + incoming_secret = sso_data.get(secret_field) + if stored_secret and isinstance(incoming_secret, str) and "*" in incoming_secret: + sso_data[secret_field] = stored_secret # rebind-ok: intentional in-place restore of a masked secret + + @router.patch( "/update/sso_settings", tags=["SSO Settings"], @@ -1021,6 +1049,8 @@ async def update_sso_settings( # Update environment variables in config and in memory sso_data: Final = sso_config.model_dump() + + _restore_masked_sso_secrets(sso_data, before_sso_data) for field_name, value in sso_data.items(): if field_name in SSO_FIELD_ENV_VARS: env_var_name = SSO_FIELD_ENV_VARS[field_name] diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_sso_secret_backfill.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_sso_secret_backfill.py new file mode 100644 index 00000000000..d9fc014636a --- /dev/null +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_sso_secret_backfill.py @@ -0,0 +1,142 @@ +"""Regression tests for #38177: a partial SSO settings update must not overwrite +a stored client secret with the masked placeholder the UI sends back, while +still allowing an intentional clear and a genuinely new secret.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.testclient import TestClient + +from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_keys +from litellm.proxy.config_resolvers.sso import SSO_SECRET_FIELDS +from litellm.proxy.proxy_server import app + +client = TestClient(app) + +REAL_SECRET = "real_generic_secret_ABCD1234" + + +@pytest.fixture +def mock_auth(): + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + async def _override(): + return {"user_id": "test_user"} + + app.dependency_overrides[user_api_key_auth] = _override + yield + app.dependency_overrides.pop(user_api_key_auth, None) + + +def _masked(secret): + return mask_sensitive_keys({"generic_client_secret": secret}, set(SSO_SECRET_FIELDS))["generic_client_secret"] + + +def _mock_prisma(monkeypatch, existing_settings): + """Wire a prisma client whose SSO row returns existing_settings (or None).""" + record = None + if existing_settings is not None: + record = MagicMock() + record.sso_settings = existing_settings + + mock_prisma = MagicMock() + mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=record) + mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock() + mock_prisma.db.litellm_config = MagicMock() + mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None) + mock_prisma.db.litellm_config.update = AsyncMock() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + + from litellm.proxy.proxy_server import proxy_config + + monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr(proxy_config, "_encrypt_env_variables", lambda environment_variables: environment_variables) + monkeypatch.setattr(proxy_config, "_decrypt_db_variables", lambda stored: stored) + return mock_prisma + + +def _stored_secret(mock_prisma): + return json.loads(mock_prisma.db.litellm_ssoconfig.upsert.call_args.kwargs["data"]["update"]["sso_settings"]) + + +def test_database_stored_secret_is_preserved(mock_auth, monkeypatch): + """A masked round-trip must keep a database-stored secret.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.delenv("GENERIC_CLIENT_SECRET", raising=False) + mock_prisma = _mock_prisma( + monkeypatch, + {"generic_client_id": "cid", "generic_client_secret": REAL_SECRET, "proxy_base_url": "https://old.example.com"}, + ) + + edited = { + "generic_client_id": "cid", + "generic_client_secret": _masked(REAL_SECRET), + "proxy_base_url": "https://new.example.com", + } + resp = client.patch("/update/sso_settings", json=edited) + assert resp.status_code == 200 + + stored = _stored_secret(mock_prisma) + assert stored["generic_client_secret"] == REAL_SECRET + assert stored["proxy_base_url"] == "https://new.example.com" + + +def test_environment_sourced_secret_is_preserved(mock_auth, monkeypatch): + """A masked round-trip must keep a secret configured via env, even with no DB row.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.setenv("GENERIC_CLIENT_SECRET", REAL_SECRET) + mock_prisma = _mock_prisma(monkeypatch, None) # nothing in the database + + edited = { + "generic_client_id": "cid", + "generic_client_secret": _masked(REAL_SECRET), + "proxy_base_url": "https://new.example.com", + } + resp = client.patch("/update/sso_settings", json=edited) + assert resp.status_code == 200 + + stored = _stored_secret(mock_prisma) + assert stored["generic_client_secret"] == REAL_SECRET + + +def test_empty_secret_clears_intentionally(mock_auth, monkeypatch): + """An empty incoming value is an intentional clear, not a masked round-trip.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.delenv("GENERIC_CLIENT_SECRET", raising=False) + mock_prisma = _mock_prisma( + monkeypatch, + {"generic_client_id": "cid", "generic_client_secret": REAL_SECRET}, + ) + + edited = {"generic_client_id": "cid", "generic_client_secret": "", "proxy_base_url": "https://x.example.com"} + resp = client.patch("/update/sso_settings", json=edited) + assert resp.status_code == 200 + + stored = _stored_secret(mock_prisma) + assert stored["generic_client_secret"] == "" + + +def test_new_secret_is_saved(mock_auth, monkeypatch): + """A genuinely new secret (no mask character) replaces the stored one.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.delenv("GENERIC_CLIENT_SECRET", raising=False) + mock_prisma = _mock_prisma( + monkeypatch, + {"generic_client_id": "cid", "generic_client_secret": REAL_SECRET}, + ) + + edited = { + "generic_client_id": "cid", + "generic_client_secret": "brand_new_secret_9999", + "proxy_base_url": "https://x", + } + resp = client.patch("/update/sso_settings", json=edited) + assert resp.status_code == 200 + + stored = _stored_secret(mock_prisma) + assert stored["generic_client_secret"] == "brand_new_secret_9999" From 8bb4ea6309bd6b6bd64c9e96eba89972c445b117 Mon Sep 17 00:00:00 2001 From: Harshit1259 Date: Sat, 29 Aug 2026 22:26:50 +0530 Subject: [PATCH 2/3] fix: mask secrets in SSO update response, honor explicit clears and omitted fields - restore a stored secret only when the field was omitted from the request or the submitted value is exactly the mask of the effective secret; explicit null / "" still clear (dashboard 'Clear SSO Settings'), and a new secret that happens to contain '*' is saved as-is - return a copy instead of mutating sso_data in place - mask *_client_secret fields in the /update/sso_settings response so the restored plaintext is never echoed back to the caller - tests: omitted field, explicit null, asterisk-in-new-secret, masked response; legacy test updated to expect the masked response --- .../proxy_setting_endpoints.py | 47 +++++++++------ .../test_proxy_setting_endpoints.py | 13 ++-- .../test_sso_secret_backfill.py | 60 ++++++++++++++++++- 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 52633c10baa..e477164ea86 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -3,7 +3,8 @@ import asyncio import json import os from collections import Counter -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping +from collections.abc import Set as AbstractSet from typing import ( Any, Final, @@ -959,31 +960,39 @@ async def get_sso_settings(): def _restore_masked_sso_secrets( - sso_data: MutableMapping[str, object], # mutable-ok: stored secrets are restored into the caller's config in place + sso_data: Mapping[str, object], + submitted_fields: AbstractSet[str], before_sso_data: Mapping[str, object] | None, -) -> None: - """Keep a stored SSO secret when the client round-trips its masked value. +) -> dict[str, object]: + """Return a copy of ``sso_data`` with stored SSO secrets restored where the + client could not have meant to change them. Secret fields are masked before they are sent to the UI (see - get_sso_settings), so a client that edits only unrelated fields sends the - masked placeholder back unchanged. A plaintext secret never contains the - mask character, so an incoming secret that still carries the mask is treated - as "unchanged" and the previously effective secret is restored. This stops a - partial edit from overwriting e.g. the OAuth client_secret with - `abcd****wxyz` and breaking SSO login. An empty value is an intentional - clear and a genuinely new secret has no mask, so both pass through unchanged. + get_sso_settings), so a client that edits only unrelated fields either + omits the secret or sends the masked placeholder back unchanged. Persisting + either would overwrite e.g. the OAuth client_secret with ``abcd****wxyz`` + and break SSO login (#38177). A secret is restored when the field was not + submitted at all, or when the submitted value is exactly the mask of the + currently effective secret; anything else -- a new secret, ``""`` or + ``None`` -- is an explicit instruction and passes through untouched, so + "Clear SSO Settings" still clears. The effective secret is the stored row value, falling back to the process environment -- the same precedence get_sso_settings uses when it masks the field -- so a secret configured via an environment variable is preserved - too, not only database-stored ones. See #38177. + too, not only database-stored ones. """ + restored: Final[dict[str, object]] = dict(sso_data) for secret_field in SSO_SECRET_FIELDS: db_secret = before_sso_data.get(secret_field) if before_sso_data else None stored_secret = db_secret or os.environ.get(SSO_FIELD_ENV_VARS.get(secret_field, "")) - incoming_secret = sso_data.get(secret_field) - if stored_secret and isinstance(incoming_secret, str) and "*" in incoming_secret: - sso_data[secret_field] = stored_secret # rebind-ok: intentional in-place restore of a masked secret + if not stored_secret: + continue + incoming_secret = restored.get(secret_field) + masked_secret = mask_sensitive_keys({secret_field: stored_secret}, {secret_field})[secret_field] + if secret_field not in submitted_fields or incoming_secret == masked_secret: + restored[secret_field] = stored_secret + return restored @router.patch( @@ -1048,9 +1057,7 @@ async def update_sso_settings( config["general_settings"] = {} # Update environment variables in config and in memory - sso_data: Final = sso_config.model_dump() - - _restore_masked_sso_secrets(sso_data, before_sso_data) + sso_data: Final = _restore_masked_sso_secrets(sso_config.model_dump(), sso_config.model_fields_set, before_sso_data) for field_name, value in sso_data.items(): if field_name in SSO_FIELD_ENV_VARS: env_var_name = SSO_FIELD_ENV_VARS[field_name] @@ -1124,7 +1131,9 @@ async def update_sso_settings( return { "message": "SSO settings updated successfully", "status": "success", - "settings": sso_data, + # Never echo plaintext secrets back; the response is masked the same way + # get_sso_settings masks them. + "settings": mask_sensitive_keys(sso_data, set(SSO_SECRET_FIELDS)), } diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index dc256ccf718..205fc5093cd 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -580,16 +580,15 @@ class TestProxySettingEndpoints: # Verify settings were updated settings = data["settings"] assert settings["google_client_id"] == new_sso_settings["google_client_id"] - assert ( - settings["google_client_secret"] == new_sso_settings["google_client_secret"] - ) + # Secrets are masked in the response; the plaintext is never echoed back. + assert settings["google_client_secret"] != new_sso_settings["google_client_secret"] + assert settings["google_client_secret"].startswith("new_") + assert "****" in settings["google_client_secret"] assert ( settings["microsoft_client_id"] == new_sso_settings["microsoft_client_id"] ) - assert ( - settings["microsoft_client_secret"] - == new_sso_settings["microsoft_client_secret"] - ) + assert settings["microsoft_client_secret"] != new_sso_settings["microsoft_client_secret"] + assert "****" in settings["microsoft_client_secret"] assert settings["proxy_base_url"] == new_sso_settings["proxy_base_url"] assert settings["user_email"] == new_sso_settings["user_email"] diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_sso_secret_backfill.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_sso_secret_backfill.py index d9fc014636a..4a989324864 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_sso_secret_backfill.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_sso_secret_backfill.py @@ -1,8 +1,10 @@ """Regression tests for #38177: a partial SSO settings update must not overwrite -a stored client secret with the masked placeholder the UI sends back, while -still allowing an intentional clear and a genuinely new secret.""" +a stored client secret with the masked placeholder the UI sends back (or lose +it when the field is omitted), while still allowing an intentional clear, a +genuinely new secret, and never echoing the plaintext secret in the response.""" import json +import os from unittest.mock import AsyncMock, MagicMock import pytest @@ -81,6 +83,8 @@ def test_database_stored_secret_is_preserved(mock_auth, monkeypatch): stored = _stored_secret(mock_prisma) assert stored["generic_client_secret"] == REAL_SECRET assert stored["proxy_base_url"] == "https://new.example.com" + # The restored plaintext must not leak back to the caller. + assert resp.json()["settings"]["generic_client_secret"] == _masked(REAL_SECRET) def test_environment_sourced_secret_is_preserved(mock_auth, monkeypatch): @@ -140,3 +144,55 @@ def test_new_secret_is_saved(mock_auth, monkeypatch): stored = _stored_secret(mock_prisma) assert stored["generic_client_secret"] == "brand_new_secret_9999" + + +def test_omitted_secret_is_preserved(mock_auth, monkeypatch): + """A partial payload that leaves the secret out entirely must keep it.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.delenv("GENERIC_CLIENT_SECRET", raising=False) + mock_prisma = _mock_prisma( + monkeypatch, + {"generic_client_id": "cid", "generic_client_secret": REAL_SECRET}, + ) + + resp = client.patch("/update/sso_settings", json={"generic_client_id": "cid", "proxy_base_url": "https://x"}) + assert resp.status_code == 200 + + assert _stored_secret(mock_prisma)["generic_client_secret"] == REAL_SECRET + + +def test_explicit_null_clears_intentionally(mock_auth, monkeypatch): + """The dashboard's "Clear SSO Settings" sends null; that must clear, not restore.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.delenv("GENERIC_CLIENT_SECRET", raising=False) + mock_prisma = _mock_prisma( + monkeypatch, + {"generic_client_id": "cid", "generic_client_secret": REAL_SECRET}, + ) + + resp = client.patch("/update/sso_settings", json={"generic_client_id": "cid", "generic_client_secret": None}) + assert resp.status_code == 200 + + assert _stored_secret(mock_prisma)["generic_client_secret"] is None + assert "GENERIC_CLIENT_SECRET" not in os.environ + + +def test_new_secret_containing_asterisk_is_saved(mock_auth, monkeypatch): + """Only the exact mask of the stored secret counts as "unchanged".""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.delenv("GENERIC_CLIENT_SECRET", raising=False) + mock_prisma = _mock_prisma( + monkeypatch, + {"generic_client_id": "cid", "generic_client_secret": REAL_SECRET}, + ) + + resp = client.patch( + "/update/sso_settings", json={"generic_client_id": "cid", "generic_client_secret": "new*secret*with*stars"} + ) + assert resp.status_code == 200 + + assert _stored_secret(mock_prisma)["generic_client_secret"] == "new*secret*with*stars" + assert resp.json()["settings"]["generic_client_secret"] != "new*secret*with*stars" From c65beba29962bf94ae4a472f58756b2957d2fd88 Mon Sep 17 00:00:00 2001 From: Harshit1259 Date: Sat, 29 Aug 2026 22:39:26 +0530 Subject: [PATCH 3/3] fix: satisfy type-discipline gate in SSO secret restore helper Final locals, immutable Mapping/AbstractSet parameters, per-field helper, and mutable-ok reasons on the two spots that must be dict/set because mask_sensitive_keys and _encrypt_env_variables take those types. --- .../proxy_setting_endpoints.py | 65 ++++++++++++------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index e477164ea86..826751706bf 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -959,40 +959,55 @@ async def get_sso_settings(): return result +def _restored_sso_secret( + secret_field: str, + incoming_secret: object, + submitted_fields: AbstractSet[str], + before_sso_data: Mapping[str, object] | None, +) -> object: + """Return the value to persist for one SSO secret field. + + The stored secret is kept when the client could not have meant to change it: + the field was not submitted at all, or the submitted value is exactly the + mask of the currently effective secret. Anything else -- a new secret, + ``""`` or ``None`` -- is an explicit instruction and passes through, so + "Clear SSO Settings" still clears. The effective secret is the stored row + value, falling back to the process environment (the precedence + get_sso_settings uses when it masks the field). + """ + db_secret: Final = before_sso_data.get(secret_field) if before_sso_data else None + stored_secret: Final = db_secret or os.environ.get(SSO_FIELD_ENV_VARS.get(secret_field, "")) + if not stored_secret: + return incoming_secret + masked: Final = mask_sensitive_keys({secret_field: stored_secret}, {secret_field}) # mutable-ok: dict/set API + masked_secret: Final = masked[secret_field] + if secret_field not in submitted_fields or incoming_secret == masked_secret: + return stored_secret + return incoming_secret + + def _restore_masked_sso_secrets( sso_data: Mapping[str, object], submitted_fields: AbstractSet[str], before_sso_data: Mapping[str, object] | None, -) -> dict[str, object]: +) -> dict[str, object]: # mutable-ok: dict API downstream """Return a copy of ``sso_data`` with stored SSO secrets restored where the - client could not have meant to change them. + client could not have meant to change them (#38177); see _restored_sso_secret. Secret fields are masked before they are sent to the UI (see get_sso_settings), so a client that edits only unrelated fields either - omits the secret or sends the masked placeholder back unchanged. Persisting + omits the secret or sends the masked placeholder back unchanged; persisting either would overwrite e.g. the OAuth client_secret with ``abcd****wxyz`` - and break SSO login (#38177). A secret is restored when the field was not - submitted at all, or when the submitted value is exactly the mask of the - currently effective secret; anything else -- a new secret, ``""`` or - ``None`` -- is an explicit instruction and passes through untouched, so - "Clear SSO Settings" still clears. - - The effective secret is the stored row value, falling back to the process - environment -- the same precedence get_sso_settings uses when it masks the - field -- so a secret configured via an environment variable is preserved - too, not only database-stored ones. + and break SSO login. """ - restored: Final[dict[str, object]] = dict(sso_data) - for secret_field in SSO_SECRET_FIELDS: - db_secret = before_sso_data.get(secret_field) if before_sso_data else None - stored_secret = db_secret or os.environ.get(SSO_FIELD_ENV_VARS.get(secret_field, "")) - if not stored_secret: - continue - incoming_secret = restored.get(secret_field) - masked_secret = mask_sensitive_keys({secret_field: stored_secret}, {secret_field})[secret_field] - if secret_field not in submitted_fields or incoming_secret == masked_secret: - restored[secret_field] = stored_secret - return restored + return { # mutable-ok: fresh copy; the original request mapping is never mutated + field_name: ( + _restored_sso_secret(field_name, value, submitted_fields, before_sso_data) + if field_name in SSO_SECRET_FIELDS + else value + ) + for field_name, value in sso_data.items() + } @router.patch( @@ -1133,7 +1148,7 @@ async def update_sso_settings( "status": "success", # Never echo plaintext secrets back; the response is masked the same way # get_sso_settings masks them. - "settings": mask_sensitive_keys(sso_data, set(SSO_SECRET_FIELDS)), + "settings": mask_sensitive_keys(sso_data, set(SSO_SECRET_FIELDS)), # mutable-ok: set API }