M3: auth.json credential storage

Adds harness-providers::auth, a persisted auth.json credential store for provider tokens, laying the groundwork for Copilot's device-flow login and token refresh.
This commit is contained in:
2026-07-08 23:37:34 +02:00
parent 607bd88ae3
commit a3f6fd6378
4 changed files with 234 additions and 0 deletions
Generated
+2
View File
@@ -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",
+2
View File
@@ -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
+228
View File
@@ -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<HashMap<String, AuthRecord>> {
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<Option<AuthRecord>> {
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<String, AuthRecord>) -> 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);
}
}
+2
View File
@@ -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;