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::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}