huskies: merge 724_story_per_account_oauth_credential_storage_with_login_pool
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user