Skip to content

fix(kernel/oauth): classify refresh failures, single-flight refresh, drop unwrap#5609

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

fix(kernel/oauth): classify refresh failures, single-flight refresh, drop unwrap#5609
houko merged 2 commits into
mainfrom
fix/oauth-refresh-token-leak

Conversation

@houko

@houko houko commented May 22, 2026

Copy link
Copy Markdown
Contributor

Hardens the MCP OAuth refresh path (librefang-kernel::mcp_oauth_provider). Implements the remaining three sub-findings of docs/issues/oauth-refresh-error-body-token-leak.md; the log-sanitization sub-finding already landed in #5526 (closed #5525), which explicitly deferred these. Internal audit.

Changes

  • Error classificationtry_refresh returned Result<OAuthTokens, String> and load_token collapsed every failure (503, DNS blip, invalid_grant) into Ok(None), which the connection layer reads as "no token, re-auth" — discarding a still-valid refresh token on a transient outage. New RefreshError { Revoked, Transient, Permanent }: 400 invalid_grant -> Revoked (the only re-auth signal, mapped to Ok(None)); 5xx / timeout / transport -> Transient (kept for retry); else -> Permanent. Transient/Permanent surface as the new McpOAuthError::RefreshFailed so the refresh token is retained. Only the short OAuth error code is parsed (as a 'static constant); no token-shaped body field is ever read or logged.
  • Single-flight refresh — rotating-refresh-token providers (Google, GitHub Apps, Notion) invalidate the old refresh token on each refresh; concurrent load_token calls that raced past the expiry check both POSTed the same soon-to-be-rotated token, burning a valid session. Added 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, load_token re-reads expires_at from the vault and returns the peer's freshly-stored token without a second network POST.
  • Unwrap removalfind_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.
  • Handle the new McpOAuthError::RefreshFailed variant in the exhaustive auth_revoke match (compile-time guard, not silent fallthrough).

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, incl. new:
    • classify_400_invalid_grant_is_revoked
    • classify_503_is_transient
    • classify_400_other_error_is_permanent
    • classify_error_message_does_not_leak_body
    • oauth_error_code_extracts_only_the_code
    • refresh_lock_is_shared_per_server_and_isolated_across_servers
    • refresh_recheck_returns_already_refreshed_token_without_network
    • load_token_expired_without_refresh_token_yields_none
  • cargo test -p librefang-api --lib oauth (23) and mcp_auth (34) — passed.
  • Clippy on the four changed files is clean. (The 6 errors a local clippy 1.95 reports are all in untouched files — validation.rs, webchat.rs, routes/registry.rs — identical on origin/main; they are CI-pinned-toolchain noise, not introduced here.)

Note on single-flight test

A full end-to-end "two concurrent load_token -> one HTTP call" test cannot run against loopback: try_refresh uses the strict is_ssrf_blocked_url guard, which blocks 127.0.0.1, and no mock-HTTP dev-dependency exists in the kernel crate (none was added — per the "no new deps" constraint). Instead the single-flight guarantee is proven by two faithful unit tests against the real load_token path: the lock map returns the same Arc<Mutex> per server_url (so concurrent refreshers serialize), and the post-lock recheck returns an already-refreshed token without any network call (the exact branch that makes the second concurrent caller skip the HTTP POST).

Closes #5608.

houko added 2 commits May 22, 2026 22:45
…drop unwrap

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)
@houko
houko enabled auto-merge (squash) May 22, 2026 14:24
@houko
houko merged commit 70e40d0 into main May 22, 2026
@houko
houko deleted the fix/oauth-refresh-token-leak branch May 22, 2026 14:24
@github-actions github-actions Bot added area/runtime Agent loop, LLM drivers, WASM sandbox area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed labels May 22, 2026
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) area/runtime Agent loop, LLM drivers, WASM sandbox size/L 250-999 lines changed

Projects

None yet

1 participant