Skip to main content

teams_cli/
graph.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::error::AppError;
5
6const GRAPH: &str = "https://graph.microsoft.com/v1.0";
7
8#[derive(Debug, Serialize)]
9pub struct Page {
10    pub items: Vec<Value>,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub next_cursor: Option<String>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub next_offset: Option<usize>,
15    pub truncated: bool,
16}
17
18#[derive(Debug, Deserialize)]
19struct GraphPage {
20    value: Vec<Value>,
21    #[serde(rename = "@odata.nextLink")]
22    next_link: Option<String>,
23}
24
25pub struct GraphClient {
26    http: reqwest::Client,
27    token: String,
28}
29
30impl GraphClient {
31    pub fn new(token: String) -> Self {
32        Self {
33            http: reqwest::Client::new(),
34            token,
35        }
36    }
37
38    pub async fn me(&self) -> Result<Value, AppError> {
39        self.get_value(&format!(
40            "{GRAPH}/me?$select=id,displayName,userPrincipalName,mail"
41        ))
42        .await
43    }
44
45    pub async fn teams(&self, limit: u16, offset: usize) -> Result<Page, AppError> {
46        self.get_offset_page(&format!("{GRAPH}/me/joinedTeams"), limit, offset)
47            .await
48    }
49
50    pub async fn teams_available(&self) -> Result<(), AppError> {
51        self.get_value(&format!("{GRAPH}/me/joinedTeams"))
52            .await
53            .map(|_| ())
54    }
55
56    pub async fn channels(&self, team: &str, limit: u16, offset: usize) -> Result<Page, AppError> {
57        self.get_offset_page(
58            &format!(
59                "{GRAPH}/teams/{team}/channels?$select=id,displayName,description,membershipType"
60            ),
61            limit,
62            offset,
63        )
64        .await
65    }
66
67    pub async fn chats(&self, limit: u16, cursor: Option<&str>) -> Result<Page, AppError> {
68        self.get_page(
69            cursor,
70            &format!("{GRAPH}/me/chats?$expand=members&$top={limit}"),
71            limit,
72        )
73        .await
74    }
75
76    pub async fn messages(
77        &self,
78        chat: Option<&str>,
79        team: Option<&str>,
80        channel: Option<&str>,
81        limit: u16,
82        cursor: Option<&str>,
83    ) -> Result<Page, AppError> {
84        let base = match (chat, team, channel) {
85            (Some(chat), None, None) => format!("{GRAPH}/chats/{chat}/messages?$top={limit}"),
86            (None, Some(team), Some(channel)) => {
87                format!("{GRAPH}/teams/{team}/channels/{channel}/messages?$top={limit}")
88            }
89            _ => {
90                return Err(AppError::InvalidInput(
91                    "choose either --chat, or both --team and --channel".into(),
92                ));
93            }
94        };
95        self.get_page(cursor, &base, limit).await
96    }
97
98    pub async fn send(
99        &self,
100        chat: Option<&str>,
101        team: Option<&str>,
102        channel: Option<&str>,
103        body: &str,
104    ) -> Result<Value, AppError> {
105        let url = match (chat, team, channel) {
106            (Some(chat), None, None) => format!("{GRAPH}/chats/{chat}/messages"),
107            (None, Some(team), Some(channel)) => {
108                format!("{GRAPH}/teams/{team}/channels/{channel}/messages")
109            }
110            _ => {
111                return Err(AppError::InvalidInput(
112                    "choose either --chat, or both --team and --channel".into(),
113                ));
114            }
115        };
116        let response = self
117            .http
118            .post(url)
119            .bearer_auth(&self.token)
120            .json(&serde_json::json!({"body": {"contentType": "text", "content": body}}))
121            .send()
122            .await
123            .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
124        self.response_value(response).await
125    }
126
127    async fn get_value(&self, url: &str) -> Result<Value, AppError> {
128        let response = self
129            .http
130            .get(url)
131            .bearer_auth(&self.token)
132            .send()
133            .await
134            .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
135        self.response_value(response).await
136    }
137
138    async fn get_page(
139        &self,
140        cursor: Option<&str>,
141        first: &str,
142        limit: u16,
143    ) -> Result<Page, AppError> {
144        let url = cursor.unwrap_or(first);
145        if !is_graph_url(url) {
146            return Err(AppError::InvalidInput(
147                "--cursor must be an opaque Microsoft Graph v1.0 continuation URL".into(),
148            ));
149        }
150        let response = self
151            .http
152            .get(url)
153            .bearer_auth(&self.token)
154            .send()
155            .await
156            .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
157        let response = self.checked(response).await?;
158        let mut page: GraphPage = response
159            .json()
160            .await
161            .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))?;
162        let over_limit = page.value.len() > limit as usize;
163        page.value.truncate(limit as usize);
164        let truncated = over_limit || page.next_link.is_some();
165        Ok(Page {
166            items: page.value,
167            next_cursor: page.next_link,
168            next_offset: None,
169            truncated,
170        })
171    }
172
173    async fn get_offset_page(
174        &self,
175        first: &str,
176        limit: u16,
177        offset: usize,
178    ) -> Result<Page, AppError> {
179        let mut url = Some(first.to_string());
180        let mut all = Vec::new();
181        while let Some(current) = url {
182            if !is_graph_url(&current) {
183                return Err(AppError::Api(
184                    "Graph returned an invalid continuation URL".into(),
185                ));
186            }
187            let response = self
188                .http
189                .get(&current)
190                .bearer_auth(&self.token)
191                .send()
192                .await
193                .map_err(|e| {
194                    AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}"))
195                })?;
196            let page: GraphPage = self
197                .checked(response)
198                .await?
199                .json()
200                .await
201                .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))?;
202            all.extend(page.value);
203            url = page.next_link;
204            if all.len() > 10_000 {
205                return Err(AppError::Api(
206                    "Graph returned more than 10,000 records while preparing the requested page"
207                        .into(),
208                ));
209            }
210        }
211        let total = all.len();
212        let items = all.into_iter().skip(offset).take(limit as usize).collect();
213        let consumed = offset.saturating_add(limit as usize);
214        let truncated = consumed < total;
215        Ok(Page {
216            items,
217            next_cursor: None,
218            next_offset: truncated.then_some(consumed),
219            truncated,
220        })
221    }
222
223    async fn response_value(&self, response: reqwest::Response) -> Result<Value, AppError> {
224        self.checked(response)
225            .await?
226            .json()
227            .await
228            .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))
229    }
230
231    async fn checked(&self, response: reqwest::Response) -> Result<reqwest::Response, AppError> {
232        let status = response.status();
233        if status.is_success() {
234            return Ok(response);
235        }
236        let retry_after = response
237            .headers()
238            .get("retry-after")
239            .and_then(|v| v.to_str().ok())
240            .and_then(|v| v.parse().ok());
241        if status.as_u16() == 429 {
242            return Err(AppError::RateLimit(retry_after));
243        }
244        let body: Value = response.json().await.unwrap_or(Value::Null);
245        let message = body
246            .pointer("/error/message")
247            .and_then(Value::as_str)
248            .unwrap_or("Microsoft Graph rejected the request");
249        match status.as_u16() {
250            401 => Err(AppError::Auth(format!("{message}; run `teams auth login`"))),
251            403 => Err(permission_error(message)),
252            404 => Err(AppError::NotFound(message.into())),
253            _ => Err(AppError::Api(format!("{message} ({status})"))),
254        }
255    }
256}
257
258fn permission_error(message: &str) -> AppError {
259    let message = message.trim();
260    let lower = message.to_ascii_lowercase();
261    let hint = if lower.contains("hasn't been provisioned")
262        || lower.contains("has not been provisioned")
263        || lower.contains("valid office365 subscription")
264        || lower.contains("valid microsoft 365 subscription")
265    {
266        "sign in with a work or school account that has Microsoft Teams enabled"
267    } else {
268        "check delegated scopes and tenant consent with `teams doctor`"
269    };
270    let separator = if matches!(message.chars().last(), Some('.' | '!' | '?')) {
271        " "
272    } else {
273        "; "
274    };
275    AppError::Permission(format!("{message}{separator}{hint}"))
276}
277
278fn is_graph_url(raw: &str) -> bool {
279    url::Url::parse(raw).is_ok_and(|url| {
280        url.scheme() == "https"
281            && url.host_str() == Some("graph.microsoft.com")
282            && url.path().starts_with("/v1.0/")
283    })
284}
285
286pub fn select_fields(page: &mut Page, fields: Option<&str>) -> Result<(), AppError> {
287    let Some(fields) = fields else {
288        return Ok(());
289    };
290    let names: Vec<&str> = fields
291        .split(',')
292        .map(str::trim)
293        .filter(|s| !s.is_empty())
294        .collect();
295    if names.is_empty() {
296        return Err(AppError::InvalidInput(
297            "--fields must name at least one field".into(),
298        ));
299    }
300    for item in &mut page.items {
301        let source = item
302            .as_object()
303            .ok_or_else(|| AppError::Api("Graph returned a non-object collection item".into()))?;
304        let filtered = names
305            .iter()
306            .filter_map(|name| {
307                source
308                    .get(*name)
309                    .cloned()
310                    .map(|value| ((*name).to_string(), value))
311            })
312            .collect();
313        *item = Value::Object(filtered);
314    }
315    Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320    #[test]
321    fn continuation_urls_cannot_exfiltrate_bearer_tokens() {
322        assert!(super::is_graph_url(
323            "https://graph.microsoft.com/v1.0/me/chats?$skiptoken=abc"
324        ));
325        assert!(!super::is_graph_url(
326            "https://graph.microsoft.com.evil.example/v1.0/me/chats"
327        ));
328        assert!(!super::is_graph_url(
329            "http://graph.microsoft.com/v1.0/me/chats"
330        ));
331    }
332
333    #[test]
334    fn unprovisioned_tenants_get_a_teams_specific_hint() {
335        let error = super::permission_error(
336            "Microsoft Teams hasn't been provisioned on the tenant. Ensure the tenant has a valid Office365 subscription.",
337        );
338        assert!(
339            error
340                .to_string()
341                .contains("account that has Microsoft Teams enabled")
342        );
343        assert!(!error.to_string().contains(".;"));
344    }
345}