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}