From 39d62348d85f6bf07ea088fa11cd84c1c85124dd Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Wed, 8 Jul 2026 23:37:34 +0200 Subject: [PATCH] harness-providers: auth.json credential storage (M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.rs: AuthRecord (OAuth {access, refresh, expires} | Api {key}) with is_expired(now, skew) honoring expires==0 = never; AuthStorage keyed by provider id over ~/.local/share/ai-harness/auth.json. - Read-modify-write on every op so concurrent refresh/login don't clobber; writes go through a temp-file rename set to 0600 (unix) to avoid truncated auth files. - 6 tempdir tests: missing→empty, set/get/overwrite, multi-provider coexist, scoped remove (+ no-op on absent), expiry skew/never, 0600 perms. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 + crates/harness-providers/Cargo.toml | 2 + crates/harness-providers/src/auth.rs | 228 +++++++++++++++++++++++++++ crates/harness-providers/src/lib.rs | 2 + 4 files changed, 234 insertions(+) create mode 100644 crates/harness-providers/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index edca5ee..b654ebe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,12 +723,14 @@ dependencies = [ "async-stream", "async-trait", "bytes", + "dirs", "eventsource-stream", "futures", "harness-core", "reqwest", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/crates/harness-providers/Cargo.toml b/crates/harness-providers/Cargo.toml index 9972f4c..ea6b8a7 100644 --- a/crates/harness-providers/Cargo.toml +++ b/crates/harness-providers/Cargo.toml @@ -18,9 +18,11 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +dirs = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/harness-providers/src/auth.rs b/crates/harness-providers/src/auth.rs new file mode 100644 index 0000000..4340081 --- /dev/null +++ b/crates/harness-providers/src/auth.rs @@ -0,0 +1,228 @@ +//! Credential storage for providers that authenticate outside the config file. +//! +//! A single JSON file (`~/.local/share/ai-harness/auth.json`, mode `0600`) keyed by provider +//! id. OAuth providers (Copilot) store the token triple and refresh it in place; API-key +//! providers store the bare key. Config-supplied keys take precedence over this file — this +//! is only for interactively-obtained credentials. + +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AuthRecord { + /// OAuth credentials. `expires` is a unix-ms timestamp; `0` means the token never expires + /// (opencode's direct-Bearer Copilot mode). + OAuth { + access: String, + refresh: String, + expires: i64, + }, + Api { + key: String, + }, +} + +impl AuthRecord { + /// True when an OAuth token is at or past `expires` (with a safety skew), given `now_ms`. + /// API keys and never-expiring OAuth tokens (`expires == 0`) are never considered expired. + pub fn is_expired(&self, now_ms: i64, skew_ms: i64) -> bool { + match self { + AuthRecord::OAuth { expires, .. } if *expires > 0 => now_ms + skew_ms >= *expires, + _ => false, + } + } +} + +/// Read/modify/write access to the auth file. Cheap to construct; every operation reloads so +/// concurrent writers (a refresh in one provider, a login in another) don't clobber each other. +#[derive(Debug, Clone)] +pub struct AuthStorage { + path: PathBuf, +} + +impl AuthStorage { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// `~/.local/share/ai-harness/auth.json` (falling back to the temp dir if there is no + /// data directory). + pub fn default_path() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(std::env::temp_dir) + .join("ai-harness") + .join("auth.json") + } + + pub fn with_default_path() -> Self { + Self::new(Self::default_path()) + } + + /// Loads all records. A missing file yields an empty map; a corrupt file is an error. + pub fn load(&self) -> io::Result> { + match std::fs::read(&self.path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(HashMap::new()), + Err(e) => Err(e), + } + } + + pub fn get(&self, provider: &str) -> io::Result> { + Ok(self.load()?.remove(provider)) + } + + pub fn set(&self, provider: &str, record: AuthRecord) -> io::Result<()> { + let mut records = self.load()?; + records.insert(provider.to_string(), record); + self.write(&records) + } + + pub fn remove(&self, provider: &str) -> io::Result<()> { + let mut records = self.load()?; + if records.remove(provider).is_some() { + self.write(&records)?; + } + Ok(()) + } + + /// Serializes `records` and writes them with `0600` permissions via a temp-file rename so + /// a partial write can never leave a truncated auth file behind. + fn write(&self, records: &HashMap) -> io::Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_vec_pretty(records) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, &json)?; + set_owner_only(&tmp)?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } +} + +#[cfg(unix)] +fn set_owner_only(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_owner_only(_path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn storage() -> (tempfile::TempDir, AuthStorage) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("auth.json"); + (dir, AuthStorage::new(path)) + } + + #[test] + fn missing_file_loads_empty() { + let (_dir, store) = storage(); + assert!(store.load().unwrap().is_empty()); + assert_eq!(store.get("anthropic").unwrap(), None); + } + + #[test] + fn set_get_roundtrip_and_overwrite() { + let (_dir, store) = storage(); + store + .set("anthropic", AuthRecord::Api { key: "k1".into() }) + .unwrap(); + assert_eq!( + store.get("anthropic").unwrap(), + Some(AuthRecord::Api { key: "k1".into() }) + ); + + store + .set("anthropic", AuthRecord::Api { key: "k2".into() }) + .unwrap(); + assert_eq!( + store.get("anthropic").unwrap(), + Some(AuthRecord::Api { key: "k2".into() }) + ); + } + + #[test] + fn multiple_providers_coexist() { + let (_dir, store) = storage(); + store + .set("openai", AuthRecord::Api { key: "sk".into() }) + .unwrap(); + store + .set( + "github-copilot", + AuthRecord::OAuth { + access: "a".into(), + refresh: "r".into(), + expires: 0, + }, + ) + .unwrap(); + let all = store.load().unwrap(); + assert_eq!(all.len(), 2); + assert!(all.contains_key("openai")); + assert!(all.contains_key("github-copilot")); + } + + #[test] + fn remove_deletes_only_named_provider() { + let (_dir, store) = storage(); + store + .set("openai", AuthRecord::Api { key: "sk".into() }) + .unwrap(); + store + .set("anthropic", AuthRecord::Api { key: "an".into() }) + .unwrap(); + store.remove("openai").unwrap(); + assert_eq!(store.get("openai").unwrap(), None); + assert!(store.get("anthropic").unwrap().is_some()); + // Removing an absent provider is a no-op, not an error. + store.remove("openai").unwrap(); + } + + #[test] + fn oauth_expiry_respects_skew_and_never_expires() { + let never = AuthRecord::OAuth { + access: "a".into(), + refresh: "r".into(), + expires: 0, + }; + assert!(!never.is_expired(i64::MAX, 0)); + + let expiring = AuthRecord::OAuth { + access: "a".into(), + refresh: "r".into(), + expires: 1_000, + }; + assert!(!expiring.is_expired(800, 120)); + assert!(expiring.is_expired(880, 120)); // within skew window + assert!(expiring.is_expired(1_000, 0)); + + assert!(!AuthRecord::Api { key: "k".into() }.is_expired(i64::MAX, 0)); + } + + #[cfg(unix)] + #[test] + fn file_is_written_owner_only() { + use std::os::unix::fs::PermissionsExt; + let (_dir, store) = storage(); + store + .set("openai", AuthRecord::Api { key: "sk".into() }) + .unwrap(); + let mode = std::fs::metadata(&store.path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } +} diff --git a/crates/harness-providers/src/lib.rs b/crates/harness-providers/src/lib.rs index 07cdffb..a91209c 100644 --- a/crates/harness-providers/src/lib.rs +++ b/crates/harness-providers/src/lib.rs @@ -1,8 +1,10 @@ pub mod anthropic; +pub mod auth; pub mod codec; pub mod openai; pub mod registry; pub use anthropic::AnthropicProvider; +pub use auth::{AuthRecord, AuthStorage}; pub use openai::OpenAiProvider; pub use registry::ProviderRegistry;