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:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user