Skip to content

[High] OAuth refresh path (mcp_oauth_provider.rs::try_refresh) hardening — error classification, single-flight, unwrap #5608

Description

@houko

Severity: High
Category: Error handling · Concurrency · Robustness
Source: internal audit (docs/issues/oauth-refresh-error-body-token-leak.md)

The MCP OAuth refresh path in crates/librefang-kernel/src/mcp_oauth_provider.rs rolls up four sub-findings. The log-sanitization sub-finding already landed in #5526 (closes #5525). This issue tracks the remaining three, which #5526 explicitly left for follow-up.

Sub-findings (remaining)

Severity Description
Medium try_refresh collapsed transient (5xx / timeout) and permanent (invalid_grant / revoked) outcomes into a single Ok(None); callers wrongly conclude they must re-auth, throwing away a still-valid refresh token on a transient outage
Medium Concurrent refreshes lacked a per-server_url single-flight; rotating-refresh-token providers (Google, GitHub Apps, Notion) issue a new refresh token on each refresh and invalidate the old one, so two racing load_token calls burn a session that was still valid
Low find_any_with_refresh -> entry.refresh_token.unwrap() relied on a documentation invariant; easy to panic under refactoring

Affected code

  • crates/librefang-kernel/src/mcp_oauth_provider.rstry_refresh body and the load_token caller that collapses errors to Ok(None); no single-flight lock.
  • crates/librefang-api/src/oauth.rsfind_any_with_refresh returning Option<(String, StoredTokens)> and the entry.refresh_token.unwrap() at the auth_refresh callsite.

Why grouped

All three live inside the same function or its immediate call chain; any fix touches the same code. Tracking them separately would force multiple PRs against one function — see the rollup doc.

Combined fix

  1. Error classification — introduce RefreshError { Revoked, Transient, Permanent }. Classify: 400 invalid_grant -> Revoked (the only outcome that flips back to a re-auth prompt, Ok(None)); 5xx / timeout / transport -> Transient (keep the refresh token, retry later); else -> Permanent. Transient/Permanent surface as a new McpOAuthError::RefreshFailed so the connection layer keeps the refresh token instead of discarding it. Only the short OAuth error code is parsed from the body — never the token-shaped fields.
  2. Single-flight — process-global per-server_url lock (KernelOAuthProvider is stateless and rebuilt per request, so the lock must be static to serialize across instances). After acquiring the lock, re-read expires_at from the vault and return the peer's freshly-stored token without a second refresh POST.
  3. Eliminate the unwrapfind_any_with_refresh returns the refresh token as a non-optional String; the Some(..) arm becomes the type-level proof.

PR closing this issue follows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/kernelCore kernel (scheduling, RBAC, workflows)area/runtimeAgent loop, LLM drivers, WASM sandboxarea/securitySecurity systems and auditingbugSomething isn't workinghas-prA pull request has been linked to this issueneeds-infoIssue lacks reproduction steps, logs, or version infosecuritySecurity issues

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions