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}