You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
auth: tokens that live 30s or less are refreshed on every request #1442
AuthorizationManager::get_access_token refreshes when fewer than REFRESH_BUFFER_SECS (30) seconds remain. When the server issues an access token with expires_in of 30 or less, the token that refresh_token just saved is already inside that window, so the next get_access_token refreshes again. Every request then costs a token request, and with refresh token rotation a new refresh token each time.
Short lifetimes like this mostly show up in test setups and some short-lived-token deployments, but the client should still work against them.
A small fix is to cap the buffer at half the token's lifetime:
let buffer = Self::REFRESH_BUFFER_SECS.min(expires_in.as_secs() / 2);if remaining < buffer {
so long-lived tokens behave as today and a 20s token is refreshed after 10s instead of immediately. A test with make_token_response("t", Some(20)) received now should return the token without a refresh request.
Happy to send a PR if that approach works for you.
cargo test -p rmcp --lib --all-features -- get_access_token_returns_short_lived_token_without_refreshing
→ FAILED: called Result::unwrap() on an Err value: AuthorizationRequired (no refresh token configured, so the refresh attempt surfaces as re-auth required). The token was received zero seconds ago.
Mechanism, for the record: REFRESH_BUFFER_SECS = 30 (crates/rmcp/src/transport/auth.rs:2470), remaining < REFRESH_BUFFER_SECS at :2494, and a refresh writes token_received_at: Some(Self::now_epoch_secs()) at :2618. So every refreshed short-lived token is stored as "received now" and is immediately inside the window again — one token request per call, and with rotation a new refresh token each time.
On the proposed fix
buffer = REFRESH_BUFFER_SECS.min(expires_in / 2) is the right shape: 3600 s stays at 30 s, 30 s refreshes at 15 s remaining, 20 s refreshes at 10 s.
Edge case that needs the extra clause: with expires_in <= 1, the buffer becomes 0, and remaining saturates at 0, so 0 < 0 is false — an expired token is then returned forever and nothing refreshes or re-auths. That would be a worse bug than the one being fixed (silent 401s with no re-auth path). Use:
I tried exactly that on main: 20 s token received now returns without refreshing, expires_in = 20 received 25 s ago still attempts a refresh, and the rest of auth::tests stays green (260 passed, 0 failed).
A max(1) on the buffer would also unbreak the expired case, but it re-introduces "refresh every call" for expires_in = 0; the remaining == 0 clause keeps today's behaviour there, which is the only thing possible when the server issues an unusable token.
Tests worth shipping with the PR
make_token_response("t", Some(20)) + token_received_at: now → Ok("t") (the test above; red on main).
Same, received_at: now - 25 → refresh is attempted (no refresh token ⇒ AuthorizationRequired) — pins that actual expiry still refreshes.
Some(1) + now - 5 → still refreshes (the remaining == 0 clause).
Labels: bug / P2 / T-security look right to me — correctness bug, not a vulnerability; the impact is token-endpoint churn and rotation pressure.
Yes, please send the PR — I'll review it at the pushed head. If you'd rather hand it off, say so and I'll open one.
Activity
github-actions commentedon Nov 10, 2026
@jokemanfire is on triage rotation this week and is the reviewer for this one.
Codex labeled this bug / P2 (T-security). Please confirm or correct those labels.
Automated triage. Labels are a first pass and the assigned maintainer has the final say.
jokemanfire commentedon Nov 11, 2026
I reproduced this on
main(f2b0ddf). Your diagnosis is right, and the fix direction is right — one edge case needs an extra clause.Repro (red on main)
The test you proposed, added next to the existing buffer tests:
cargo test -p rmcp --lib --all-features -- get_access_token_returns_short_lived_token_without_refreshing→ FAILED:
called Result::unwrap() on an Err value: AuthorizationRequired(no refresh token configured, so the refresh attempt surfaces as re-auth required). The token was received zero seconds ago.Mechanism, for the record:
REFRESH_BUFFER_SECS = 30(crates/rmcp/src/transport/auth.rs:2470),remaining < REFRESH_BUFFER_SECSat:2494, and a refresh writestoken_received_at: Some(Self::now_epoch_secs())at:2618. So every refreshed short-lived token is stored as "received now" and is immediately inside the window again — one token request per call, and with rotation a new refresh token each time.On the proposed fix
buffer = REFRESH_BUFFER_SECS.min(expires_in / 2)is the right shape: 3600 s stays at 30 s, 30 s refreshes at 15 s remaining, 20 s refreshes at 10 s.Edge case that needs the extra clause: with
expires_in <= 1, the buffer becomes0, andremainingsaturates at0, so0 < 0is false — an expired token is then returned forever and nothing refreshes or re-auths. That would be a worse bug than the one being fixed (silent 401s with no re-auth path). Use:I tried exactly that on
main: 20 s token received now returns without refreshing,expires_in = 20received 25 s ago still attempts a refresh, and the rest ofauth::testsstays green (260 passed, 0 failed).A
max(1)on the buffer would also unbreak the expired case, but it re-introduces "refresh every call" forexpires_in = 0; theremaining == 0clause keeps today's behaviour there, which is the only thing possible when the server issues an unusable token.Tests worth shipping with the PR
make_token_response("t", Some(20))+token_received_at: now→Ok("t")(the test above; red onmain).received_at: now - 25→ refresh is attempted (no refresh token ⇒AuthorizationRequired) — pins that actual expiry still refreshes.Some(1)+now - 5→ still refreshes (theremaining == 0clause).Labels:
bug/P2/T-securitylook right to me — correctness bug, not a vulnerability; the impact is token-endpoint churn and rotation pressure.Yes, please send the PR — I'll review it at the pushed head. If you'd rather hand it off, say so and I'll open one.