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::{CredentialStore, Profile};
12use crate::error::AppError;
13use crate::output::Output;
14
15pub const BASE_SCOPES: &str = "openid profile offline_access User.Read User.ReadBasic.All 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 User.ReadBasic.All Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send ChannelMessage.Read.All";
18const SERVICE: &str = "teams-cli";
19
20mod pass;
21mod process;
22
23#[cfg(any(target_os = "linux", test))]
24mod wsl;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TokenBundle {
28 pub access_token: String,
29 pub refresh_token: Option<String>,
30 pub expires_at: u64,
31 pub scope: Option<String>,
32}
33
34#[derive(Debug, Deserialize)]
35struct TokenResponse {
36 access_token: String,
37 refresh_token: Option<String>,
38 expires_in: u64,
39 scope: Option<String>,
40}
41
42#[derive(Debug, Deserialize)]
43struct OAuthError {
44 error: String,
45 error_description: Option<String>,
46}
47
48fn now() -> u64 {
49 SystemTime::now()
50 .duration_since(UNIX_EPOCH)
51 .unwrap_or_default()
52 .as_secs()
53}
54
55fn entry(profile_name: &str) -> Result<keyring::Entry, AppError> {
56 let profile = crate::config::connection_profile(Some(profile_name))?;
57 entry_for(profile_name, profile.as_ref())
58}
59
60fn entry_for(profile_name: &str, profile: Option<&Profile>) -> Result<keyring::Entry, AppError> {
61 if let Some(profile) = profile
62 && profile.credential_store == CredentialStore::Pass
63 {
64 let directory = profile.pass_store_dir.as_deref().ok_or_else(|| AppError::InvalidInput(
65 "This pass profile has no saved pass_store_dir; restore its original directory in the profile configuration. No other credential store was used".into(),
66 ))?;
67 return pass::entry(profile_name, directory);
68 }
69 #[cfg(target_os = "linux")]
70 if wsl::is_wsl() {
71 return Ok(wsl::entry(profile_name));
72 }
73 keyring::Entry::new(SERVICE, profile_name)
74 .map_err(|e| AppError::Unexpected(format!("credential store is unavailable: {e}")))
75}
76
77pub fn credential_present(profile_name: &str) -> Result<bool, AppError> {
79 if std::env::var("TEAMS_ACCESS_TOKEN").is_ok() {
80 return Ok(true);
81 }
82 match entry(profile_name)?.get_password() {
83 Ok(_) => Ok(true),
84 Err(keyring::Error::NoEntry) => Ok(false),
85 Err(error) => Err(AppError::Unexpected(format!(
86 "Could not read the saved credential: {error}"
87 ))),
88 }
89}
90
91pub fn has_token(profile_name: &str) -> bool {
92 credential_present(profile_name).unwrap_or(false)
93}
94
95pub fn available_pass_directory() -> Option<std::path::PathBuf> {
96 pass::available_directory()
97}
98
99pub fn prepare_pass_directory(
100 directory: Option<&std::path::Path>,
101) -> Result<std::path::PathBuf, AppError> {
102 pass::prepare(directory)
103}
104
105pub fn native_store_label() -> &'static str {
106 #[cfg(target_os = "linux")]
107 if wsl::is_wsl() {
108 return "Windows DPAPI";
109 }
110 if cfg!(target_os = "macos") {
111 "macOS Keychain"
112 } else if cfg!(windows) {
113 "Windows Credential Manager"
114 } else {
115 "Linux Secret Service"
116 }
117}
118
119pub fn native_store_error(profile_name: &str) -> Option<String> {
121 let result = entry_for(profile_name, None).and_then(|entry| match entry.get_password() {
122 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
123 Err(error) => Err(AppError::Unexpected(error.to_string())),
124 });
125 result.err().map(|error| error.to_string())
126}
127
128pub fn check_store(profile_name: &str, profile: &Profile) -> Result<(), AppError> {
130 match entry_for(profile_name, Some(profile))?.get_password() {
131 Err(keyring::Error::NoEntry) if profile.credential_store == CredentialStore::Pass => {
132 pass::probe(
133 profile
134 .pass_store_dir
135 .as_deref()
136 .expect("validated pass directory"),
137 )
138 }
139 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
140 Err(error) => Err(AppError::Unexpected(format!(
141 "Credential storage is unavailable: {error}. Sign-in was not started"
142 ))),
143 }
144}
145
146pub fn granted_scopes(profile_name: &str) -> Option<Vec<String>> {
147 if std::env::var("TEAMS_ACCESS_TOKEN").is_ok() {
148 return None;
149 }
150 load(profile_name).ok().and_then(|token| {
151 token.scope.map(|scope| {
152 scope
153 .split_whitespace()
154 .map(str::to_owned)
155 .collect::<Vec<_>>()
156 })
157 })
158}
159
160pub fn channel_history_granted(profile_name: &str) -> Option<bool> {
161 granted_scopes(profile_name).map(|scopes| {
162 scopes
163 .iter()
164 .any(|scope| scope.eq_ignore_ascii_case(CHANNEL_HISTORY_SCOPE))
165 })
166}
167
168fn store(profile_name: &str, profile: &Profile, token: &TokenBundle) -> Result<(), AppError> {
169 let encoded = serde_json::to_string(token).map_err(|e| AppError::Unexpected(e.to_string()))?;
170 entry_for(profile_name, Some(profile))?
171 .set_password(&encoded)
172 .map_err(|e| {
173 AppError::Unexpected(format!(
174 "could not save credentials in the selected credential store: {e}"
175 ))
176 })
177}
178
179fn load(profile_name: &str) -> Result<TokenBundle, AppError> {
180 let encoded = entry(profile_name)?
181 .get_password()
182 .map_err(|error| match error {
183 keyring::Error::NoEntry => {
184 AppError::Auth("not signed in; run `teams auth login`".into())
185 }
186 error => AppError::Unexpected(format!("Could not read the saved credential: {error}")),
187 })?;
188 serde_json::from_str(&encoded).map_err(|_| {
189 AppError::Auth("stored credential is unreadable; run `teams auth login` again".into())
190 })
191}
192
193pub fn logout(profile_name: &str) -> Result<(), AppError> {
194 match entry(profile_name)?.delete_credential() {
195 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
196 Err(e) if secret_service_is_unavailable(&e) => Ok(()),
197 Err(e) => Err(AppError::Unexpected(format!(
198 "could not remove stored credentials: {e}"
199 ))),
200 }
201}
202
203#[cfg(target_os = "linux")]
204fn secret_service_is_unavailable(error: &keyring::Error) -> bool {
205 match error {
206 keyring::Error::PlatformFailure(source) => {
207 let message = source.to_string();
208 message.contains("org.freedesktop.secrets") && message.contains("not provided")
209 }
210 _ => false,
211 }
212}
213
214#[cfg(not(target_os = "linux"))]
215fn secret_service_is_unavailable(_error: &keyring::Error) -> bool {
216 false
217}
218
219pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<String, AppError> {
220 profile.require_graph()?;
221 if let Ok(token) = std::env::var("TEAMS_ACCESS_TOKEN") {
222 return Ok(token);
223 }
224 let mut bundle = load(profile_name)?;
225 if bundle.expires_at > now() + 120 {
226 return Ok(bundle.access_token);
227 }
228 let refresh = bundle.refresh_token.as_deref().ok_or_else(|| {
229 AppError::Auth(
230 "session expired and has no refresh credential; run `teams auth login`".into(),
231 )
232 })?;
233 let response = reqwest::Client::new()
234 .post(token_url(profile))
235 .form(&[
236 ("client_id", profile.client_id.as_str()),
237 ("grant_type", "refresh_token"),
238 ("refresh_token", refresh),
239 ("scope", bundle.scope.as_deref().unwrap_or(BASE_SCOPES)),
240 ])
241 .send()
242 .await
243 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
244 let mut replacement = parse_token_response(response).await?;
245 if replacement.refresh_token.is_none() {
246 replacement.refresh_token = bundle.refresh_token.clone();
247 }
248 bundle = replacement;
249 store(profile_name, profile, &bundle)?;
250 Ok(bundle.access_token)
251}
252
253pub async fn login(
254 profile_name: &str,
255 profile: &Profile,
256 device_code: bool,
257 channel_history: bool,
258 out: &Output,
259) -> Result<TokenBundle, AppError> {
260 check_store(profile_name, profile)?;
261 let bundle = sign_in(profile, device_code, channel_history, out).await?;
262 store(profile_name, profile, &bundle)?;
263 Ok(bundle)
264}
265
266pub async fn sign_in(
269 profile: &Profile,
270 device_code: bool,
271 channel_history: bool,
272 out: &Output,
273) -> Result<TokenBundle, AppError> {
274 profile.require_graph()?;
275 let scopes = scopes(channel_history);
276 if device_code {
277 device_login(profile, scopes, out).await
278 } else {
279 browser_login(profile, scopes, out).await
280 }
281}
282
283pub fn save_verified_login<T>(
285 profile_name: &str,
286 profile: &Profile,
287 bundle: &TokenBundle,
288 save_config: impl FnOnce() -> Result<T, AppError>,
289) -> Result<T, AppError> {
290 commit_login(
291 &entry_for(profile_name, Some(profile))?,
292 bundle,
293 save_config,
294 )
295}
296
297fn commit_login<T>(
298 credential: &keyring::Entry,
299 bundle: &TokenBundle,
300 save_config: impl FnOnce() -> Result<T, AppError>,
301) -> Result<T, AppError> {
302 let previous = match credential.get_password() {
303 Ok(previous) => Some(previous),
304 Err(keyring::Error::NoEntry) => None,
305 Err(error) => {
306 return Err(AppError::Unexpected(format!(
307 "Could not preserve the previous sign-in: {error}. No connection settings were saved."
308 )));
309 }
310 };
311 let encoded = serde_json::to_string(bundle).map_err(|e| AppError::Unexpected(e.to_string()))?;
312 credential.set_password(&encoded).map_err(|e| {
313 AppError::Unexpected(format!(
314 "Could not save the verified sign-in: {e}. No connection settings were saved."
315 ))
316 })?;
317 match save_config() {
318 Ok(result) => Ok(result),
319 Err(error) => {
320 let restored = match previous {
321 Some(previous) => credential.set_password(&previous),
322 None => match credential.delete_credential() {
323 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
324 Err(error) => Err(error),
325 },
326 };
327 match restored {
328 Ok(()) => Err(error.with_hint("Connection settings could not be saved; your previous sign-in was preserved.")),
329 Err(_) => Err(error.with_hint("Connection settings could not be saved, and the credential store could not restore your previous sign-in. Retry setup to reconnect this profile.")),
330 }
331 }
332 }
333}
334
335fn scopes(channel_history: bool) -> &'static str {
336 if channel_history {
337 CHANNEL_HISTORY_SCOPES
338 } else {
339 BASE_SCOPES
340 }
341}
342
343async fn browser_login(
344 profile: &Profile,
345 scopes: &str,
346 out: &Output,
347) -> Result<TokenBundle, AppError> {
348 let listener = TcpListener::bind("127.0.0.1:0").await?;
349 let redirect = format!("http://localhost:{}", listener.local_addr()?.port());
350 let mut verifier_bytes = [0u8; 32];
351 rand::rng().fill_bytes(&mut verifier_bytes);
352 let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
353 let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
354 .encode(Sha256::digest(verifier.as_bytes()));
355 let mut state_bytes = [0u8; 18];
356 rand::rng().fill_bytes(&mut state_bytes);
357 let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(state_bytes);
358 let mut authorize = url::Url::parse(&format!(
359 "https://login.microsoftonline.com/{}/oauth2/v2.0/authorize",
360 profile.tenant
361 ))
362 .map_err(|e| AppError::Unexpected(e.to_string()))?;
363 authorize
364 .query_pairs_mut()
365 .append_pair("client_id", &profile.client_id)
366 .append_pair("response_type", "code")
367 .append_pair("redirect_uri", &redirect)
368 .append_pair("response_mode", "query")
369 .append_pair("scope", scopes)
370 .append_pair("code_challenge", &challenge)
371 .append_pair("code_challenge_method", "S256")
372 .append_pair("state", &state);
373 out.note("Opening Microsoft sign-in in your browser…");
374 out.note(format!(
375 "If it does not open, visit:\n{}",
376 authorize.as_str()
377 ));
378 open::that(authorize.as_str())
379 .map_err(|e| AppError::Unexpected(format!("could not open a browser: {e}")))?;
380
381 let (mut socket, _) = tokio::time::timeout(Duration::from_secs(300), listener.accept())
382 .await
383 .map_err(|_| AppError::Auth("browser sign-in timed out after 5 minutes".into()))??;
384 let mut bytes = vec![0; 8192];
385 let count = socket.read(&mut bytes).await?;
386 let request = String::from_utf8_lossy(&bytes[..count]);
387 let target = request
388 .split_whitespace()
389 .nth(1)
390 .ok_or_else(|| AppError::Auth("invalid browser callback".into()))?;
391 let callback = url::Url::parse(&format!("http://localhost{target}"))
392 .map_err(|e| AppError::Auth(e.to_string()))?;
393 let params: HashMap<_, _> = callback.query_pairs().into_owned().collect();
394 let ok = params.contains_key("code");
395 let page = if ok {
396 "Sign-in complete. You can close this tab and return to teams."
397 } else {
398 "Sign-in did not complete. Return to the terminal for details."
399 };
400 let body = format!(
401 "<html><body style=\"font-family:system-ui;max-width:42rem;margin:10vh auto;padding:2rem\"><h1>{page}</h1></body></html>"
402 );
403 let response = format!(
404 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
405 body.len()
406 );
407 socket.write_all(response.as_bytes()).await?;
408 if params.get("state") != Some(&state) {
409 return Err(AppError::Auth(
410 "sign-in callback state did not match".into(),
411 ));
412 }
413 if let Some(error) = params
414 .get("error_description")
415 .or_else(|| params.get("error"))
416 {
417 return Err(AppError::Auth(error.clone()));
418 }
419 let code = params
420 .get("code")
421 .ok_or_else(|| AppError::Auth("Microsoft did not return an authorization code".into()))?;
422 let response = reqwest::Client::new()
423 .post(token_url(profile))
424 .form(&[
425 ("client_id", profile.client_id.as_str()),
426 ("grant_type", "authorization_code"),
427 ("code", code.as_str()),
428 ("redirect_uri", redirect.as_str()),
429 ("code_verifier", verifier.as_str()),
430 ("scope", scopes),
431 ])
432 .send()
433 .await
434 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
435 parse_token_response(response).await
436}
437
438#[derive(Debug, Deserialize)]
439struct DeviceCode {
440 device_code: String,
441 user_code: String,
442 verification_uri: String,
443 expires_in: u64,
444 interval: Option<u64>,
445 message: Option<String>,
446}
447
448async fn device_login(
449 profile: &Profile,
450 scopes: &str,
451 out: &Output,
452) -> Result<TokenBundle, AppError> {
453 let response = reqwest::Client::new()
454 .post(format!(
455 "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
456 profile.tenant
457 ))
458 .form(&[("client_id", profile.client_id.as_str()), ("scope", scopes)])
459 .send()
460 .await
461 .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
462 if !response.status().is_success() {
463 return Err(oauth_response_error(response).await);
464 }
465 let device: DeviceCode = response
466 .json()
467 .await
468 .map_err(|e| AppError::Auth(format!("invalid device-code response: {e}")))?;
469 out.note(device.message.clone().unwrap_or_else(|| {
470 format!(
471 "Open {} and enter {}",
472 device.verification_uri, device.user_code
473 )
474 }));
475 let deadline = now() + device.expires_in;
476 let interval = device.interval.unwrap_or(5);
477 while now() < deadline {
478 tokio::time::sleep(Duration::from_secs(interval)).await;
479 let response = reqwest::Client::new()
480 .post(token_url(profile))
481 .form(&[
482 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
483 ("client_id", profile.client_id.as_str()),
484 ("device_code", device.device_code.as_str()),
485 ])
486 .send()
487 .await
488 .map_err(|e| AppError::Unexpected(e.to_string()))?;
489 if response.status().is_success() {
490 return parse_token_response(response).await;
491 }
492 let status = response.status();
493 let error: OAuthError = response.json().await.unwrap_or(OAuthError {
494 error: "unknown_error".into(),
495 error_description: None,
496 });
497 if error.error == "authorization_pending" {
498 continue;
499 }
500 if error.error == "slow_down" {
501 tokio::time::sleep(Duration::from_secs(5)).await;
502 continue;
503 }
504 return Err(AppError::Auth(
505 error
506 .error_description
507 .unwrap_or_else(|| format!("{} ({status})", error.error)),
508 ));
509 }
510 Err(AppError::Auth("device-code sign-in expired".into()))
511}
512
513fn token_url(profile: &Profile) -> String {
514 format!(
515 "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
516 profile.tenant
517 )
518}
519
520async fn parse_token_response(response: reqwest::Response) -> Result<TokenBundle, AppError> {
521 if !response.status().is_success() {
522 return Err(oauth_response_error(response).await);
523 }
524 let token: TokenResponse = response
525 .json()
526 .await
527 .map_err(|e| AppError::Auth(format!("invalid token response: {e}")))?;
528 Ok(TokenBundle {
529 access_token: token.access_token,
530 refresh_token: token.refresh_token,
531 expires_at: now() + token.expires_in,
532 scope: token.scope,
533 })
534}
535
536async fn oauth_response_error(response: reqwest::Response) -> AppError {
537 let status = response.status();
538 let error: Option<OAuthError> = response.json().await.ok();
539 AppError::Auth(
540 error
541 .and_then(|e| e.error_description.or(Some(e.error)))
542 .unwrap_or_else(|| format!("Microsoft sign-in failed with {status}")),
543 )
544}
545
546#[cfg(test)]
547mod tests {
548 use super::{BASE_SCOPES, CHANNEL_HISTORY_SCOPE, scopes};
549
550 fn bundle() -> super::TokenBundle {
551 super::TokenBundle {
552 access_token: "new-synthetic-access".into(),
553 refresh_token: Some("new-synthetic-refresh".into()),
554 expires_at: u64::MAX,
555 scope: Some(BASE_SCOPES.into()),
556 }
557 }
558
559 #[test]
560 fn failed_config_save_restores_the_previous_protected_credential() {
561 let credential =
562 keyring::Entry::new_with_credential(Box::new(keyring::mock::MockCredential::default()));
563 credential
564 .set_password("previous-protected-credential")
565 .unwrap();
566 let error = super::commit_login(&credential, &bundle(), || {
567 assert!(
568 credential
569 .get_password()
570 .unwrap()
571 .contains("new-synthetic-access")
572 );
573 Err::<(), _>(crate::error::AppError::Io(std::io::Error::from(
574 std::io::ErrorKind::PermissionDenied,
575 )))
576 })
577 .unwrap_err();
578 assert!(error.to_string().contains("previous sign-in was preserved"));
579 assert_eq!(
580 credential.get_password().unwrap(),
581 "previous-protected-credential"
582 );
583 }
584
585 #[test]
586 fn failed_config_save_removes_a_new_credential_and_success_keeps_it() {
587 let credential =
588 keyring::Entry::new_with_credential(Box::new(keyring::mock::MockCredential::default()));
589 assert!(
590 super::commit_login(&credential, &bundle(), || {
591 Err::<(), _>(crate::error::AppError::Unexpected(
592 "config write failed".into(),
593 ))
594 })
595 .is_err()
596 );
597 assert!(matches!(
598 credential.get_password(),
599 Err(keyring::Error::NoEntry)
600 ));
601 assert_eq!(
602 super::commit_login(&credential, &bundle(), || Ok("saved-config")).unwrap(),
603 "saved-config"
604 );
605 let saved: super::TokenBundle =
606 serde_json::from_str(&credential.get_password().unwrap()).unwrap();
607 assert_eq!(saved.access_token, "new-synthetic-access");
608 assert_eq!(
609 saved.refresh_token.as_deref(),
610 Some("new-synthetic-refresh")
611 );
612 }
613
614 #[test]
615 fn default_login_is_least_privilege() {
616 assert!(!BASE_SCOPES.contains(CHANNEL_HISTORY_SCOPE));
617 assert!(
618 BASE_SCOPES
619 .split_whitespace()
620 .any(|scope| scope == "User.ReadBasic.All")
621 );
622 assert!(
623 !BASE_SCOPES
624 .split_whitespace()
625 .any(|scope| scope == "User.Read.All")
626 );
627 assert_eq!(scopes(false), BASE_SCOPES);
628 }
629
630 #[test]
631 fn channel_history_is_explicitly_opted_in() {
632 assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
633 }
634
635 #[cfg(target_os = "linux")]
636 #[test]
637 fn missing_secret_service_is_treated_as_an_empty_store() {
638 let error = keyring::Error::PlatformFailure(Box::new(std::io::Error::other(
639 "The name org.freedesktop.secrets was not provided by any .service files",
640 )));
641
642 assert!(super::secret_service_is_unavailable(&error));
643 }
644}