Skip to main content

nexo_auth/
telegram.rs

1//! [`CredentialStore`] impl for Telegram bots. One instance = one bot
2//! token. The token material is held in-memory as a `String` so the
3//! plugin can attach it to every HTTP call without hitting the
4//! filesystem per request; the gauntlet is responsible for enforcing
5//! 0o600 on the source file before the token ever reaches this store.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::error::CredentialError;
11use crate::handle::{Channel, CredentialHandle, TELEGRAM};
12use crate::store::{CredentialStore, ValidationReport};
13
14#[derive(Clone)]
15pub struct TelegramAccount {
16    pub instance: String,
17    pub token: String,
18    pub allow_agents: Vec<String>,
19    pub allowed_chat_ids: Vec<i64>,
20}
21
22impl std::fmt::Debug for TelegramAccount {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        // Never print token even under `{:?}`.
25        f.debug_struct("TelegramAccount")
26            .field("instance", &self.instance)
27            .field("allow_agents", &self.allow_agents)
28            .field("allowed_chat_ids", &self.allowed_chat_ids)
29            .field("token", &"<redacted>")
30            .finish()
31    }
32}
33
34#[derive(Debug, Clone)]
35pub struct TelegramCredentialStore {
36    accounts: Arc<HashMap<String, TelegramAccount>>,
37}
38
39impl TelegramCredentialStore {
40    pub fn new(accounts: Vec<TelegramAccount>) -> Self {
41        let mut map = HashMap::with_capacity(accounts.len());
42        for a in accounts {
43            map.insert(a.instance.clone(), a);
44        }
45        Self {
46            accounts: Arc::new(map),
47        }
48    }
49
50    pub fn empty() -> Self {
51        Self {
52            accounts: Arc::new(HashMap::new()),
53        }
54    }
55
56    pub fn account(&self, instance: &str) -> Option<&TelegramAccount> {
57        self.accounts.get(instance)
58    }
59}
60
61impl CredentialStore for TelegramCredentialStore {
62    type Account = TelegramAccount;
63
64    fn channel(&self) -> Channel {
65        TELEGRAM
66    }
67
68    fn get(&self, handle: &CredentialHandle) -> Result<Self::Account, CredentialError> {
69        let id = handle.account_id_raw();
70        self.accounts
71            .get(id)
72            .cloned()
73            .ok_or_else(|| CredentialError::NotFound {
74                channel: TELEGRAM,
75                account: id.to_string(),
76            })
77    }
78
79    fn issue(&self, account_id: &str, agent_id: &str) -> Result<CredentialHandle, CredentialError> {
80        let account = self
81            .accounts
82            .get(account_id)
83            .ok_or_else(|| CredentialError::NotFound {
84                channel: TELEGRAM,
85                account: account_id.to_string(),
86            })?;
87        if !account.allow_agents.is_empty() && !account.allow_agents.iter().any(|a| a == agent_id) {
88            let handle = CredentialHandle::new(TELEGRAM, account_id, agent_id);
89            return Err(CredentialError::NotPermitted {
90                channel: TELEGRAM,
91                agent: agent_id.to_string(),
92                fp: handle.fingerprint(),
93            });
94        }
95        Ok(CredentialHandle::new(TELEGRAM, account_id, agent_id))
96    }
97
98    fn list(&self) -> Vec<String> {
99        let mut ids: Vec<_> = self.accounts.keys().cloned().collect();
100        ids.sort();
101        ids
102    }
103
104    fn allow_agents(&self, account_id: &str) -> Vec<String> {
105        self.accounts
106            .get(account_id)
107            .map(|a| a.allow_agents.clone())
108            .unwrap_or_default()
109    }
110
111    fn validate(&self) -> ValidationReport {
112        let mut report = ValidationReport::default();
113        for (id, a) in self.accounts.iter() {
114            if a.token.trim().is_empty() {
115                report.warnings.push(format!(
116                    "telegram instance '{id}' has an empty token; bot will 401 on every call"
117                ));
118            } else {
119                report.accounts_ok += 1;
120            }
121        }
122        report
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn mk(instance: &str, allow: &[&str]) -> TelegramAccount {
131        TelegramAccount {
132            instance: instance.into(),
133            token: "123:ABC".into(),
134            allow_agents: allow.iter().map(|s| s.to_string()).collect(),
135            allowed_chat_ids: vec![],
136        }
137    }
138
139    #[test]
140    fn token_is_redacted_in_debug() {
141        let a = mk("ana", &["ana"]);
142        let rendered = format!("{a:?}");
143        assert!(!rendered.contains("123:ABC"));
144        assert!(rendered.contains("<redacted>"));
145    }
146
147    #[test]
148    fn issue_and_list() {
149        let store = TelegramCredentialStore::new(vec![mk("a", &["ana"]), mk("b", &[])]);
150        assert_eq!(store.list(), vec!["a", "b"]);
151        assert!(store.issue("a", "ana").is_ok());
152        assert!(matches!(
153            store.issue("a", "kate").unwrap_err(),
154            CredentialError::NotPermitted { .. }
155        ));
156        assert!(store.issue("b", "kate").is_ok());
157    }
158
159    #[test]
160    fn empty_token_warning() {
161        let account = TelegramAccount {
162            token: "   ".into(),
163            ..mk("a", &[])
164        };
165        let store = TelegramCredentialStore::new(vec![account]);
166        let report = store.validate();
167        assert_eq!(report.warnings.len(), 1);
168        assert_eq!(report.accounts_ok, 0);
169    }
170}