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