huskies: merge 724_story_per_account_oauth_credential_storage_with_login_pool

This commit is contained in:
dave
2026-04-27 17:36:00 +00:00
parent ed8646f0d9
commit 1ecb4dad55
5 changed files with 265 additions and 33 deletions
+88
View File
@@ -1,6 +1,7 @@
//! Anthropic OAuth — token refresh and credential management for Claude API access.
use crate::slog;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
/// The client ID used by Claude Code for OAuth.
@@ -85,6 +86,93 @@ pub fn write_credentials(creds: &CredentialsFile) -> Result<(), String> {
Ok(())
}
// ── Multi-account pool ────────────────────────────────────────────────────────
/// A single account entry in the multi-account login pool.
///
/// Stored in the pool file, keyed by email address.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PoolAccount {
/// Email address that identifies this account.
pub email: String,
/// OAuth access token.
pub access_token: String,
/// OAuth refresh token (empty string when not present).
pub refresh_token: String,
/// Unix-epoch milliseconds when the access token expires.
pub expires_at: u64,
/// Scopes granted by this token.
#[serde(default)]
pub scopes: Vec<String>,
/// Claude subscription tier (e.g. "max"), if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscription_type: Option<String>,
/// Rate-limit tier string, if known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate_limit_tier: Option<String>,
/// Whether the server has observed a rate-limit response for this account.
#[serde(default)]
pub rate_limited: bool,
}
/// The multi-account login pool, stored in `~/.claude/oauth_pool.json`.
///
/// Accounts are keyed by email address so that repeated logins with the same
/// account update the existing entry rather than creating a duplicate.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AccountPool {
/// Email → account credentials.
pub accounts: HashMap<String, PoolAccount>,
}
/// Returns the path to `~/.claude/oauth_pool.json`.
fn pool_path() -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
Ok(PathBuf::from(home).join(".claude").join("oauth_pool.json"))
}
/// Read the account pool from disk.
///
/// Returns an empty pool when no file exists yet.
pub fn read_pool() -> Result<AccountPool, String> {
let path = pool_path()?;
if !path.exists() {
return Ok(AccountPool::default());
}
let data = std::fs::read_to_string(&path)
.map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
serde_json::from_str(&data).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
}
/// Write the account pool to disk with 0600 permissions.
pub fn write_pool(pool: &AccountPool) -> Result<(), String> {
let path = pool_path()?;
let data =
serde_json::to_string_pretty(pool).map_err(|e| format!("Failed to serialize pool: {e}"))?;
std::fs::write(&path, &data).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
#[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(())
}
/// Insert or update an account in the pool, then persist to disk.
///
/// If an account with the same email already exists its credentials are
/// overwritten; all other accounts are left unchanged.
pub fn upsert_pool_account(account: PoolAccount) -> Result<(), String> {
let mut pool = read_pool()?;
pool.accounts.insert(account.email.clone(), account);
write_pool(&pool)
}
/// Refresh the OAuth access token using the stored refresh token.
///
/// On success, updates `~/.claude/.credentials.json` with the new access