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