1use std::collections::HashMap;
2use std::time::{Duration, SystemTime, UNIX_EPOCH};
3
4use base64::Engine;
5use rand::RngCore;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use tokio::net::TcpListener;
10
11use crate::config::Profile;
12use crate::error::AppError;
13use crate::output::Output;
14
15pub const BASE_SCOPES: &str = "openid profile offline_access User.Read Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send";
16pub const CHANNEL_HISTORY_SCOPE: &str = "ChannelMessage.Read.All";
17pub const CHANNEL_HISTORY_SCOPES: &str = "openid profile offline_access User.Read Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send ChannelMessage.Read.All";
18const SERVICE: &str = "teams-cli";
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct TokenBundle {
22 pub access_token: String,
23 pub refresh_token: Option<String>,
24 pub expires_at: u64,
25 pub scope: Option<String>,
26}
27
28#[derive(Debug, Deserialize)]
29struct TokenResponse {
30 access_token: String,
31 refresh_token: Option<String>,
32 expires_in: u64,
33 scope: Option<String>,
34}
35
36#[derive(Debug, Deserialize)]
37struct OAuthError {
38 error: String,
39 error_description: Option<String>,
40}
41
42fn now() -> u64 {
43 SystemTime::now()
44 .duration_since(UNIX_EPOCH)
45 .unwrap_or_default()
46 .as_secs()
47}
48
49fn entry(profile_name: &str) -> Result<keyring::Entry, AppError> {
50 keyring::Entry::new(SERVICE, profile_name)
51 .map_err(|e| AppError::Unexpected(format!("credential store is unavailable: {e}")))
52}
53
54pub fn has_token(profile_name: &str) -> bool {
55 std::env::var("TEAMS_ACCESS_TOKEN").is_ok()
56 || entry(profile_name)
57 .and_then(|e| {
58 e.get_password()
59 .map_err(|err| AppError::Unexpected(err.to_string()))
60 })
61 .is_ok()
62}
63
64pub fn granted_scopes(profile_name: &str) -> Option<Vec<String>> {
65 if std::env::var("TEAMS_ACCESS_TOKEN").is_ok() {
66 return None;
67 }
68 load(profile_name).ok().and_then(|token| {
69 token.scope.map(|scope| {
70 scope
71 .split_whitespace()
72 .map(str::to_owned)
73 .collect::<Vec<_>>()
74 })
75 })
76}
77
78pub fn channel_history_granted(profile_name: &str) -> Option<bool> {
79 granted_scopes(profile_name).map(|scopes| {
80 scopes
81 .iter()
82 .any(|scope| scope.eq_ignore_ascii_case(CHANNEL_HISTORY_SCOPE))
83 })
84}
85
86fn store(profile_name: &str, token: &TokenBundle) -> Result<(), AppError> {
87 let encoded = serde_json::to_string(token).map_err(|e| AppError::Unexpected(e.to_string()))?;
88 entry(profile_name)?.set_password(&encoded).map_err(|e| {
89 AppError::Unexpected(format!(
90 "could not save credentials in the OS credential store: {e}"
91 ))
92 })
93}
94
95fn load(profile_name: &str) -> Result<TokenBundle, AppError> {
96 let encoded = entry(profile_name)?
97 .get_password()
98 .map_err(|_| AppError::Auth("not signed in; run `teams auth login`".into()))?;
99 serde_json::from_str(&encoded).map_err(|_| {
100 AppError::Auth("stored credential is unreadable; run `teams auth login` again".into())
101 })
102}
103
104pub fn logout(profile_name: &str) -> Result<(), AppError> {
105 match entry(profile_name)?.delete_credential() {
106 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
107 Err(e) if secret_service_is_unavailable(&e) => Ok(()),
108 Err(e) => Err(AppError::Unexpected(format!(
109 "could not remove stored credentials: {e}"
110 ))),
111 }
112}
113
114#[cfg(target_os = "linux")]
115fn secret_service_is_unavailable(error: &keyring::Error) -> bool {
116 match error {
117 keyring::Error::PlatformFailure(source) => {
118 let message = source.to_string();
119 message.contains("org.freedesktop.secrets") && message.contains("not provided")
120 }
121 _ => false,
122 }
123}
124
125#[cfg(not(target_os = "linux"))]
126fn secret_service_is_unavailable(_error: &keyring::Error) -> bool {
127 false
128}
129
130pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<String, AppError> {
131 profile.require_graph()?;
132 if let Ok(token) = std::env::var("TEAMS_ACCESS_TOKEN") {
133 return Ok(token);
134 }
135 let mut bundle = load(profile_name)?;
136 if bundle.expires_at > now() + 120 {
137 return Ok(bundle.access_token);
138 }
139 let refresh = bundle.refresh_token.as_deref().ok_or_else(|| {
140 AppError::Auth(
141 "session expired and has no refresh credential; run `teams auth login`".into(),
142 )
143 })?;
144 let response = reqwest::Client::new()
145 .post(token_url(profile))
146 .form(&[
147 ("client_id", profile.client_id.as_str()),
148 ("grant_type", "refresh_token"),
149 ("refresh_token", refresh),
150 ("scope", bundle.scope.as_deref().unwrap_or(BASE_SCOPES)),
151 ])
152 .send()
153 .await
154 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
155 let mut replacement = parse_token_response(response).await?;
156 if replacement.refresh_token.is_none() {
157 replacement.refresh_token = bundle.refresh_token.clone();
158 }
159 bundle = replacement;
160 store(profile_name, &bundle)?;
161 Ok(bundle.access_token)
162}
163
164pub async fn login(
165 profile_name: &str,
166 profile: &Profile,
167 device_code: bool,
168 channel_history: bool,
169 out: &Output,
170) -> Result<TokenBundle, AppError> {
171 profile.require_graph()?;
172 let scopes = scopes(channel_history);
173 let bundle = if device_code {
174 device_login(profile, scopes, out).await?
175 } else {
176 browser_login(profile, scopes, out).await?
177 };
178 store(profile_name, &bundle)?;
179 Ok(bundle)
180}
181
182fn scopes(channel_history: bool) -> &'static str {
183 if channel_history {
184 CHANNEL_HISTORY_SCOPES
185 } else {
186 BASE_SCOPES
187 }
188}
189
190async fn browser_login(
191 profile: &Profile,
192 scopes: &str,
193 out: &Output,
194) -> Result<TokenBundle, AppError> {
195 let listener = TcpListener::bind("127.0.0.1:0").await?;
196 let redirect = format!("http://localhost:{}", listener.local_addr()?.port());
197 let mut verifier_bytes = [0u8; 32];
198 rand::rng().fill_bytes(&mut verifier_bytes);
199 let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
200 let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
201 .encode(Sha256::digest(verifier.as_bytes()));
202 let mut state_bytes = [0u8; 18];
203 rand::rng().fill_bytes(&mut state_bytes);
204 let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(state_bytes);
205 let mut authorize = url::Url::parse(&format!(
206 "https://login.microsoftonline.com/{}/oauth2/v2.0/authorize",
207 profile.tenant
208 ))
209 .map_err(|e| AppError::Unexpected(e.to_string()))?;
210 authorize
211 .query_pairs_mut()
212 .append_pair("client_id", &profile.client_id)
213 .append_pair("response_type", "code")
214 .append_pair("redirect_uri", &redirect)
215 .append_pair("response_mode", "query")
216 .append_pair("scope", scopes)
217 .append_pair("code_challenge", &challenge)
218 .append_pair("code_challenge_method", "S256")
219 .append_pair("state", &state);
220 out.note("Opening Microsoft sign-in in your browser…");
221 out.note(format!(
222 "If it does not open, visit:\n{}",
223 authorize.as_str()
224 ));
225 open::that(authorize.as_str())
226 .map_err(|e| AppError::Unexpected(format!("could not open a browser: {e}")))?;
227
228 let (mut socket, _) = tokio::time::timeout(Duration::from_secs(300), listener.accept())
229 .await
230 .map_err(|_| AppError::Auth("browser sign-in timed out after 5 minutes".into()))??;
231 let mut bytes = vec![0; 8192];
232 let count = socket.read(&mut bytes).await?;
233 let request = String::from_utf8_lossy(&bytes[..count]);
234 let target = request
235 .split_whitespace()
236 .nth(1)
237 .ok_or_else(|| AppError::Auth("invalid browser callback".into()))?;
238 let callback = url::Url::parse(&format!("http://localhost{target}"))
239 .map_err(|e| AppError::Auth(e.to_string()))?;
240 let params: HashMap<_, _> = callback.query_pairs().into_owned().collect();
241 let ok = params.contains_key("code");
242 let page = if ok {
243 "Sign-in complete. You can close this tab and return to teams."
244 } else {
245 "Sign-in did not complete. Return to the terminal for details."
246 };
247 let body = format!(
248 "<html><body style=\"font-family:system-ui;max-width:42rem;margin:10vh auto;padding:2rem\"><h1>{page}</h1></body></html>"
249 );
250 let response = format!(
251 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
252 body.len()
253 );
254 socket.write_all(response.as_bytes()).await?;
255 if params.get("state") != Some(&state) {
256 return Err(AppError::Auth(
257 "sign-in callback state did not match".into(),
258 ));
259 }
260 if let Some(error) = params
261 .get("error_description")
262 .or_else(|| params.get("error"))
263 {
264 return Err(AppError::Auth(error.clone()));
265 }
266 let code = params
267 .get("code")
268 .ok_or_else(|| AppError::Auth("Microsoft did not return an authorization code".into()))?;
269 let response = reqwest::Client::new()
270 .post(token_url(profile))
271 .form(&[
272 ("client_id", profile.client_id.as_str()),
273 ("grant_type", "authorization_code"),
274 ("code", code.as_str()),
275 ("redirect_uri", redirect.as_str()),
276 ("code_verifier", verifier.as_str()),
277 ("scope", scopes),
278 ])
279 .send()
280 .await
281 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
282 parse_token_response(response).await
283}
284
285#[derive(Debug, Deserialize)]
286struct DeviceCode {
287 device_code: String,
288 user_code: String,
289 verification_uri: String,
290 expires_in: u64,
291 interval: Option<u64>,
292 message: Option<String>,
293}
294
295async fn device_login(
296 profile: &Profile,
297 scopes: &str,
298 out: &Output,
299) -> Result<TokenBundle, AppError> {
300 let response = reqwest::Client::new()
301 .post(format!(
302 "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
303 profile.tenant
304 ))
305 .form(&[("client_id", profile.client_id.as_str()), ("scope", scopes)])
306 .send()
307 .await
308 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
309 if !response.status().is_success() {
310 return Err(oauth_response_error(response).await);
311 }
312 let device: DeviceCode = response
313 .json()
314 .await
315 .map_err(|e| AppError::Auth(format!("invalid device-code response: {e}")))?;
316 out.note(device.message.clone().unwrap_or_else(|| {
317 format!(
318 "Open {} and enter {}",
319 device.verification_uri, device.user_code
320 )
321 }));
322 let deadline = now() + device.expires_in;
323 let interval = device.interval.unwrap_or(5);
324 while now() < deadline {
325 tokio::time::sleep(Duration::from_secs(interval)).await;
326 let response = reqwest::Client::new()
327 .post(token_url(profile))
328 .form(&[
329 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
330 ("client_id", profile.client_id.as_str()),
331 ("device_code", device.device_code.as_str()),
332 ])
333 .send()
334 .await
335 .map_err(|e| AppError::Unexpected(e.to_string()))?;
336 if response.status().is_success() {
337 return parse_token_response(response).await;
338 }
339 let status = response.status();
340 let error: OAuthError = response.json().await.unwrap_or(OAuthError {
341 error: "unknown_error".into(),
342 error_description: None,
343 });
344 if error.error == "authorization_pending" {
345 continue;
346 }
347 if error.error == "slow_down" {
348 tokio::time::sleep(Duration::from_secs(5)).await;
349 continue;
350 }
351 return Err(AppError::Auth(
352 error
353 .error_description
354 .unwrap_or_else(|| format!("{} ({status})", error.error)),
355 ));
356 }
357 Err(AppError::Auth("device-code sign-in expired".into()))
358}
359
360fn token_url(profile: &Profile) -> String {
361 format!(
362 "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
363 profile.tenant
364 )
365}
366
367async fn parse_token_response(response: reqwest::Response) -> Result<TokenBundle, AppError> {
368 if !response.status().is_success() {
369 return Err(oauth_response_error(response).await);
370 }
371 let token: TokenResponse = response
372 .json()
373 .await
374 .map_err(|e| AppError::Auth(format!("invalid token response: {e}")))?;
375 Ok(TokenBundle {
376 access_token: token.access_token,
377 refresh_token: token.refresh_token,
378 expires_at: now() + token.expires_in,
379 scope: token.scope,
380 })
381}
382
383async fn oauth_response_error(response: reqwest::Response) -> AppError {
384 let status = response.status();
385 let error: Option<OAuthError> = response.json().await.ok();
386 AppError::Auth(
387 error
388 .and_then(|e| e.error_description.or(Some(e.error)))
389 .unwrap_or_else(|| format!("Microsoft sign-in failed with {status}")),
390 )
391}
392
393#[cfg(test)]
394mod tests {
395 use super::{BASE_SCOPES, CHANNEL_HISTORY_SCOPE, scopes};
396
397 #[test]
398 fn default_login_is_least_privilege() {
399 assert!(!BASE_SCOPES.contains(CHANNEL_HISTORY_SCOPE));
400 assert_eq!(scopes(false), BASE_SCOPES);
401 }
402
403 #[test]
404 fn channel_history_is_explicitly_opted_in() {
405 assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
406 }
407
408 #[cfg(target_os = "linux")]
409 #[test]
410 fn missing_secret_service_is_treated_as_an_empty_store() {
411 let error = keyring::Error::PlatformFailure(Box::new(std::io::Error::other(
412 "The name org.freedesktop.secrets was not provided by any .service files",
413 )));
414
415 assert!(super::secret_service_is_unavailable(&error));
416 }
417}