Skip to content

Commit fcad7fb

Browse files
committed
v1.0.5
- Removed custom OAuth token refresh logic that was inadvertently invalidating Claude Code's refresh token via token rotation - Removed hardcoded OAuth constants (token URL, client ID, scopes) no longer needed - Removed in-memory token cache (CachedTokens, TOKEN_CACHE) no longer needed - Delegate token refresh to the Claude CLI (claude auth status) so it manages its own credentials and writes updated tokens to disk - Re-read credentials file after CLI refresh to pick up the fresh access token - Simplified Credentials struct by removing unused refresh_token field
1 parent 6a32755 commit fcad7fb

File tree

1 file changed

+21
-115
lines changed

1 file changed

+21
-115
lines changed

src/poller.rs

Lines changed: 21 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,16 @@
11
use std::path::PathBuf;
2-
use std::sync::Mutex;
2+
use std::process::Command;
33
use std::time::{Duration, SystemTime, UNIX_EPOCH};
44

55
use crate::models::{UsageData, UsageSection};
66

77
const API_URL: &str = "https://api.anthropic.com/v1/messages";
8-
const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
9-
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
10-
const OAUTH_SCOPES: &str = "user:profile user:inference user:sessions:claude_code user:mcp_servers";
118

129
const MODEL_FALLBACK_CHAIN: &[&str] = &[
1310
"claude-3-haiku-20240307",
1411
"claude-haiku-4-5-20251001",
1512
];
1613

17-
/// In-memory cache of refreshed tokens so we don't lose them between poll cycles
18-
/// and don't need to write to the credentials file.
19-
struct CachedTokens {
20-
access_token: String,
21-
refresh_token: String,
22-
expires_at: i64, // milliseconds since epoch
23-
}
24-
25-
static TOKEN_CACHE: Mutex<Option<CachedTokens>> = Mutex::new(None);
26-
2714
#[derive(Debug)]
2815
pub enum PollError {
2916
NoCredentials,
@@ -32,53 +19,30 @@ pub enum PollError {
3219
}
3320

3421
pub fn poll() -> Result<UsageData, PollError> {
35-
let token = resolve_access_token()?;
36-
fetch_usage_with_fallback(&token)
37-
}
38-
39-
/// Resolve a valid access token by checking (in order):
40-
/// 1. In-memory cache (from a previous refresh)
41-
/// 2. On-disk credentials file
42-
/// Then refresh if the token is expired.
43-
fn resolve_access_token() -> Result<String, PollError> {
44-
// Try cached tokens first
45-
{
46-
let cache = TOKEN_CACHE.lock().unwrap();
47-
if let Some(cached) = cache.as_ref() {
48-
if !is_token_expired(Some(cached.expires_at)) {
49-
return Ok(cached.access_token.clone());
50-
}
51-
// Cached token expired — we'll refresh below using the cached refresh token
52-
}
53-
}
54-
55-
// Get the refresh token to use: prefer cached (may be rotated), fall back to disk
56-
let (access_token, refresh_token, expires_at) = {
57-
let cache = TOKEN_CACHE.lock().unwrap();
58-
if let Some(cached) = cache.as_ref() {
59-
(cached.access_token.clone(), Some(cached.refresh_token.clone()), Some(cached.expires_at))
60-
} else {
61-
let creds = read_credentials().ok_or(PollError::NoCredentials)?;
62-
(creds.access_token, creds.refresh_token, creds.expires_at)
22+
let mut creds = read_credentials().ok_or(PollError::NoCredentials)?;
23+
24+
if is_token_expired(creds.expires_at) {
25+
// Token expired — ask the Claude CLI to refresh its own credentials
26+
cli_refresh_token();
27+
// Re-read the credentials file after CLI refresh
28+
creds = read_credentials().ok_or(PollError::NoCredentials)?;
29+
if is_token_expired(creds.expires_at) {
30+
return Err(PollError::TokenExpired);
6331
}
64-
};
65-
66-
if !is_token_expired(expires_at) {
67-
return Ok(access_token);
6832
}
6933

70-
// Token is expired — refresh it
71-
let refresh = refresh_token.ok_or(PollError::TokenExpired)?;
72-
let new_tokens = refresh_access_token(&refresh).map_err(|_| PollError::TokenExpired)?;
73-
74-
// Cache the new tokens
75-
let new_access = new_tokens.access_token.clone();
76-
{
77-
let mut cache = TOKEN_CACHE.lock().unwrap();
78-
*cache = Some(new_tokens);
79-
}
34+
fetch_usage_with_fallback(&creds.access_token)
35+
}
8036

81-
Ok(new_access)
37+
/// Invoke the Claude CLI to trigger its internal token refresh.
38+
/// `claude auth status` checks auth state, which causes the CLI to
39+
/// refresh expired tokens and write updated credentials to disk.
40+
fn cli_refresh_token() {
41+
let _ = Command::new("claude")
42+
.args(["auth", "status"])
43+
.stdout(std::process::Stdio::null())
44+
.stderr(std::process::Stdio::null())
45+
.status();
8246
}
8347

8448
fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
@@ -93,7 +57,6 @@ fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
9357

9458
struct Credentials {
9559
access_token: String,
96-
refresh_token: Option<String>,
9760
expires_at: Option<i64>,
9861
}
9962

@@ -107,7 +70,6 @@ fn read_credentials() -> Option<Credentials> {
10770
let oauth = json.get("claudeAiOauth")?;
10871
Some(Credentials {
10972
access_token: oauth.get("accessToken")?.as_str()?.to_string(),
110-
refresh_token: oauth.get("refreshToken").and_then(|v| v.as_str()).map(String::from),
11173
expires_at: oauth.get("expiresAt").and_then(|v| v.as_i64()),
11274
})
11375
}
@@ -121,62 +83,6 @@ fn is_token_expired(expires_at: Option<i64>) -> bool {
12183
now >= exp
12284
}
12385

124-
/// Refresh the OAuth token using the refresh grant.
125-
/// Returns the full token set (access, refresh, expiry) for in-memory caching.
126-
fn refresh_access_token(refresh_token: &str) -> Result<CachedTokens, String> {
127-
let tls = std::sync::Arc::new(
128-
native_tls::TlsConnector::new().map_err(|e| e.to_string())?
129-
);
130-
let agent = ureq::AgentBuilder::new()
131-
.timeout(Duration::from_secs(30))
132-
.tls_connector(tls)
133-
.build();
134-
135-
let body = serde_json::json!({
136-
"grant_type": "refresh_token",
137-
"refresh_token": refresh_token,
138-
"client_id": CLIENT_ID,
139-
"scope": OAUTH_SCOPES,
140-
});
141-
142-
let resp = agent
143-
.post(TOKEN_URL)
144-
.set("Content-Type", "application/json")
145-
.send_json(&body)
146-
.map_err(|e| e.to_string())?;
147-
148-
let resp_body: serde_json::Value = resp
149-
.into_json()
150-
.map_err(|e| e.to_string())?;
151-
152-
let new_access = resp_body.get("access_token")
153-
.and_then(|v| v.as_str())
154-
.ok_or("missing access_token in refresh response")?
155-
.to_string();
156-
157-
// Use new refresh token if server returned one (rotation), otherwise keep existing
158-
let new_refresh = resp_body.get("refresh_token")
159-
.and_then(|v| v.as_str())
160-
.unwrap_or(refresh_token)
161-
.to_string();
162-
163-
// Calculate expiry from expires_in (seconds), default to 1 hour if missing
164-
let expires_in = resp_body.get("expires_in")
165-
.and_then(|v| v.as_i64())
166-
.unwrap_or(3600);
167-
let now_ms = SystemTime::now()
168-
.duration_since(UNIX_EPOCH)
169-
.unwrap_or_default()
170-
.as_millis() as i64;
171-
let expires_at = now_ms + (expires_in * 1000);
172-
173-
Ok(CachedTokens {
174-
access_token: new_access,
175-
refresh_token: new_refresh,
176-
expires_at,
177-
})
178-
}
179-
18086
fn try_model(token: &str, model: &str) -> Option<UsageData> {
18187
let tls = std::sync::Arc::new(
18288
native_tls::TlsConnector::new().ok()?

0 commit comments

Comments
 (0)