Skip to main content

teams_cli/
auth.rs

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
77/// Inspect the selected store without treating a helper/decryption failure as logout.
78pub 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
119/// Probe only the native store when offering a new-profile storage choice.
120pub 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
128/// Check storage before opening Microsoft sign-in. GPG may prompt for an existing entry.
129pub 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
266/// Complete Microsoft sign-in without replacing a saved credential yet.
267/// Guided setup verifies this exact token before committing the new connection.
268pub 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
283/// Save a verified sign-in with its config; restore the previous credential if config fails.
284pub 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}