feat: auto-refresh expired OAuth token for Claude Code PTY (story 405)

Detect authentication_failed errors from the Claude Code PTY stream
and automatically refresh the OAuth access token using the stored
refresh token in ~/.claude/.credentials.json.

- New module server/src/llm/oauth.rs: reads credentials, calls
  platform.claude.com/v1/oauth/token with JSON body, writes back
- PTY provider detects "error":"authentication_failed" via AtomicBool
- chat_stream retries once after successful refresh
- Clear error message if refresh also fails

On success the retry is transparent. On failure the user sees:
"OAuth session expired. Please run claude login to re-authenticate."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-03-26 19:58:04 +00:00
parent ab4ce2db92
commit 710b604b7c
4 changed files with 421 additions and 75 deletions
+215
View File
@@ -0,0 +1,215 @@
use crate::slog;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// The client ID used by Claude Code for OAuth.
const CLAUDE_CODE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const TOKEN_ENDPOINT: &str = "https://platform.claude.com/v1/oauth/token";
/// OAuth credentials as stored in `~/.claude/.credentials.json`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthCredentials {
pub access_token: String,
pub refresh_token: String,
pub expires_at: u64,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscription_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit_tier: Option<String>,
}
/// Top-level structure of `~/.claude/.credentials.json`.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialsFile {
pub claude_ai_oauth: OAuthCredentials,
}
/// Response from the Anthropic OAuth token refresh endpoint.
#[derive(Debug, Deserialize)]
struct TokenRefreshResponse {
access_token: String,
expires_in: u64,
#[allow(dead_code)]
token_type: Option<String>,
}
/// Error from the Anthropic OAuth token refresh endpoint.
#[derive(Debug, Deserialize)]
struct TokenRefreshError {
#[allow(dead_code)]
error: String,
error_description: Option<String>,
}
/// Returns the path to `~/.claude/.credentials.json`.
fn credentials_path() -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
Ok(PathBuf::from(home).join(".claude").join(".credentials.json"))
}
/// Read OAuth credentials from disk.
pub fn read_credentials() -> Result<CredentialsFile, String> {
let path = credentials_path()?;
let data = std::fs::read_to_string(&path).map_err(|e| {
format!(
"Cannot read {}: {e}. Run `claude login` to authenticate.",
path.display()
)
})?;
serde_json::from_str(&data).map_err(|e| {
format!(
"Failed to parse {}: {e}",
path.display()
)
})
}
/// Write updated credentials back to disk with 0600 permissions.
pub fn write_credentials(creds: &CredentialsFile) -> Result<(), String> {
let path = credentials_path()?;
let data = serde_json::to_string_pretty(creds)
.map_err(|e| format!("Failed to serialize credentials: {e}"))?;
std::fs::write(&path, &data)
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
// Restore 0600 permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms)
.map_err(|e| format!("Failed to set permissions on {}: {e}", path.display()))?;
}
Ok(())
}
/// Refresh the OAuth access token using the stored refresh token.
///
/// On success, updates `~/.claude/.credentials.json` with the new access
/// token and expiry, then returns `Ok(())`.
///
/// On failure (e.g. refresh token expired), returns an error string.
pub async fn refresh_access_token() -> Result<(), String> {
slog!("[oauth] Attempting to refresh OAuth access token");
let mut creds = read_credentials()?;
let refresh_token = creds.claude_ai_oauth.refresh_token.clone();
if refresh_token.is_empty() {
return Err(
"No refresh token found. Run `claude login` to authenticate.".to_string(),
);
}
let client = reqwest::Client::new();
let resp = client
.post(TOKEN_ENDPOINT)
.json(&serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLAUDE_CODE_CLIENT_ID,
}))
.send()
.await
.map_err(|e| format!("OAuth refresh request failed: {e}"))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| format!("Failed to read refresh response: {e}"))?;
if !status.is_success() {
// Try to parse a structured error
if let Ok(err) = serde_json::from_str::<TokenRefreshError>(&body) {
let desc = err
.error_description
.unwrap_or_else(|| "unknown error".to_string());
slog!("[oauth] Refresh failed: {desc} (full body: {body})");
return Err(format!(
"OAuth session expired. Please run `claude login` to re-authenticate. ({desc})"
));
}
return Err(format!(
"OAuth session expired. Please run `claude login` to re-authenticate. (HTTP {status})"
));
}
let token_resp: TokenRefreshResponse = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse refresh response: {e}"))?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
creds.claude_ai_oauth.access_token = token_resp.access_token;
creds.claude_ai_oauth.expires_at = now_ms + (token_resp.expires_in * 1000);
write_credentials(&creds)?;
slog!(
"[oauth] Successfully refreshed access token, expires at {}",
creds.claude_ai_oauth.expires_at
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_credentials_file() {
let json = r#"{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-test",
"refreshToken": "sk-ant-ort01-test",
"expiresAt": 1774466144677,
"scopes": ["user:inference"],
"subscriptionType": "max",
"rateLimitTier": "default_claude_max_20x"
}
}"#;
let creds: CredentialsFile = serde_json::from_str(json).unwrap();
assert_eq!(creds.claude_ai_oauth.access_token, "sk-ant-oat01-test");
assert_eq!(creds.claude_ai_oauth.refresh_token, "sk-ant-ort01-test");
assert_eq!(creds.claude_ai_oauth.expires_at, 1774466144677);
assert_eq!(creds.claude_ai_oauth.subscription_type.as_deref(), Some("max"));
}
#[test]
fn serialize_credentials_roundtrip() {
let creds = CredentialsFile {
claude_ai_oauth: OAuthCredentials {
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
expires_at: 12345,
scopes: vec!["user:inference".to_string()],
subscription_type: Some("max".to_string()),
rate_limit_tier: None,
},
};
let json = serde_json::to_string(&creds).unwrap();
let parsed: CredentialsFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.claude_ai_oauth.access_token, "access");
assert_eq!(parsed.claude_ai_oauth.refresh_token, "refresh");
// rate_limit_tier should be omitted from JSON (skip_serializing_if)
assert!(!json.contains("rateLimitTier"));
}
#[test]
fn credentials_path_uses_home() {
// Just verify it doesn't panic and returns a path ending in .credentials.json
if std::env::var("HOME").is_ok() {
let path = credentials_path().unwrap();
assert!(path.ends_with(".claude/.credentials.json"));
}
}
}