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 if let Ok(token) = std::env::var("TEAMS_ACCESS_TOKEN") {
132 return Ok(token);
133 }
134 let mut bundle = load(profile_name)?;
135 if bundle.expires_at > now() + 120 {
136 return Ok(bundle.access_token);
137 }
138 let refresh = bundle.refresh_token.as_deref().ok_or_else(|| {
139 AppError::Auth(
140 "session expired and has no refresh credential; run `teams auth login`".into(),
141 )
142 })?;
143 let response = reqwest::Client::new()
144 .post(token_url(profile))
145 .form(&[
146 ("client_id", profile.client_id.as_str()),
147 ("grant_type", "refresh_token"),
148 ("refresh_token", refresh),
149 ("scope", bundle.scope.as_deref().unwrap_or(BASE_SCOPES)),
150 ])
151 .send()
152 .await
153 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
154 let mut replacement = parse_token_response(response).await?;
155 if replacement.refresh_token.is_none() {
156 replacement.refresh_token = bundle.refresh_token.clone();
157 }
158 bundle = replacement;
159 store(profile_name, &bundle)?;
160 Ok(bundle.access_token)
161}
162
163pub async fn login(
164 profile_name: &str,
165 profile: &Profile,
166 device_code: bool,
167 channel_history: bool,
168 out: &Output,
169) -> Result<TokenBundle, AppError> {
170 let scopes = scopes(channel_history);
171 let bundle = if device_code {
172 device_login(profile, scopes, out).await?
173 } else {
174 browser_login(profile, scopes, out).await?
175 };
176 store(profile_name, &bundle)?;
177 Ok(bundle)
178}
179
180fn scopes(channel_history: bool) -> &'static str {
181 if channel_history {
182 CHANNEL_HISTORY_SCOPES
183 } else {
184 BASE_SCOPES
185 }
186}
187
188async fn browser_login(
189 profile: &Profile,
190 scopes: &str,
191 out: &Output,
192) -> Result<TokenBundle, AppError> {
193 let listener = TcpListener::bind("127.0.0.1:0").await?;
194 let redirect = format!("http://localhost:{}", listener.local_addr()?.port());
195 let mut verifier_bytes = [0u8; 32];
196 rand::rng().fill_bytes(&mut verifier_bytes);
197 let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
198 let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
199 .encode(Sha256::digest(verifier.as_bytes()));
200 let mut state_bytes = [0u8; 18];
201 rand::rng().fill_bytes(&mut state_bytes);
202 let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(state_bytes);
203 let mut authorize = url::Url::parse(&format!(
204 "https://login.microsoftonline.com/{}/oauth2/v2.0/authorize",
205 profile.tenant
206 ))
207 .map_err(|e| AppError::Unexpected(e.to_string()))?;
208 authorize
209 .query_pairs_mut()
210 .append_pair("client_id", &profile.client_id)
211 .append_pair("response_type", "code")
212 .append_pair("redirect_uri", &redirect)
213 .append_pair("response_mode", "query")
214 .append_pair("scope", scopes)
215 .append_pair("code_challenge", &challenge)
216 .append_pair("code_challenge_method", "S256")
217 .append_pair("state", &state);
218 out.note("Opening Microsoft sign-in in your browser…");
219 out.note(format!(
220 "If it does not open, visit:\n{}",
221 authorize.as_str()
222 ));
223 open::that(authorize.as_str())
224 .map_err(|e| AppError::Unexpected(format!("could not open a browser: {e}")))?;
225
226 let (mut socket, _) = tokio::time::timeout(Duration::from_secs(300), listener.accept())
227 .await
228 .map_err(|_| AppError::Auth("browser sign-in timed out after 5 minutes".into()))??;
229 let mut bytes = vec![0; 8192];
230 let count = socket.read(&mut bytes).await?;
231 let request = String::from_utf8_lossy(&bytes[..count]);
232 let target = request
233 .split_whitespace()
234 .nth(1)
235 .ok_or_else(|| AppError::Auth("invalid browser callback".into()))?;
236 let callback = url::Url::parse(&format!("http://localhost{target}"))
237 .map_err(|e| AppError::Auth(e.to_string()))?;
238 let params: HashMap<_, _> = callback.query_pairs().into_owned().collect();
239 let ok = params.contains_key("code");
240 let page = if ok {
241 "Sign-in complete. You can close this tab and return to teams."
242 } else {
243 "Sign-in did not complete. Return to the terminal for details."
244 };
245 let body = format!(
246 "<html><body style=\"font-family:system-ui;max-width:42rem;margin:10vh auto;padding:2rem\"><h1>{page}</h1></body></html>"
247 );
248 let response = format!(
249 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
250 body.len()
251 );
252 socket.write_all(response.as_bytes()).await?;
253 if params.get("state") != Some(&state) {
254 return Err(AppError::Auth(
255 "sign-in callback state did not match".into(),
256 ));
257 }
258 if let Some(error) = params
259 .get("error_description")
260 .or_else(|| params.get("error"))
261 {
262 return Err(AppError::Auth(error.clone()));
263 }
264 let code = params
265 .get("code")
266 .ok_or_else(|| AppError::Auth("Microsoft did not return an authorization code".into()))?;
267 let response = reqwest::Client::new()
268 .post(token_url(profile))
269 .form(&[
270 ("client_id", profile.client_id.as_str()),
271 ("grant_type", "authorization_code"),
272 ("code", code.as_str()),
273 ("redirect_uri", redirect.as_str()),
274 ("code_verifier", verifier.as_str()),
275 ("scope", scopes),
276 ])
277 .send()
278 .await
279 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
280 parse_token_response(response).await
281}
282
283#[derive(Debug, Deserialize)]
284struct DeviceCode {
285 device_code: String,
286 user_code: String,
287 verification_uri: String,
288 expires_in: u64,
289 interval: Option<u64>,
290 message: Option<String>,
291}
292
293async fn device_login(
294 profile: &Profile,
295 scopes: &str,
296 out: &Output,
297) -> Result<TokenBundle, AppError> {
298 let response = reqwest::Client::new()
299 .post(format!(
300 "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
301 profile.tenant
302 ))
303 .form(&[("client_id", profile.client_id.as_str()), ("scope", scopes)])
304 .send()
305 .await
306 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
307 if !response.status().is_success() {
308 return Err(oauth_response_error(response).await);
309 }
310 let device: DeviceCode = response
311 .json()
312 .await
313 .map_err(|e| AppError::Auth(format!("invalid device-code response: {e}")))?;
314 out.note(device.message.clone().unwrap_or_else(|| {
315 format!(
316 "Open {} and enter {}",
317 device.verification_uri, device.user_code
318 )
319 }));
320 let deadline = now() + device.expires_in;
321 let interval = device.interval.unwrap_or(5);
322 while now() < deadline {
323 tokio::time::sleep(Duration::from_secs(interval)).await;
324 let response = reqwest::Client::new()
325 .post(token_url(profile))
326 .form(&[
327 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
328 ("client_id", profile.client_id.as_str()),
329 ("device_code", device.device_code.as_str()),
330 ])
331 .send()
332 .await
333 .map_err(|e| AppError::Unexpected(e.to_string()))?;
334 if response.status().is_success() {
335 return parse_token_response(response).await;
336 }
337 let status = response.status();
338 let error: OAuthError = response.json().await.unwrap_or(OAuthError {
339 error: "unknown_error".into(),
340 error_description: None,
341 });
342 if error.error == "authorization_pending" {
343 continue;
344 }
345 if error.error == "slow_down" {
346 tokio::time::sleep(Duration::from_secs(5)).await;
347 continue;
348 }
349 return Err(AppError::Auth(
350 error
351 .error_description
352 .unwrap_or_else(|| format!("{} ({status})", error.error)),
353 ));
354 }
355 Err(AppError::Auth("device-code sign-in expired".into()))
356}
357
358fn token_url(profile: &Profile) -> String {
359 format!(
360 "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
361 profile.tenant
362 )
363}
364
365async fn parse_token_response(response: reqwest::Response) -> Result<TokenBundle, AppError> {
366 if !response.status().is_success() {
367 return Err(oauth_response_error(response).await);
368 }
369 let token: TokenResponse = response
370 .json()
371 .await
372 .map_err(|e| AppError::Auth(format!("invalid token response: {e}")))?;
373 Ok(TokenBundle {
374 access_token: token.access_token,
375 refresh_token: token.refresh_token,
376 expires_at: now() + token.expires_in,
377 scope: token.scope,
378 })
379}
380
381async fn oauth_response_error(response: reqwest::Response) -> AppError {
382 let status = response.status();
383 let error: Option<OAuthError> = response.json().await.ok();
384 AppError::Auth(
385 error
386 .and_then(|e| e.error_description.or(Some(e.error)))
387 .unwrap_or_else(|| format!("Microsoft sign-in failed with {status}")),
388 )
389}
390
391#[cfg(test)]
392mod tests {
393 use super::{BASE_SCOPES, CHANNEL_HISTORY_SCOPE, scopes};
394
395 #[test]
396 fn default_login_is_least_privilege() {
397 assert!(!BASE_SCOPES.contains(CHANNEL_HISTORY_SCOPE));
398 assert_eq!(scopes(false), BASE_SCOPES);
399 }
400
401 #[test]
402 fn channel_history_is_explicitly_opted_in() {
403 assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
404 }
405
406 #[cfg(target_os = "linux")]
407 #[test]
408 fn missing_secret_service_is_treated_as_an_empty_store() {
409 let error = keyring::Error::PlatformFailure(Box::new(std::io::Error::other(
410 "The name org.freedesktop.secrets was not provided by any .service files",
411 )));
412
413 assert!(super::secret_service_is_unavailable(&error));
414 }
415}