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.rs — try_refresh body and the load_token caller that collapses errors to Ok(None); no single-flight lock.
crates/librefang-api/src/oauth.rs — find_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
- 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.
- 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.
- Eliminate the unwrap —
find_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.
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.rsrolls 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)
try_refreshcollapsed transient (5xx / timeout) and permanent (invalid_grant/ revoked) outcomes into a singleOk(None); callers wrongly conclude they must re-auth, throwing away a still-valid refresh token on a transient outageserver_urlsingle-flight; rotating-refresh-token providers (Google, GitHub Apps, Notion) issue a new refresh token on each refresh and invalidate the old one, so two racingload_tokencalls burn a session that was still validfind_any_with_refresh -> entry.refresh_token.unwrap()relied on a documentation invariant; easy to panic under refactoringAffected code
crates/librefang-kernel/src/mcp_oauth_provider.rs—try_refreshbody and theload_tokencaller that collapses errors toOk(None); no single-flight lock.crates/librefang-api/src/oauth.rs—find_any_with_refreshreturningOption<(String, StoredTokens)>and theentry.refresh_token.unwrap()at theauth_refreshcallsite.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
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 newMcpOAuthError::RefreshFailedso the connection layer keeps the refresh token instead of discarding it. Only the short OAutherrorcode is parsed from the body — never the token-shaped fields.server_urllock (KernelOAuthProvideris stateless and rebuilt per request, so the lock must bestaticto serialize across instances). After acquiring the lock, re-readexpires_atfrom the vault and return the peer's freshly-stored token without a second refresh POST.find_any_with_refreshreturns the refresh token as a non-optionalString; theSome(..)arm becomes the type-level proof.PR closing this issue follows.