From eb188752d2422ad9a07337b6c3d482949318887b Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 25 Aug 2026 14:21:44 +0000 Subject: [PATCH] fix(oauth): keep using valid MCP access tokens without a refresh token (#11324) --- crates/goose/src/oauth/mod.rs | 63 ++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/goose/src/oauth/mod.rs b/crates/goose/src/oauth/mod.rs index 07b7214fa..fc3271d8f 100644 --- a/crates/goose/src/oauth/mod.rs +++ b/crates/goose/src/oauth/mod.rs @@ -17,7 +17,7 @@ use serde::Deserialize; use std::collections::BTreeSet; use std::net::SocketAddr; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::{oneshot, Mutex}; use tracing::warn; @@ -202,6 +202,26 @@ fn resolve_refreshed_granted_scopes( token_scopes.unwrap_or_else(|| previous_granted_scopes.to_vec()) } +const REFRESH_BUFFER_SECS: u64 = 30; + +fn access_token_needs_refresh(stored_credentials: &StoredCredentials) -> bool { + let Some(token_response) = stored_credentials.token_response.as_ref() else { + return true; + }; + let (Some(expires_in), Some(received_at)) = ( + token_response.expires_in(), + stored_credentials.token_received_at, + ) else { + return false; + }; + let elapsed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_sub(received_at); + expires_in.as_secs().saturating_sub(elapsed) < REFRESH_BUFFER_SECS +} + fn configure_static_client( auth_manager: &mut AuthorizationManager, static_client: Option<&StaticOAuthClientConfig>, @@ -331,6 +351,9 @@ pub async fn oauth_flow_with_challenge( // client_id alone; a confidential client must present its secret // at the token endpoint for the refresh to succeed. configure_static_client(&mut auth_manager, static_client, mcp_server_url)?; + if !access_token_needs_refresh(stored_credentials) { + return Ok(auth_manager); + } match auth_manager.refresh_token().await { Ok(mut token_response) => { let restored_omitted_scopes = @@ -797,6 +820,44 @@ mod tests { assert_eq!(request.scopes, vec!["scope.read", "scope.write"]); } + #[test] + fn long_lived_token_without_refresh_token_is_not_refreshed() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let token_response: OAuthTokenResponse = serde_json::from_value(serde_json::json!({ + "access_token": "mcp_long_lived", + "token_type": "bearer", + "expires_in": 31_536_000, + })) + .unwrap(); + let stored = StoredCredentials::new( + "client-id".to_string(), + Some(token_response.clone()), + vec![], + Some(now), + ); + + assert!(token_response.refresh_token().is_none()); + assert!( + !access_token_needs_refresh(&stored), + "a token valid for another year must be used as-is instead of forcing browser re-auth" + ); + + let expired = StoredCredentials::new( + "client-id".to_string(), + Some(token_response), + vec![], + Some(now - 31_536_000), + ); + assert!( + access_token_needs_refresh(&expired), + "an expired token must still take the refresh path" + ); + } + #[tokio::test] async fn wait_for_callback_times_out_with_authorization_url() { let (_sender, receiver) = oneshot::channel();