diff --git a/litellm/llms/base_llm/auth/client_credentials.py b/litellm/llms/base_llm/auth/client_credentials.py index d25dc86a535..ec312bc63bd 100644 --- a/litellm/llms/base_llm/auth/client_credentials.py +++ b/litellm/llms/base_llm/auth/client_credentials.py @@ -145,8 +145,23 @@ def _resolve_client_secret(config: KeycloakSource, secret_reader: SecretReader) return secret +def _wire_forms_of_secret(config: KeycloakSource, client_secret: str) -> tuple[SecretStr, ...]: + """Every shape the secret leaves this process in, so an echo of any of them is caught. + + client_secret_basic sends base64 of ``id:secret``, which decodes straight back to the secret, + so an endpoint echoing that blob hands over reversible material that a raw comparison misses. + """ + raw: Final = SecretStr(client_secret) + if config.auth_method != "client_secret_basic": + return (raw,) + encoded_pair: Final = f"{_form_encode(config.client_id)}:{_form_encode(client_secret)}" + return (raw, SecretStr(base64.b64encode(encoded_pair.encode()).decode("ascii"))) + + def _endpoint_error_message(config: KeycloakSource, response: httpx.Response, client_secret: str) -> str: - endpoint_error: Final = redact_oauth_error_body(response.status_code, response.text, SecretStr(client_secret)) + endpoint_error: Final = redact_oauth_error_body( + response.status_code, response.text, _wire_forms_of_secret(config, client_secret) + ) return ( f"keycloak token endpoint {endpoint_url_for_error_message(config.token_url)} " f"returned HTTP {endpoint_error.status_code}: {endpoint_error.redacted_body}" diff --git a/litellm/llms/base_llm/auth/token_exchange.py b/litellm/llms/base_llm/auth/token_exchange.py index de10108fd23..e0cab3f4ad2 100644 --- a/litellm/llms/base_llm/auth/token_exchange.py +++ b/litellm/llms/base_llm/auth/token_exchange.py @@ -13,7 +13,7 @@ import json import re import threading import time -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import Callable, Coroutine, Mapping, Sequence from concurrent.futures import Executor, ThreadPoolExecutor from dataclasses import dataclass from math import inf @@ -116,12 +116,28 @@ def validate_token_endpoint_url(url: str) -> str | InsecureTokenUrl: return InsecureTokenUrl(host=parsed.hostname or "") -def redact_oauth_error_body(status_code: int, body_text: str, assertion: SecretStr | None = None) -> TokenEndpointError: +def redact_oauth_error_body( + status_code: int, + body_text: str, + assertion: SecretStr | Sequence[SecretStr] | None = None, +) -> TokenEndpointError: + """``assertion`` may be every form of the credential that went out on the wire. + + A grant that encodes its credential before sending it (``client_secret_basic`` base64s + ``id:secret``) can have that encoded form echoed back, and it decodes straight to the secret, + so checking only the raw value lets reversible material through. + """ rendered: Final = _redact_body_text(body_text) - return TokenEndpointError( - status_code=status_code, - redacted_body=_drop_reflected_assertion(rendered, assertion), + secrets: Final = () if assertion is None else (assertion,) if isinstance(assertion, SecretStr) else tuple(assertion) + redacted: Final = next( + ( + _REFLECTED_VALUE_MESSAGE + for secret in secrets + if _drop_reflected_assertion(rendered, secret) is _REFLECTED_VALUE_MESSAGE + ), + rendered, ) + return TokenEndpointError(status_code=status_code, redacted_body=redacted) def _drop_reflected_assertion(rendered: str, assertion: SecretStr | None) -> str: