fix(auth): redact the encoded form of a client secret as well as the raw one

client_secret_basic sends base64 of "id:secret", and an endpoint that echoes that
blob back hands over material that decodes straight to the secret. Redaction only
ever compared the raw value, so the encoded form reached the caller and the
telemetry intact.

The redactor now takes every shape the credential went out in, and the Keycloak
source declares both, so an echo of either is caught. Only client_secret_basic
carries the encoded form; client_secret_post sends the secret as a plain field.
This commit is contained in:
derhornspieler 2026-08-25 07:50:26 -04:00
parent d51d3eba44
commit e14811bdbf
2 changed files with 37 additions and 6 deletions

View file

@ -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}"

View file

@ -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: