Skip to main content

agentd/auth/
cache.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The durable endpoint-credential cache (RFC 0031 §11). Access + refresh tokens
3//! with expiry, persisted in the durable store under [`Kind::Cred`], keyed by a
4//! hash of the login target (e.g. `mcp:github`, `intelligence`). Written by
5//! `agentd login` and read at daemon startup to seed a provider; refreshed
6//! in-memory during a run, re-loaded (and re-refreshed from the refresh token) on
7//! restart.
8//!
9//! **Redaction (RFC 0031 §13):** a cred record holds live tokens — it is
10//! excluded from all logs, audit, and the `agent://` read surface. The `Kind::Cred`
11//! class is non-indexed and never appears in the manifest.
12
13use crate::sha::sha256_hex;
14use crate::state::{Durable, Kind};
15use serde::{Deserialize, Serialize};
16use std::path::PathBuf;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19/// Milliseconds since the Unix epoch (for `expires_at_ms`).
20pub fn now_ms() -> u64 {
21    SystemTime::now()
22        .duration_since(UNIX_EPOCH)
23        .map(|d| d.as_millis() as u64)
24        .unwrap_or(0)
25}
26
27/// A cached credential for one endpoint (RFC 0031 §11). Never logged.
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct CachedCred {
30    #[serde(default)]
31    pub access_token: String,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub refresh_token: Option<String>,
34    /// Absolute expiry (ms since the Unix epoch); `0` = unknown / non-expiring.
35    #[serde(default)]
36    pub expires_at_ms: u64,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub token_type: Option<String>,
39    /// Provider-specific extras — e.g. temporary AWS credentials from an SSO
40    /// login (`aws_access_key_id` / `aws_secret_access_key` / `aws_session_token`).
41    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
42    pub extra: serde_json::Map<String, serde_json::Value>,
43}
44
45impl CachedCred {
46    /// Whether the access token is still usable at `now_ms`, keeping `skew_ms`
47    /// of headroom so an in-flight request never rides a just-expired token.
48    pub fn valid_at(&self, now_ms: u64, skew_ms: u64) -> bool {
49        self.expires_at_ms == 0 || now_ms.saturating_add(skew_ms) < self.expires_at_ms
50    }
51}
52
53/// The stable, filesystem-safe record id for a login `target`.
54pub fn cred_id(target: &str) -> String {
55    sha256_hex(target.as_bytes())
56}
57
58/// Load the cached credential for `target`, if present and parseable.
59pub fn load(durable: &Durable, target: &str) -> Option<CachedCred> {
60    let env = durable.get(Kind::Cred, &cred_id(target)).ok()??;
61    serde_json::from_value(env.state).ok()
62}
63
64/// Store (or replace) the credential for `target`.
65pub fn store(durable: &Durable, target: &str, cred: &CachedCred) -> Result<(), String> {
66    let value = serde_json::to_value(cred).map_err(|e| e.to_string())?;
67    durable
68        .put(Kind::Cred, &cred_id(target), value, None)
69        .map(|_| ())
70        .map_err(|e| format!("{e}"))
71}
72
73/// Evict the credential for `target` (`agentd logout`).
74pub fn evict(durable: &Durable, target: &str) -> Result<(), String> {
75    durable
76        .delete(Kind::Cred, &cred_id(target))
77        .map_err(|e| format!("{e}"))
78}
79
80// --- file-backed cache (the interactive `agentd login` handoff) --------------
81//
82// `agentd login` runs on a human's machine, where the configured durable store
83// may be a remote backend the login has no business touching. The obtained token
84// is cached in a per-user file (0600), the same path the daemon reads at startup
85// to seed a provider — the pattern `aws`/`gcloud`/`kubectl` use for OAuth tokens.
86
87/// The default per-user credential directory (RFC 0031 §11):
88/// `$AGENTD_CRED_DIR`, else `$XDG_STATE_HOME/agentd/creds`, else
89/// `$HOME/.local/state/agentd/creds`, else the OS temp dir.
90pub fn default_dir() -> PathBuf {
91    if let Some(d) = std::env::var_os("AGENTD_CRED_DIR") {
92        return PathBuf::from(d);
93    }
94    if let Some(d) = std::env::var_os("XDG_STATE_HOME") {
95        return PathBuf::from(d).join("agentd").join("creds");
96    }
97    if let Some(h) = std::env::var_os("HOME") {
98        return PathBuf::from(h)
99            .join(".local")
100            .join("state")
101            .join("agentd")
102            .join("creds");
103    }
104    std::env::temp_dir().join("agentd").join("creds")
105}
106
107fn file_path(dir: &std::path::Path, target: &str) -> PathBuf {
108    dir.join(format!("{}.json", cred_id(target)))
109}
110
111/// Load a credential from the file cache, if present and parseable.
112pub fn load_file(dir: &std::path::Path, target: &str) -> Option<CachedCred> {
113    let bytes = std::fs::read(file_path(dir, target)).ok()?;
114    serde_json::from_slice(&bytes).ok()
115}
116
117/// Write a credential to the file cache (creating the dir; the file is `0600`).
118pub fn store_file(dir: &std::path::Path, target: &str, cred: &CachedCred) -> Result<(), String> {
119    std::fs::create_dir_all(dir).map_err(|e| format!("cred dir {}: {e}", dir.display()))?;
120    let path = file_path(dir, target);
121    let json = serde_json::to_vec_pretty(cred).map_err(|e| e.to_string())?;
122    std::fs::write(&path, &json).map_err(|e| format!("write {}: {e}", path.display()))?;
123    // Owner-only (0600) — the file holds live tokens.
124    #[cfg(unix)]
125    {
126        use std::os::unix::fs::PermissionsExt;
127        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
128    }
129    Ok(())
130}
131
132/// Evict a credential from the file cache (`agentd logout`). Absent = success.
133pub fn evict_file(dir: &std::path::Path, target: &str) -> Result<(), String> {
134    match std::fs::remove_file(file_path(dir, target)) {
135        Ok(()) => Ok(()),
136        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
137        Err(e) => Err(format!("{e}")),
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn cred_id_is_stable_and_distinct() {
147        assert_eq!(cred_id("mcp:github"), cred_id("mcp:github"));
148        assert_ne!(cred_id("mcp:github"), cred_id("intelligence"));
149        // 64 hex chars (sha-256).
150        assert_eq!(cred_id("x").len(), 64);
151    }
152
153    #[test]
154    fn valid_at_honours_expiry_and_skew() {
155        let c = CachedCred {
156            access_token: "t".into(),
157            expires_at_ms: 1_000,
158            ..Default::default()
159        };
160        assert!(c.valid_at(0, 0));
161        assert!(c.valid_at(900, 50));
162        assert!(!c.valid_at(960, 50)); // inside the skew window
163        assert!(!c.valid_at(1_000, 0));
164        // Non-expiring (0) is always valid.
165        let never = CachedCred {
166            access_token: "t".into(),
167            expires_at_ms: 0,
168            ..Default::default()
169        };
170        assert!(never.valid_at(u64::MAX - 1, 10));
171    }
172}