Skip to content

fix(security/oauth): never log raw IdP token-endpoint response bodies#5526

Merged
houko merged 2 commits into
mainfrom
fix/oauth-refresh-log-sanitization
May 22, 2026
Merged

fix(security/oauth): never log raw IdP token-endpoint response bodies#5526
houko merged 2 commits into
mainfrom
fix/oauth-refresh-log-sanitization

Conversation

@houko

@houko houko commented May 22, 2026

Copy link
Copy Markdown
Contributor

Closes #5525

Summary

The OAuth-refresh failure log path in crates/librefang-kernel/src/mcp_oauth_provider.rs and the 2xx-parse-failure body_preview (plus the immediately preceding non-success body_preview) in crates/librefang-api/src/routes/mcp_auth.rs previously logged the IdP token-endpoint response body verbatim, which could emit access_token / refresh_token / id_token / client_secret in clear text into the operator's logs.

Replaced all three with a shared redact_token_endpoint_response(status, content_type, body) helper that produces a RedactedTokenEndpointResponse { status, content_type, body_sha256_prefix, body_len } value for tracing. The body itself never reaches the log. Both Display and Debug impls are sanitized, so %redacted and ?redacted are equally safe.

The hex digest prefix lets operators correlate two log lines that saw the same body without ever holding the body itself.

Files changed

  • crates/librefang-kernel/src/mcp_oauth_provider.rs — added RedactedTokenEndpointResponse + redact_token_endpoint_response helper (pub), updated try_refresh call site, and added three regression tests.
  • crates/librefang-api/src/routes/mcp_auth.rs — updated both body_preview log sites (non-success status branch and 2xx parse-failure branch) to use the kernel helper; added a regression test that exercises both call shapes.

Out of scope

The other three sub-findings of docs/issues/oauth-refresh-error-body-token-leak.md (error classification refactor, per-server single-flight refresh lock, refresh_token.unwrap() removal) are intentionally separate — each warrants its own focused review.

Verification

  • cargo check -p librefang-kernel -p librefang-api --lib — clean.
  • cargo clippy -p librefang-kernel -p librefang-api --lib --all-targets -- -D warnings — clean.
  • cargo test -p librefang-kernel --lib mcp_oauth_provider — 15 passed (3 new: redact_token_endpoint_response_strips_token_fields, redact_token_endpoint_response_digest_is_stable, warn_emission_does_not_contain_raw_token_body).
  • cargo test -p librefang-api --lib mcp_auth::tests::callback_body_preview_redaction_strips_token_fields — passes.

Refs docs/issues/oauth-refresh-error-body-token-leak.md.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed labels May 22, 2026
houko added 2 commits May 22, 2026 20:50
…#5525)

The OAuth refresh path
(`crates/librefang-kernel/src/mcp_oauth_provider.rs::try_refresh`) and the
OAuth callback handler
(`crates/librefang-api/src/routes/mcp_auth.rs`) previously logged the
token-endpoint response body verbatim via `warn!` / `error!`. When the
body contained `access_token` / `refresh_token` / `id_token` /
`client_secret` (legitimately for IdPs that echo session state on
`invalid_grant`, or adversarially for malicious IdPs designed to plant
secrets in operator logs) the secret was persisted to whatever log
aggregator the daemon ships to — typically a SIEM with retention
longer than the OAuth flow itself.

Replaces both sites with a shared
`redact_token_endpoint_response(status, content_type, body)` helper in
`librefang-kernel::mcp_oauth_provider`. It returns a
`RedactedTokenEndpointResponse { status, content_type,
body_sha256_prefix, body_len }`; the raw body never leaves the helper.
Both `Display` and `Debug` impls are sanitized so a `tracing::warn!`
with `%redacted` or `?redacted` is equally safe. The hex digest prefix
lets operators correlate two log lines that saw the same body without
ever seeing the body itself.

Three regression tests asserting that secrets like `super-secret-12345`
do not appear in the captured log output across both the kernel
`try_refresh` call shape and the API callback's 2xx-parse-failure and
non-success call shapes.

The other three sub-findings of the rollup
(`docs/issues/oauth-refresh-error-body-token-leak.md` — error
classification, single-flight refresh lock, `refresh_token.unwrap()`
removal) are intentionally separate and tracked separately.

Refs #5525.
`KernelOAuthProvider::register_client` (RFC 7591 Dynamic Client
Registration) had the same body-interpolation pattern as the
token-endpoint refresh path: on non-success status the raw response
body was both returned in the `Err(String)` (which surfaces to
`McpAuthState::Error.message` in the dashboard) and logged via
`tracing::warn!(error = %e, "Dynamic Client Registration failed")`
in `routes::mcp_auth::auth_start`.

RFC 7591 §3.2.1 allows the registration response (including error
bodies) to contain `client_secret`, and an adversarial AS can plant
any token-shaped value here just as easily as on the token endpoint —
the threat model is identical. Route both emissions through the
existing `redact_token_endpoint_response` helper instead of the
verbatim body. Added `dcr_failure_does_not_leak_raw_body_into_error_or_log`
to lock in the behaviour: the returned `Err` keeps the HTTP status but
no body, and a `tracing::warn!` capture across the call shape contains
the sanitized digest but none of the token-shaped substrings.

Verified: cargo check -p librefang-kernel --lib,
cargo clippy -p librefang-kernel --lib -- -D warnings,
cargo test -p librefang-kernel --lib mcp_oauth_provider — 16/16 passing,
cargo test -p librefang-api --lib mcp_auth::tests::callback_body_preview_redaction_strips_token_fields — passing.
@houko
houko force-pushed the fix/oauth-refresh-log-sanitization branch from 01efa76 to 139830a Compare May 22, 2026 12:18
@houko
houko merged commit 06c9bb9 into main May 22, 2026
7 checks passed
@houko
houko deleted the fix/oauth-refresh-log-sanitization branch May 22, 2026 12:18
houko added a commit that referenced this pull request May 22, 2026
…drop unwrap (#5609)

Hardens the MCP OAuth refresh path
(`librefang-kernel::mcp_oauth_provider`) — the remaining three
sub-findings of `docs/issues/oauth-refresh-error-body-token-leak.md`
after the log-sanitization sub-finding landed in #5526. Internal audit.

Error classification: `try_refresh` previously returned
`Result<OAuthTokens, String>` and `load_token` collapsed every failure
— a 503, a DNS blip, an `invalid_grant` — into `Ok(None)`, which the
connection layer reads as "no token, re-auth". That throws away a
still-valid refresh token on a transient outage. Introduce a
`RefreshError { Revoked, Transient, Permanent }` enum and classify:
`400 invalid_grant` -> Revoked (the only re-auth signal, mapped to
`Ok(None)`); 5xx / timeout / transport error -> Transient (kept for
retry); everything else -> Permanent. Transient/Permanent now surface as
the new `McpOAuthError::RefreshFailed` so the refresh token is retained
rather than discarded. The OAuth `error` code is parsed only as a short
`'static` code; no token-shaped body field is ever read or logged.

Single-flight: rotating-refresh-token providers (Google, GitHub Apps,
Notion) invalidate the old refresh token on every refresh. Concurrent
`load_token` calls that raced past the expiry check both POSTed the same
soon-to-be-rotated token, burning a valid session. Add a process-global
per-`server_url` lock (`KernelOAuthProvider` is stateless and rebuilt
per request, so an instance field would not serialize across callers).
After acquiring the lock, re-read `expires_at` from the vault and return
the peer's freshly-stored token without a second network POST.

Unwrap removal: `find_any_with_refresh` now returns the refresh token as
a non-optional `String`, so `entry.refresh_token.unwrap()` in
`auth_refresh` is gone — the `Some(..)` arm is the type-level proof.

Tests (kernel, next to the code): 400 invalid_grant -> Revoked;
503/502 -> Transient; non-invalid_grant 4xx and bare 401 -> Permanent;
classified error Display does not leak token-shaped body fields;
`oauth_error_code` extracts only the code; the single-flight lock is
shared per server_url and isolated across servers; the recheck branch
returns an already-refreshed token without touching the network; an
expired token with no refresh token yields Ok(None). The unwrap removal
is a compile-time proof.

Verification:
- cargo check -p librefang-kernel -p librefang-api -p librefang-runtime-mcp
- cargo check --workspace --lib
- cargo test -p librefang-kernel --lib mcp_oauth_provider (24 passed)
- cargo test -p librefang-api --lib oauth (23) and mcp_auth (34) passed
- cargo clippy on changed files clean (pre-existing 1.95 lints in
  untouched validation.rs/webchat.rs/registry.rs are CI-pinned-toolchain
  noise, identical on origin/main)
f-liva pushed a commit to f-liva/librefang that referenced this pull request May 25, 2026
Adds `McpOAuthConfig.client_secret_env` field so operators can declare
which environment variable holds the OAuth client secret for confidential
providers (e.g. Google Workspace MCP servers that reject refresh
requests without `client_secret`).

Flow: auth_start resolves env var -> persists to vault -> auth_callback
and try_refresh include client_secret in form params. Pure PKCE clients
(client_secret_env unset) are unaffected.

Addresses all review blockers from PR librefang#5060 v1:
- Ground-up rebase on upstream/main preserves librefang#5609 (RefreshError enum,
  single-flight lock, classify_refresh_failure), librefang#5526 (DCR body
  redaction via redact_token_endpoint_response), and librefang#5069 regression
  test (vault_set_twice_round_trips_via_env_key)
- Bug-D ordering: DCR incompatibility check runs BEFORE vault_set so no
  stale secret is persisted on misconfiguration
- New integration test: client_secret_env_vault_round_trip pins the
  vault storage contract
- Golden fixture regenerated against current main
houko added a commit that referenced this pull request Jun 21, 2026
…5060) (#6260)

* feat(mcp-oauth): add client_secret_env for confidential OAuth clients

Adds `McpOAuthConfig.client_secret_env` field so operators can declare
which environment variable holds the OAuth client secret for confidential
providers (e.g. Google Workspace MCP servers that reject refresh
requests without `client_secret`).

Flow: auth_start resolves env var -> persists to vault -> auth_callback
and try_refresh include client_secret in form params. Pure PKCE clients
(client_secret_env unset) are unaffected.

Addresses all review blockers from PR #5060 v1:
- Ground-up rebase on upstream/main preserves #5609 (RefreshError enum,
  single-flight lock, classify_refresh_failure), #5526 (DCR body
  redaction via redact_token_endpoint_response), and #5069 regression
  test (vault_set_twice_round_trips_via_env_key)
- Bug-D ordering: DCR incompatibility check runs BEFORE vault_set so no
  stale secret is persisted on misconfiguration
- New integration test: client_secret_env_vault_round_trip pins the
  vault storage contract
- Golden fixture regenerated against current main

* fix(mcp): set client_secret_env in test McpOAuthConfig + refresh secrets baseline

The new client_secret_env field made an OAuth test initializer
non-exhaustive after the main merge; set it to None. Refresh
.secrets.baseline for the new client_secret_env doc-example env var name
(false positive, consistent with the existing example entries).

* fix(mcp): add client_secret_env to mcp_oauth_integration test McpOAuthConfig

The cherry-picked feature adds client_secret_env: Option<String> to McpOAuthConfig.
The struct-literal in crates/librefang-runtime/tests/mcp_oauth_integration.rs constructs the config without ..Default::default(), so the new field is mandatory there too — otherwise E0063 breaks the runtime test-build lanes (the exact blocker flagged on #5060).

---------

Co-authored-by: Federico Liva <[email protected]>
Co-authored-by: Evan <[email protected]>
Co-authored-by: Evan <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sanitize OAuth token-endpoint response bodies in logs (no verbatim access_token / refresh_token / id_token / client_secret)

1 participant