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 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";
16const SERVICE: &str = "teams-cli";
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct TokenBundle {
20    pub access_token: String,
21    pub refresh_token: Option<String>,
22    pub expires_at: u64,
23    pub scope: Option<String>,
24}
25
26#[derive(Debug, Deserialize)]
27struct TokenResponse {
28    access_token: String,
29    refresh_token: Option<String>,
30    expires_in: u64,
31    scope: Option<String>,
32}
33
34#[derive(Debug, Deserialize)]
35struct OAuthError {
36    error: String,
37    error_description: Option<String>,
38}
39
40fn now() -> u64 {
41    SystemTime::now()
42        .duration_since(UNIX_EPOCH)
43        .unwrap_or_default()
44        .as_secs()
45}
46
47fn entry(profile_name: &str) -> Result<keyring::Entry, AppError> {
48    keyring::Entry::new(SERVICE, profile_name)
49        .map_err(|e| AppError::Unexpected(format!("credential store is unavailable: {e}")))
50}
51
52pub fn has_token(profile_name: &str) -> bool {
53    std::env::var("TEAMS_ACCESS_TOKEN").is_ok()
54        || entry(profile_name)
55            .and_then(|e| {
56                e.get_password()
57                    .map_err(|err| AppError::Unexpected(err.to_string()))
58            })
59            .is_ok()
60}
61
62fn store(profile_name: &str, token: &TokenBundle) -> Result<(), AppError> {
63    let encoded = serde_json::to_string(token).map_err(|e| AppError::Unexpected(e.to_string()))?;
64    entry(profile_name)?.set_password(&encoded).map_err(|e| {
65        AppError::Unexpected(format!(
66            "could not save credentials in the OS credential store: {e}"
67        ))
68    })
69}
70
71fn load(profile_name: &str) -> Result<TokenBundle, AppError> {
72    let encoded = entry(profile_name)?
73        .get_password()
74        .map_err(|_| AppError::Auth("not signed in; run `teams auth login`".into()))?;
75    serde_json::from_str(&encoded).map_err(|_| {
76        AppError::Auth("stored credential is unreadable; run `teams auth login` again".into())
77    })
78}
79
80pub fn logout(profile_name: &str) -> Result<(), AppError> {
81    match entry(profile_name)?.delete_credential() {
82        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
83        Err(e) => Err(AppError::Unexpected(format!(
84            "could not remove stored credentials: {e}"
85        ))),
86    }
87}
88
89pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<String, AppError> {
90    if let Ok(token) = std::env::var("TEAMS_ACCESS_TOKEN") {
91        return Ok(token);
92    }
93    let mut bundle = load(profile_name)?;
94    if bundle.expires_at > now() + 120 {
95        return Ok(bundle.access_token);
96    }
97    let refresh = bundle.refresh_token.as_deref().ok_or_else(|| {
98        AppError::Auth(
99            "session expired and has no refresh credential; run `teams auth login`".into(),
100        )
101    })?;
102    let response = reqwest::Client::new()
103        .post(token_url(profile))
104        .form(&[
105            ("client_id", profile.client_id.as_str()),
106            ("grant_type", "refresh_token"),
107            ("refresh_token", refresh),
108            ("scope", SCOPES),
109        ])
110        .send()
111        .await
112        .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
113    let mut replacement = parse_token_response(response).await?;
114    if replacement.refresh_token.is_none() {
115        replacement.refresh_token = bundle.refresh_token.clone();
116    }
117    bundle = replacement;
118    store(profile_name, &bundle)?;
119    Ok(bundle.access_token)
120}
121
122pub async fn login(
123    profile_name: &str,
124    profile: &Profile,
125    device_code: bool,
126    out: &Output,
127) -> Result<TokenBundle, AppError> {
128    let bundle = if device_code {
129        device_login(profile, out).await?
130    } else {
131        browser_login(profile, out).await?
132    };
133    store(profile_name, &bundle)?;
134    Ok(bundle)
135}
136
137async fn browser_login(profile: &Profile, out: &Output) -> Result<TokenBundle, AppError> {
138    let listener = TcpListener::bind("127.0.0.1:0").await?;
139    let redirect = format!("http://localhost:{}", listener.local_addr()?.port());
140    let mut verifier_bytes = [0u8; 32];
141    rand::rng().fill_bytes(&mut verifier_bytes);
142    let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
143    let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
144        .encode(Sha256::digest(verifier.as_bytes()));
145    let mut state_bytes = [0u8; 18];
146    rand::rng().fill_bytes(&mut state_bytes);
147    let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(state_bytes);
148    let mut authorize = url::Url::parse(&format!(
149        "https://login.microsoftonline.com/{}/oauth2/v2.0/authorize",
150        profile.tenant
151    ))
152    .map_err(|e| AppError::Unexpected(e.to_string()))?;
153    authorize
154        .query_pairs_mut()
155        .append_pair("client_id", &profile.client_id)
156        .append_pair("response_type", "code")
157        .append_pair("redirect_uri", &redirect)
158        .append_pair("response_mode", "query")
159        .append_pair("scope", SCOPES)
160        .append_pair("code_challenge", &challenge)
161        .append_pair("code_challenge_method", "S256")
162        .append_pair("state", &state);
163    out.note("Opening Microsoft sign-in in your browser…");
164    out.note(format!(
165        "If it does not open, visit:\n{}",
166        authorize.as_str()
167    ));
168    open::that(authorize.as_str())
169        .map_err(|e| AppError::Unexpected(format!("could not open a browser: {e}")))?;
170
171    let (mut socket, _) = tokio::time::timeout(Duration::from_secs(300), listener.accept())
172        .await
173        .map_err(|_| AppError::Auth("browser sign-in timed out after 5 minutes".into()))??;
174    let mut bytes = vec![0; 8192];
175    let count = socket.read(&mut bytes).await?;
176    let request = String::from_utf8_lossy(&bytes[..count]);
177    let target = request
178        .split_whitespace()
179        .nth(1)
180        .ok_or_else(|| AppError::Auth("invalid browser callback".into()))?;
181    let callback = url::Url::parse(&format!("http://localhost{target}"))
182        .map_err(|e| AppError::Auth(e.to_string()))?;
183    let params: HashMap<_, _> = callback.query_pairs().into_owned().collect();
184    let ok = params.contains_key("code");
185    let page = if ok {
186        "Sign-in complete. You can close this tab and return to teams."
187    } else {
188        "Sign-in did not complete. Return to the terminal for details."
189    };
190    let body = format!(
191        "<html><body style=\"font-family:system-ui;max-width:42rem;margin:10vh auto;padding:2rem\"><h1>{page}</h1></body></html>"
192    );
193    let response = format!(
194        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
195        body.len()
196    );
197    socket.write_all(response.as_bytes()).await?;
198    if params.get("state") != Some(&state) {
199        return Err(AppError::Auth(
200            "sign-in callback state did not match".into(),
201        ));
202    }
203    if let Some(error) = params
204        .get("error_description")
205        .or_else(|| params.get("error"))
206    {
207        return Err(AppError::Auth(error.clone()));
208    }
209    let code = params
210        .get("code")
211        .ok_or_else(|| AppError::Auth("Microsoft did not return an authorization code".into()))?;
212    let response = reqwest::Client::new()
213        .post(token_url(profile))
214        .form(&[
215            ("client_id", profile.client_id.as_str()),
216            ("grant_type", "authorization_code"),
217            ("code", code.as_str()),
218            ("redirect_uri", redirect.as_str()),
219            ("code_verifier", verifier.as_str()),
220            ("scope", SCOPES),
221        ])
222        .send()
223        .await
224        .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
225    parse_token_response(response).await
226}
227
228#[derive(Debug, Deserialize)]
229struct DeviceCode {
230    device_code: String,
231    user_code: String,
232    verification_uri: String,
233    expires_in: u64,
234    interval: Option<u64>,
235    message: Option<String>,
236}
237
238async fn device_login(profile: &Profile, out: &Output) -> Result<TokenBundle, AppError> {
239    let response = reqwest::Client::new()
240        .post(format!(
241            "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
242            profile.tenant
243        ))
244        .form(&[("client_id", profile.client_id.as_str()), ("scope", SCOPES)])
245        .send()
246        .await
247        .map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
248    if !response.status().is_success() {
249        return Err(oauth_response_error(response).await);
250    }
251    let device: DeviceCode = response
252        .json()
253        .await
254        .map_err(|e| AppError::Auth(format!("invalid device-code response: {e}")))?;
255    out.note(device.message.clone().unwrap_or_else(|| {
256        format!(
257            "Open {} and enter {}",
258            device.verification_uri, device.user_code
259        )
260    }));
261    let deadline = now() + device.expires_in;
262    let interval = device.interval.unwrap_or(5);
263    while now() < deadline {
264        tokio::time::sleep(Duration::from_secs(interval)).await;
265        let response = reqwest::Client::new()
266            .post(token_url(profile))
267            .form(&[
268                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
269                ("client_id", profile.client_id.as_str()),
270                ("device_code", device.device_code.as_str()),
271            ])
272            .send()
273            .await
274            .map_err(|e| AppError::Unexpected(e.to_string()))?;
275        if response.status().is_success() {
276            return parse_token_response(response).await;
277        }
278        let status = response.status();
279        let error: OAuthError = response.json().await.unwrap_or(OAuthError {
280            error: "unknown_error".into(),
281            error_description: None,
282        });
283        if error.error == "authorization_pending" {
284            continue;
285        }
286        if error.error == "slow_down" {
287            tokio::time::sleep(Duration::from_secs(5)).await;
288            continue;
289        }
290        return Err(AppError::Auth(
291            error
292                .error_description
293                .unwrap_or_else(|| format!("{} ({status})", error.error)),
294        ));
295    }
296    Err(AppError::Auth("device-code sign-in expired".into()))
297}
298
299fn token_url(profile: &Profile) -> String {
300    format!(
301        "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
302        profile.tenant
303    )
304}
305
306async fn parse_token_response(response: reqwest::Response) -> Result<TokenBundle, AppError> {
307    if !response.status().is_success() {
308        return Err(oauth_response_error(response).await);
309    }
310    let token: TokenResponse = response
311        .json()
312        .await
313        .map_err(|e| AppError::Auth(format!("invalid token response: {e}")))?;
314    Ok(TokenBundle {
315        access_token: token.access_token,
316        refresh_token: token.refresh_token,
317        expires_at: now() + token.expires_in,
318        scope: token.scope,
319    })
320}
321
322async fn oauth_response_error(response: reqwest::Response) -> AppError {
323    let status = response.status();
324    let error: Option<OAuthError> = response.json().await.ok();
325    AppError::Auth(
326        error
327            .and_then(|e| e.error_description.or(Some(e.error)))
328            .unwrap_or_else(|| format!("Microsoft sign-in failed with {status}")),
329    )
330}