From b7ac01bea5a4995a798ef12ae327aaad7d05e7ee Mon Sep 17 00:00:00 2001 From: y4ho0 <119646314+y4ho0@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:31:05 +0800 Subject: [PATCH] fix(auth): refresh cached tokens without expiry --- .changeset/refresh-tokens-without-expiry.md | 5 +++ .../google-workspace-cli/src/token_storage.rs | 43 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .changeset/refresh-tokens-without-expiry.md diff --git a/.changeset/refresh-tokens-without-expiry.md b/.changeset/refresh-tokens-without-expiry.md new file mode 100644 index 000000000..c96c724c6 --- /dev/null +++ b/.changeset/refresh-tokens-without-expiry.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Refresh cached OAuth access tokens whose expiration time is missing instead of reusing them indefinitely. diff --git a/crates/google-workspace-cli/src/token_storage.rs b/crates/google-workspace-cli/src/token_storage.rs index 5fb17a006..f4a9ca615 100644 --- a/crates/google-workspace-cli/src/token_storage.rs +++ b/crates/google-workspace-cli/src/token_storage.rs @@ -149,7 +149,12 @@ impl TokenStorage for EncryptedTokenStorage { if let Some(map) = map_lock.as_ref() { let key = Self::cache_key(scopes); if let Some(token) = map.get(&key) { - return Some(token.clone()); + // yup-oauth2 treats a missing expiry as "never expires". Google access tokens + // are short-lived, so reusing such an entry can cause permanent 401 responses. + // Treat it as a cache miss and let the authenticator fetch a fresh token. + if token.expires_at.is_some() { + return Some(token.clone()); + } } } @@ -172,4 +177,40 @@ mod tests { let cache_lock = storage.cache.lock().await; assert!(cache_lock.is_none()); } + + #[tokio::test] + async fn test_get_ignores_cached_token_without_expiry() { + let storage = EncryptedTokenStorage::new(PathBuf::from("/unused/token.json")); + let scopes = ["scope-a", "scope-b"]; + let token = TokenInfo { + access_token: Some("stale-access-token".into()), + refresh_token: Some("refresh-token".into()), + expires_at: None, + id_token: None, + }; + + *storage.cache.lock().await = Some(HashMap::from([( + EncryptedTokenStorage::cache_key(&scopes), + token, + )])); + + assert!(storage.get(&scopes).await.is_none()); + } + + #[tokio::test] + async fn test_get_returns_cached_token_with_known_expiry() { + let storage = EncryptedTokenStorage::new(PathBuf::from("/unused/token.json")); + let scopes = ["scope-a"]; + let token: TokenInfo = serde_json::from_str( + r#"{"access_token":"access-token","refresh_token":"refresh-token","expires_at":[2026,43,19,44,15,0,0,0,0],"id_token":null}"#, + ) + .unwrap(); + + *storage.cache.lock().await = Some(HashMap::from([( + EncryptedTokenStorage::cache_key(&scopes), + token.clone(), + )])); + + assert_eq!(storage.get(&scopes).await, Some(token)); + } }