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 channels(&self, team: &str, limit: u16, offset: usize) -> Result<Page, AppError> {
51        self.get_offset_page(
52            &format!(
53                "{GRAPH}/teams/{team}/channels?$select=id,displayName,description,membershipType"
54            ),
55            limit,
56            offset,
57        )
58        .await
59    }
60
61    pub async fn chats(&self, limit: u16, cursor: Option<&str>) -> Result<Page, AppError> {
62        self.get_page(
63            cursor,
64            &format!("{GRAPH}/me/chats?$expand=members&$top={limit}"),
65            limit,
66        )
67        .await
68    }
69
70    pub async fn messages(
71        &self,
72        chat: Option<&str>,
73        team: Option<&str>,
74        channel: Option<&str>,
75        limit: u16,
76        cursor: Option<&str>,
77    ) -> Result<Page, AppError> {
78        let base = match (chat, team, channel) {
79            (Some(chat), None, None) => format!("{GRAPH}/chats/{chat}/messages?$top={limit}"),
80            (None, Some(team), Some(channel)) => {
81                format!("{GRAPH}/teams/{team}/channels/{channel}/messages?$top={limit}")
82            }
83            _ => {
84                return Err(AppError::InvalidInput(
85                    "choose either --chat, or both --team and --channel".into(),
86                ));
87            }
88        };
89        self.get_page(cursor, &base, limit).await
90    }
91
92    pub async fn send(
93        &self,
94        chat: Option<&str>,
95        team: Option<&str>,
96        channel: Option<&str>,
97        body: &str,
98    ) -> Result<Value, AppError> {
99        let url = match (chat, team, channel) {
100            (Some(chat), None, None) => format!("{GRAPH}/chats/{chat}/messages"),
101            (None, Some(team), Some(channel)) => {
102                format!("{GRAPH}/teams/{team}/channels/{channel}/messages")
103            }
104            _ => {
105                return Err(AppError::InvalidInput(
106                    "choose either --chat, or both --team and --channel".into(),
107                ));
108            }
109        };
110        let response = self
111            .http
112            .post(url)
113            .bearer_auth(&self.token)
114            .json(&serde_json::json!({"body": {"contentType": "text", "content": body}}))
115            .send()
116            .await
117            .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
118        self.response_value(response).await
119    }
120
121    async fn get_value(&self, url: &str) -> Result<Value, AppError> {
122        let response = self
123            .http
124            .get(url)
125            .bearer_auth(&self.token)
126            .send()
127            .await
128            .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
129        self.response_value(response).await
130    }
131
132    async fn get_page(
133        &self,
134        cursor: Option<&str>,
135        first: &str,
136        limit: u16,
137    ) -> Result<Page, AppError> {
138        let url = cursor.unwrap_or(first);
139        if !is_graph_url(url) {
140            return Err(AppError::InvalidInput(
141                "--cursor must be an opaque Microsoft Graph v1.0 continuation URL".into(),
142            ));
143        }
144        let response = self
145            .http
146            .get(url)
147            .bearer_auth(&self.token)
148            .send()
149            .await
150            .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
151        let response = self.checked(response).await?;
152        let mut page: GraphPage = response
153            .json()
154            .await
155            .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))?;
156        let over_limit = page.value.len() > limit as usize;
157        page.value.truncate(limit as usize);
158        let truncated = over_limit || page.next_link.is_some();
159        Ok(Page {
160            items: page.value,
161            next_cursor: page.next_link,
162            next_offset: None,
163            truncated,
164        })
165    }
166
167    async fn get_offset_page(
168        &self,
169        first: &str,
170        limit: u16,
171        offset: usize,
172    ) -> Result<Page, AppError> {
173        let mut url = Some(first.to_string());
174        let mut all = Vec::new();
175        while let Some(current) = url {
176            if !is_graph_url(&current) {
177                return Err(AppError::Api(
178                    "Graph returned an invalid continuation URL".into(),
179                ));
180            }
181            let response = self
182                .http
183                .get(&current)
184                .bearer_auth(&self.token)
185                .send()
186                .await
187                .map_err(|e| {
188                    AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}"))
189                })?;
190            let page: GraphPage = self
191                .checked(response)
192                .await?
193                .json()
194                .await
195                .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))?;
196            all.extend(page.value);
197            url = page.next_link;
198            if all.len() > 10_000 {
199                return Err(AppError::Api(
200                    "Graph returned more than 10,000 records while preparing the requested page"
201                        .into(),
202                ));
203            }
204        }
205        let total = all.len();
206        let items = all.into_iter().skip(offset).take(limit as usize).collect();
207        let consumed = offset.saturating_add(limit as usize);
208        let truncated = consumed < total;
209        Ok(Page {
210            items,
211            next_cursor: None,
212            next_offset: truncated.then_some(consumed),
213            truncated,
214        })
215    }
216
217    async fn response_value(&self, response: reqwest::Response) -> Result<Value, AppError> {
218        self.checked(response)
219            .await?
220            .json()
221            .await
222            .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))
223    }
224
225    async fn checked(&self, response: reqwest::Response) -> Result<reqwest::Response, AppError> {
226        let status = response.status();
227        if status.is_success() {
228            return Ok(response);
229        }
230        let retry_after = response
231            .headers()
232            .get("retry-after")
233            .and_then(|v| v.to_str().ok())
234            .and_then(|v| v.parse().ok());
235        if status.as_u16() == 429 {
236            return Err(AppError::RateLimit(retry_after));
237        }
238        let body: Value = response.json().await.unwrap_or(Value::Null);
239        let message = body
240            .pointer("/error/message")
241            .and_then(Value::as_str)
242            .unwrap_or("Microsoft Graph rejected the request");
243        match status.as_u16() {
244            401 => Err(AppError::Auth(format!("{message}; run `teams auth login`"))),
245            403 => Err(AppError::Permission(format!(
246                "{message}; check delegated scopes and tenant consent with `teams doctor`"
247            ))),
248            404 => Err(AppError::NotFound(message.into())),
249            _ => Err(AppError::Api(format!("{message} ({status})"))),
250        }
251    }
252}
253
254fn is_graph_url(raw: &str) -> bool {
255    url::Url::parse(raw).is_ok_and(|url| {
256        url.scheme() == "https"
257            && url.host_str() == Some("graph.microsoft.com")
258            && url.path().starts_with("/v1.0/")
259    })
260}
261
262pub fn select_fields(page: &mut Page, fields: Option<&str>) -> Result<(), AppError> {
263    let Some(fields) = fields else {
264        return Ok(());
265    };
266    let names: Vec<&str> = fields
267        .split(',')
268        .map(str::trim)
269        .filter(|s| !s.is_empty())
270        .collect();
271    if names.is_empty() {
272        return Err(AppError::InvalidInput(
273            "--fields must name at least one field".into(),
274        ));
275    }
276    for item in &mut page.items {
277        let source = item
278            .as_object()
279            .ok_or_else(|| AppError::Api("Graph returned a non-object collection item".into()))?;
280        let filtered = names
281            .iter()
282            .filter_map(|name| {
283                source
284                    .get(*name)
285                    .cloned()
286                    .map(|value| ((*name).to_string(), value))
287            })
288            .collect();
289        *item = Value::Object(filtered);
290    }
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    #[test]
297    fn continuation_urls_cannot_exfiltrate_bearer_tokens() {
298        assert!(super::is_graph_url(
299            "https://graph.microsoft.com/v1.0/me/chats?$skiptoken=abc"
300        ));
301        assert!(!super::is_graph_url(
302            "https://graph.microsoft.com.evil.example/v1.0/me/chats"
303        ));
304        assert!(!super::is_graph_url(
305            "http://graph.microsoft.com/v1.0/me/chats"
306        ));
307    }
308}