fix(kernel/oauth): classify refresh failures, single-flight refresh, drop unwrap#5609
Merged
Conversation
…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
enabled auto-merge (squash)
May 22, 2026 14:24
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hardens the MCP OAuth refresh path (
librefang-kernel::mcp_oauth_provider). Implements the remaining three sub-findings ofdocs/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
try_refreshreturnedResult<OAuthTokens, String>andload_tokencollapsed every failure (503, DNS blip,invalid_grant) intoOk(None), which the connection layer reads as "no token, re-auth" — discarding a still-valid refresh token on a transient outage. NewRefreshError { Revoked, Transient, Permanent }:400 invalid_grant-> Revoked (the only re-auth signal, mapped toOk(None)); 5xx / timeout / transport -> Transient (kept for retry); else -> Permanent. Transient/Permanent surface as the newMcpOAuthError::RefreshFailedso the refresh token is retained. Only the short OAutherrorcode is parsed (as a'staticconstant); no token-shaped body field is ever read or logged.load_tokencalls 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_urllock (KernelOAuthProvideris stateless and rebuilt per request, so an instance field would not serialize across callers). After acquiring the lock,load_tokenre-readsexpires_atfrom the vault and returns the peer's freshly-stored token without a second network POST.find_any_with_refreshnow returns the refresh token as a non-optionalString, soentry.refresh_token.unwrap()inauth_refreshis gone; theSome(..)arm is the type-level proof.McpOAuthError::RefreshFailedvariant in the exhaustiveauth_revokematch (compile-time guard, not silent fallthrough).Verification
cargo check -p librefang-kernel -p librefang-api -p librefang-runtime-mcpcargo check --workspace --libcargo test -p librefang-kernel --lib mcp_oauth_provider— 24 passed, incl. new:classify_400_invalid_grant_is_revokedclassify_503_is_transientclassify_400_other_error_is_permanentclassify_error_message_does_not_leak_bodyoauth_error_code_extracts_only_the_coderefresh_lock_is_shared_per_server_and_isolated_across_serversrefresh_recheck_returns_already_refreshed_token_without_networkload_token_expired_without_refresh_token_yields_nonecargo test -p librefang-api --lib oauth(23) andmcp_auth(34) — passed.validation.rs,webchat.rs,routes/registry.rs— identical onorigin/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_refreshuses the strictis_ssrf_blocked_urlguard, which blocks127.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 realload_tokenpath: the lock map returns the sameArc<Mutex>perserver_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.