Skip to main content

nexo_auth/
google.rs

1//! [`CredentialStore`] impl for Google OAuth accounts. One account per
2//! agent (`agent_id` is 1:1 for V1). Holds paths to the three files
3//! the gmail-poller already uses (`client_id_path`, `client_secret_path`,
4//! `token_path`), plus scopes.
5//!
6//! Token refresh is serialised per-account with a `tokio::Mutex` so
7//! multiple jobs reading the same token file do not race and trigger
8//! Google's concurrent-refresh 400.
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use dashmap::DashMap;
15
16use crate::error::CredentialError;
17use crate::handle::{Channel, CredentialHandle, Fingerprint, GOOGLE};
18use crate::store::{CredentialStore, ValidationReport};
19
20#[derive(Debug, Clone)]
21pub struct GoogleAccount {
22    pub id: String,
23    pub agent_id: String,
24    pub client_id_path: PathBuf,
25    pub client_secret_path: PathBuf,
26    pub token_path: PathBuf,
27    pub scopes: Vec<String>,
28}
29
30pub struct GoogleCredentialStore {
31    accounts: Arc<HashMap<String, GoogleAccount>>,
32    /// Per-fingerprint serialisation for token refresh. Lazily created
33    /// the first time a refresh is requested for an account.
34    refresh_locks: DashMap<Fingerprint, Arc<tokio::sync::Mutex<()>>>,
35}
36
37impl GoogleCredentialStore {
38    pub fn new(accounts: Vec<GoogleAccount>) -> Self {
39        let mut map = HashMap::with_capacity(accounts.len());
40        for a in accounts {
41            map.insert(a.id.clone(), a);
42        }
43        Self {
44            accounts: Arc::new(map),
45            refresh_locks: DashMap::new(),
46        }
47    }
48
49    pub fn empty() -> Self {
50        Self {
51            accounts: Arc::new(HashMap::new()),
52            refresh_locks: DashMap::new(),
53        }
54    }
55
56    pub fn account(&self, id: &str) -> Option<&GoogleAccount> {
57        self.accounts.get(id)
58    }
59
60    pub fn account_for_agent(&self, agent_id: &str) -> Option<&GoogleAccount> {
61        self.accounts.values().find(|a| a.agent_id == agent_id)
62    }
63
64    /// Acquire the refresh mutex for the account behind `handle`. The
65    /// lock lives for the lifetime of the returned guard; callers
66    /// should hold it across the full HTTP roundtrip that rotates the
67    /// refresh_token on the disk. `None` when the handle points at an
68    /// unknown account (treat as NotFound).
69    pub fn refresh_lock(&self, handle: &CredentialHandle) -> Option<Arc<tokio::sync::Mutex<()>>> {
70        let fp = handle.fingerprint();
71        let entry = self
72            .refresh_locks
73            .entry(fp)
74            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())));
75        Some(entry.clone())
76    }
77}
78
79impl std::fmt::Debug for GoogleCredentialStore {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("GoogleCredentialStore")
82            .field("account_count", &self.accounts.len())
83            .field("active_refresh_locks", &self.refresh_locks.len())
84            .finish()
85    }
86}
87
88impl CredentialStore for GoogleCredentialStore {
89    type Account = GoogleAccount;
90
91    fn channel(&self) -> Channel {
92        GOOGLE
93    }
94
95    fn get(&self, handle: &CredentialHandle) -> Result<Self::Account, CredentialError> {
96        let id = handle.account_id_raw();
97        self.accounts
98            .get(id)
99            .cloned()
100            .ok_or_else(|| CredentialError::NotFound {
101                channel: GOOGLE,
102                account: id.to_string(),
103            })
104    }
105
106    fn issue(&self, account_id: &str, agent_id: &str) -> Result<CredentialHandle, CredentialError> {
107        let account = self
108            .accounts
109            .get(account_id)
110            .ok_or_else(|| CredentialError::NotFound {
111                channel: GOOGLE,
112                account: account_id.to_string(),
113            })?;
114        // Google accounts are 1:1 — the declared agent_id must match.
115        if account.agent_id != agent_id {
116            let handle = CredentialHandle::new(GOOGLE, account_id, agent_id);
117            return Err(CredentialError::NotPermitted {
118                channel: GOOGLE,
119                agent: agent_id.to_string(),
120                fp: handle.fingerprint(),
121            });
122        }
123        Ok(CredentialHandle::new(GOOGLE, account_id, agent_id))
124    }
125
126    fn list(&self) -> Vec<String> {
127        let mut ids: Vec<_> = self.accounts.keys().cloned().collect();
128        ids.sort();
129        ids
130    }
131
132    fn allow_agents(&self, account_id: &str) -> Vec<String> {
133        self.accounts
134            .get(account_id)
135            .map(|a| vec![a.agent_id.clone()])
136            .unwrap_or_default()
137    }
138
139    fn validate(&self) -> ValidationReport {
140        let mut report = ValidationReport::default();
141        for (id, a) in self.accounts.iter() {
142            if a.scopes.is_empty() {
143                report
144                    .warnings
145                    .push(format!("google account '{id}' has no scopes declared"));
146            }
147            // Paths prefixed with `inline:` are synthetic markers for
148            // legacy `agents[].google_auth` inline credentials migrated
149            // in-memory. Skip the exists-check for those.
150            let is_inline = |p: &std::path::Path| p.to_string_lossy().starts_with("inline:");
151            if !is_inline(&a.client_id_path) && !a.client_id_path.exists() {
152                report.errors.push(crate::error::BuildError::Credential {
153                    channel: GOOGLE,
154                    instance: id.clone(),
155                    source: CredentialError::FileMissing {
156                        path: a.client_id_path.clone(),
157                    },
158                });
159            }
160            if !is_inline(&a.client_secret_path) && !a.client_secret_path.exists() {
161                report.errors.push(crate::error::BuildError::Credential {
162                    channel: GOOGLE,
163                    instance: id.clone(),
164                    source: CredentialError::FileMissing {
165                        path: a.client_secret_path.clone(),
166                    },
167                });
168            }
169            // token_path is allowed to be absent — the setup wizard
170            // writes it on first consent.
171            report.accounts_ok += 1;
172        }
173        report
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn mk(id: &str, agent: &str) -> GoogleAccount {
182        GoogleAccount {
183            id: id.into(),
184            agent_id: agent.into(),
185            client_id_path: PathBuf::from("/nonexistent/cid"),
186            client_secret_path: PathBuf::from("/nonexistent/csec"),
187            token_path: PathBuf::from("/nonexistent/tok"),
188            scopes: vec!["https://www.googleapis.com/auth/gmail.readonly".into()],
189        }
190    }
191
192    #[test]
193    fn issue_rejects_mismatched_agent() {
194        let store = GoogleCredentialStore::new(vec![mk("ana@x.com", "ana")]);
195        assert!(store.issue("ana@x.com", "ana").is_ok());
196        let err = store.issue("ana@x.com", "kate").unwrap_err();
197        assert!(matches!(err, CredentialError::NotPermitted { .. }));
198    }
199
200    #[test]
201    fn account_for_agent_lookup() {
202        let store =
203            GoogleCredentialStore::new(vec![mk("ana@x.com", "ana"), mk("kate@x.com", "kate")]);
204        assert_eq!(store.account_for_agent("ana").unwrap().id, "ana@x.com");
205        assert_eq!(store.account_for_agent("kate").unwrap().id, "kate@x.com");
206        assert!(store.account_for_agent("nobody").is_none());
207    }
208
209    #[tokio::test]
210    async fn refresh_lock_serialises_same_account() {
211        let store = GoogleCredentialStore::new(vec![mk("ana@x.com", "ana")]);
212        let h = store.issue("ana@x.com", "ana").unwrap();
213        let l1 = store.refresh_lock(&h).unwrap();
214        let l2 = store.refresh_lock(&h).unwrap();
215        // Same Arc instance — both handles map to the same mutex.
216        assert!(Arc::ptr_eq(&l1, &l2));
217        let _guard = l1.lock().await;
218        // Second lock attempt should time out while the first guard is held.
219        let try_second =
220            tokio::time::timeout(std::time::Duration::from_millis(50), l2.lock()).await;
221        assert!(try_second.is_err(), "second lock should block");
222    }
223
224    #[tokio::test]
225    async fn refresh_lock_distinct_for_different_accounts() {
226        let store = GoogleCredentialStore::new(vec![mk("a@x.com", "ana"), mk("k@x.com", "kate")]);
227        let ha = store.issue("a@x.com", "ana").unwrap();
228        let hk = store.issue("k@x.com", "kate").unwrap();
229        let la = store.refresh_lock(&ha).unwrap();
230        let lk = store.refresh_lock(&hk).unwrap();
231        assert!(!Arc::ptr_eq(&la, &lk));
232    }
233
234    #[test]
235    fn validate_flags_missing_files() {
236        let store = GoogleCredentialStore::new(vec![mk("ana@x.com", "ana")]);
237        let report = store.validate();
238        assert!(report.errors.len() >= 2, "missing files should surface");
239    }
240}