Skip to main content

agentd/auth/
cache.rs

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