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(¤t) {
177 return Err(AppError::Api(
178 "Graph returned an invalid continuation URL".into(),
179 ));
180 }
181 let response = self
182 .http
183 .get(¤t)
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}