Skip to main content

agentmux_srv/backend/storage/
agent_credentials.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-agent M2M Cognito credential for muxbus Tier-4 credential binding.
5//! See db_agent_credentials in migrations.rs (SHARED_STORE_SCHEMA_VERSION v4).
6//! The relay side is designed in the private cloud repo.
7//!
8//! One row per locally-registered agent_id — distinct from the single
9//! account-level db_muxbus_credentials singleton (muxbus.rs), which stores
10//! the human's PKCE login used only to provision these.
11
12use rusqlite::params;
13
14use super::error::StoreError;
15use super::store::Store;
16
17#[derive(Debug, Clone, Default)]
18pub struct AgentCredential {
19    pub client_id: String,
20    pub client_secret: String,
21    pub token_endpoint: String,
22    /// Cached client_credentials access token — separate from client_secret
23    /// so a fresh token fetch doesn't require re-provisioning.
24    pub access_token: String,
25    pub expires_at: i64,
26}
27
28impl AgentCredential {
29    /// client_credentials tokens carry no refresh token (per the cloud-side
30    /// design) — "valid" just means "not expired yet," with the
31    /// same 300s early-refresh margin used by MuxBusCredentials.
32    pub fn is_valid(&self) -> bool {
33        !self.access_token.is_empty() && self.expires_at - agentmux_common::time::now_secs() > 300
34    }
35}
36
37/// The row key for `agent_id` in this channel's sign-in: the lowercased agent
38/// id, behind the channel's prefix when channels share the store. These cached
39/// credentials belong to the account a channel is signed in as, and two
40/// channels may be signed in as different accounts.
41fn scoped_key(agent_id: &str) -> String {
42    format!("{}{}", super::muxbus::current_agent_credential_prefix(), agent_id.to_lowercase())
43}
44
45impl Store {
46    pub fn agent_credential_load(&self, agent_id: &str) -> Result<Option<AgentCredential>, StoreError> {
47        let key = scoped_key(agent_id);
48        let conn = self.conn.lock().unwrap();
49        let mut stmt = conn.prepare(
50            "SELECT client_id, client_secret, token_endpoint, access_token, expires_at
51             FROM db_agent_credentials WHERE agent_id = ?1",
52        )?;
53        match stmt.query_row(params![key], |row| {
54            Ok(AgentCredential {
55                client_id: row.get(0)?,
56                client_secret: row.get(1)?,
57                token_endpoint: row.get(2)?,
58                access_token: row.get(3)?,
59                expires_at: row.get(4)?,
60            })
61        }) {
62            Ok(c) => Ok(Some(c)),
63            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
64            Err(e) => Err(e.into()),
65        }
66    }
67
68    /// Save a newly-provisioned client_id/client_secret (access_token/expires_at
69    /// left at their defaults — the caller fetches a token separately via
70    /// agent_credential_save_token).
71    pub fn agent_credential_save(
72        &self,
73        agent_id: &str,
74        client_id: &str,
75        client_secret: &str,
76        token_endpoint: &str,
77    ) -> Result<(), StoreError> {
78        let key = scoped_key(agent_id);
79        let conn = self.conn.lock().unwrap();
80        conn.execute(
81            "INSERT INTO db_agent_credentials
82                 (agent_id, client_id, client_secret, token_endpoint, created_at)
83             VALUES (?1, ?2, ?3, ?4, ?5)
84             ON CONFLICT(agent_id) DO UPDATE SET
85                 client_id = excluded.client_id,
86                 client_secret = excluded.client_secret,
87                 token_endpoint = excluded.token_endpoint",
88            params![key, client_id, client_secret, token_endpoint, agentmux_common::time::now_secs()],
89        )?;
90        Ok(())
91    }
92
93    /// Cache a freshly-fetched client_credentials access token against an
94    /// already-provisioned agent. No-op if the agent has no row yet (the
95    /// caller should provision first).
96    pub fn agent_credential_save_token(
97        &self,
98        agent_id: &str,
99        access_token: &str,
100        expires_at: i64,
101    ) -> Result<(), StoreError> {
102        let key = scoped_key(agent_id);
103        let conn = self.conn.lock().unwrap();
104        conn.execute(
105            "UPDATE db_agent_credentials SET access_token = ?2, expires_at = ?3 WHERE agent_id = ?1",
106            params![key, access_token, expires_at],
107        )?;
108        Ok(())
109    }
110
111    /// Clear a per-agent credential's cached access token (client_id/secret
112    /// are left intact — only the token is suspect). Used when the server
113    /// rejects a request with 401 despite our cached expires_at still
114    /// looking valid (revoked/rotated out-of-band): forces the next
115    /// ensure_agent_credential call to re-fetch via fetch_m2m_token instead
116    /// of retrying the exact same rejected token. No-op if the agent has no
117    /// row. reagentx P1 on PR #2342.
118    pub fn agent_credential_invalidate_token(&self, agent_id: &str) -> Result<(), StoreError> {
119        let key = scoped_key(agent_id);
120        let conn = self.conn.lock().unwrap();
121        conn.execute(
122            "UPDATE db_agent_credentials SET access_token = '', expires_at = 0 WHERE agent_id = ?1",
123            params![key],
124        )?;
125        Ok(())
126    }
127
128    /// Delete one agent's credential row outright — for a client the cloud
129    /// no longer recognizes (`invalid_client`), where keeping the
130    /// client_id/secret would only fail again. The next
131    /// `ensure_agent_credential` re-provisions it. No-op if there is no row.
132    /// docs/specs/SPEC_CLOUD_SETTINGS_DISCOVERY_2026_09_27.md §3.4.
133    pub fn agent_credential_delete(&self, agent_id: &str) -> Result<(), StoreError> {
134        let key = scoped_key(agent_id);
135        let conn = self.conn.lock().unwrap();
136        conn.execute("DELETE FROM db_agent_credentials WHERE agent_id = ?1", params![key])?;
137        Ok(())
138    }
139
140    /// Wipe every cached per-agent M2M credential. `db_agent_credentials`
141    /// carries no account/user_sub column of its own — each row is only
142    /// ever meaningful under the muxbus account that provisioned it — so a
143    /// genuine account switch (muxbus_save with a different user_sub) or a
144    /// logout (muxbus_clear) must wipe this cache wholesale; otherwise a
145    /// stale row keeps authenticating this agent_id's requests as the
146    /// PREVIOUS account after the human has switched to a different one.
147    /// Re-provisioning is cheap and idempotent on the cloud side, so clearing on every account transition —
148    /// not just the ones that turn out to matter — is the safe default.
149    /// reagentx P0 on PR #2342.
150    pub fn agent_credentials_clear_all(&self) -> Result<(), StoreError> {
151        self.agent_credentials_clear_prefix(&super::muxbus::current_agent_credential_prefix())
152    }
153
154    /// Every channel's row for one agent: what deleting the agent removes.
155    pub fn agent_credentials_purge_agent(&self, agent_id: &str) -> Result<usize, StoreError> {
156        let conn = self.conn.lock().unwrap();
157        Ok(conn.execute(
158            "DELETE FROM db_agent_credentials
159             WHERE agent_id = ?1 OR (instr(agent_id, '/') > 0 AND substr(agent_id, instr(agent_id, '/') + 1) = ?1)",
160            params![agent_id.to_lowercase()],
161        )?)
162    }
163
164    /// The rows behind `prefix` (a channel's `<channel>/`), or, with an empty
165    /// prefix, the unprefixed ones.
166    fn agent_credentials_clear_prefix(&self, prefix: &str) -> Result<(), StoreError> {
167        let conn = self.conn.lock().unwrap();
168        if prefix.is_empty() {
169            // The host-wide set (and a channel's own store): rows with no
170            // channel prefix. Other channels' rows are theirs.
171            conn.execute("DELETE FROM db_agent_credentials WHERE instr(agent_id, '/') = 0", [])?;
172        } else {
173            conn.execute(
174                "DELETE FROM db_agent_credentials WHERE substr(agent_id, 1, length(?1)) = ?1",
175                params![prefix],
176            )?;
177        }
178        Ok(())
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    // A shared-schema store built on a temp file (open_shared needs a real
187    // path; :memory: would be re-created per connection) — db_agent_credentials
188    // lives in the shared schema (SHARED_STORE_SCHEMA_VERSION v3), not objects.db.
189    fn shared_store() -> Store {
190        Store::open_shared(std::path::Path::new(":memory:")).unwrap()
191    }
192
193    /// Channels sharing the store keep their cached credentials apart; the
194    /// host-wide set (`stable`) and each channel clear only their own.
195    #[test]
196    fn a_channels_credentials_are_its_own_rows_and_clear_all_clears_only_those() {
197        let store = shared_store();
198        let conn = store.conn.lock().unwrap();
199        for key in ["agentx", "local-main-x/agentx", "local-main-y/agentx"] {
200            conn.execute(
201                "INSERT INTO db_agent_credentials (agent_id, client_id) VALUES (?1, 'c')",
202                params![key],
203            )
204            .unwrap();
205        }
206        let count = |conn: &rusqlite::Connection| -> i64 {
207            conn.query_row("SELECT COUNT(*) FROM db_agent_credentials", [], |r| r.get(0)).unwrap()
208        };
209        assert_eq!(count(&conn), 3);
210        drop(conn);
211        let left = |store: &Store| -> Vec<String> {
212            let conn = store.conn.lock().unwrap();
213            let mut stmt = conn.prepare("SELECT agent_id FROM db_agent_credentials ORDER BY agent_id").unwrap();
214            stmt.query_map([], |r| r.get(0)).unwrap().collect::<Result<_, _>>().unwrap()
215        };
216        // The host-wide set clears only the unprefixed row.
217        store.agent_credentials_clear_prefix("").unwrap();
218        assert_eq!(left(&store), vec!["local-main-x/agentx".to_string(), "local-main-y/agentx".to_string()]);
219        // A channel clears only its own.
220        store.agent_credentials_clear_prefix("local-main-x/").unwrap();
221        assert_eq!(left(&store), vec!["local-main-y/agentx".to_string()]);
222    }
223
224    /// Deleting an agent removes its row in every channel and no one else's.
225    #[test]
226    fn purging_an_agent_removes_its_rows_in_every_channel() {
227        let store = shared_store();
228        {
229            let conn = store.conn.lock().unwrap();
230            for key in ["agentx", "local-main-x/agentx", "dev-main-y/agentx", "agentxy", "local-main-x/agenty"] {
231                conn.execute("INSERT INTO db_agent_credentials (agent_id, client_id) VALUES (?1, 'c')", params![key])
232                    .unwrap();
233            }
234        }
235        assert_eq!(store.agent_credentials_purge_agent("AgentX").unwrap(), 3);
236        let conn = store.conn.lock().unwrap();
237        let left: Vec<String> = conn
238            .prepare("SELECT agent_id FROM db_agent_credentials ORDER BY agent_id")
239            .unwrap()
240            .query_map([], |r| r.get(0))
241            .unwrap()
242            .collect::<Result<_, _>>()
243            .unwrap();
244        assert_eq!(left, vec!["agentxy".to_string(), "local-main-x/agenty".to_string()]);
245    }
246
247    #[test]
248    fn load_returns_none_when_unprovisioned() {
249        let store = shared_store();
250        assert!(store.agent_credential_load("agentx").unwrap().is_none());
251    }
252
253    #[test]
254    fn save_then_load_round_trips() {
255        let store = shared_store();
256        store
257            .agent_credential_save("AgentX", "client-1", "secret-1", "https://auth.example.com/oauth2/token")
258            .unwrap();
259
260        // Lookup is case-insensitive — normalized the same way agent_id is
261        // normalized everywhere else in this codebase.
262        let loaded = store.agent_credential_load("agentx").unwrap().unwrap();
263        assert_eq!(loaded.client_id, "client-1");
264        assert_eq!(loaded.client_secret, "secret-1");
265        assert_eq!(loaded.token_endpoint, "https://auth.example.com/oauth2/token");
266        assert_eq!(loaded.access_token, "");
267        assert!(!loaded.is_valid()); // no access_token yet
268    }
269
270    #[test]
271    fn save_is_idempotent_and_updates_in_place() {
272        let store = shared_store();
273        store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
274        store.agent_credential_save("agentx", "client-2", "secret-2", "endpoint-2").unwrap();
275
276        let loaded = store.agent_credential_load("agentx").unwrap().unwrap();
277        assert_eq!(loaded.client_id, "client-2");
278        assert_eq!(loaded.client_secret, "secret-2");
279    }
280
281    #[test]
282    fn save_token_caches_access_token_and_expiry() {
283        let store = shared_store();
284        store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
285
286        let future = agentmux_common::time::now_secs() + 3600;
287        store.agent_credential_save_token("agentx", "tok-abc", future).unwrap();
288
289        let loaded = store.agent_credential_load("agentx").unwrap().unwrap();
290        assert_eq!(loaded.access_token, "tok-abc");
291        assert_eq!(loaded.expires_at, future);
292        assert!(loaded.is_valid());
293    }
294
295    #[test]
296    fn save_token_is_a_noop_when_not_provisioned() {
297        let store = shared_store();
298        // No agent_credential_save call first -- row doesn't exist.
299        store.agent_credential_save_token("agentx", "tok-abc", agentmux_common::time::now_secs() + 3600).unwrap();
300        assert!(store.agent_credential_load("agentx").unwrap().is_none());
301    }
302
303    #[test]
304    fn invalidate_token_clears_token_and_expiry_but_keeps_client() {
305        let store = shared_store();
306        store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
307        store
308            .agent_credential_save_token("agentx", "tok-abc", agentmux_common::time::now_secs() + 3600)
309            .unwrap();
310
311        store.agent_credential_invalidate_token("agentx").unwrap();
312
313        let loaded = store.agent_credential_load("agentx").unwrap().unwrap();
314        assert_eq!(loaded.access_token, "");
315        assert_eq!(loaded.expires_at, 0);
316        assert!(!loaded.is_valid());
317        // Provisioned client identity survives — only the token was suspect.
318        assert_eq!(loaded.client_id, "client-1");
319        assert_eq!(loaded.client_secret, "secret-1");
320    }
321
322    #[test]
323    fn invalidate_token_is_a_noop_when_not_provisioned() {
324        let store = shared_store();
325        store.agent_credential_invalidate_token("agentx").unwrap();
326        assert!(store.agent_credential_load("agentx").unwrap().is_none());
327    }
328
329    #[test]
330    fn delete_removes_only_that_agents_row() {
331        let store = shared_store();
332        store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
333        store.agent_credential_save("agenty", "client-2", "secret-2", "endpoint-2").unwrap();
334
335        store.agent_credential_delete("AgentX").unwrap();
336
337        assert!(store.agent_credential_load("agentx").unwrap().is_none());
338        assert!(store.agent_credential_load("agenty").unwrap().is_some());
339        // Deleting a missing row is fine.
340        store.agent_credential_delete("agentx").unwrap();
341    }
342
343    #[test]
344    fn clear_all_wipes_every_agent_credential() {
345        let store = shared_store();
346        store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
347        store.agent_credential_save("agenty", "client-2", "secret-2", "endpoint-2").unwrap();
348
349        store.agent_credentials_clear_all().unwrap();
350
351        assert!(store.agent_credential_load("agentx").unwrap().is_none());
352        assert!(store.agent_credential_load("agenty").unwrap().is_none());
353    }
354
355    #[test]
356    fn clear_all_is_a_noop_on_an_empty_table() {
357        let store = shared_store();
358        store.agent_credentials_clear_all().unwrap();
359        assert!(store.agent_credential_load("agentx").unwrap().is_none());
360    }
361
362    #[test]
363    fn is_valid_respects_the_300s_early_refresh_margin() {
364        let mut cred = AgentCredential {
365            client_id: "c".to_string(),
366            client_secret: "s".to_string(),
367            token_endpoint: "e".to_string(),
368            access_token: "tok".to_string(),
369            expires_at: agentmux_common::time::now_secs() + 301,
370        };
371        assert!(cred.is_valid());
372
373        cred.expires_at = agentmux_common::time::now_secs() + 299;
374        assert!(!cred.is_valid());
375    }
376
377    #[test]
378    fn is_valid_false_with_no_access_token() {
379        let cred = AgentCredential {
380            expires_at: agentmux_common::time::now_secs() + 3600,
381            ..Default::default()
382        };
383        assert!(!cred.is_valid());
384    }
385}