use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use crate::config::{CredentialStore, Profile};
use crate::error::AppError;
use crate::output::Output;
pub const BASE_SCOPES: &str = "openid profile offline_access User.Read User.ReadBasic.All Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send";
pub const CHANNEL_HISTORY_SCOPE: &str = "ChannelMessage.Read.All";
pub const CHANNEL_HISTORY_SCOPES: &str = "openid profile offline_access User.Read User.ReadBasic.All Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send ChannelMessage.Read.All";
const SERVICE: &str = "teams-cli";
mod pass;
mod process;
#[cfg(any(target_os = "linux", test))]
mod wsl;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenBundle {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: u64,
pub scope: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: u64,
scope: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OAuthError {
error: String,
error_description: Option<String>,
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn entry(profile_name: &str) -> Result<keyring::Entry, AppError> {
let profile = crate::config::connection_profile(Some(profile_name))?;
entry_for(profile_name, profile.as_ref())
}
fn entry_for(profile_name: &str, profile: Option<&Profile>) -> Result<keyring::Entry, AppError> {
if let Some(profile) = profile
&& profile.credential_store == CredentialStore::Pass
{
let directory = profile.pass_store_dir.as_deref().ok_or_else(|| AppError::InvalidInput(
"This pass profile has no saved pass_store_dir; restore its original directory in the profile configuration. No other credential store was used".into(),
))?;
return pass::entry(profile_name, directory);
}
#[cfg(target_os = "linux")]
if wsl::is_wsl() {
return Ok(wsl::entry(profile_name));
}
keyring::Entry::new(SERVICE, profile_name)
.map_err(|e| AppError::Unexpected(format!("credential store is unavailable: {e}")))
}
pub fn credential_present(profile_name: &str) -> Result<bool, AppError> {
if std::env::var("TEAMS_ACCESS_TOKEN").is_ok() {
return Ok(true);
}
match entry(profile_name)?.get_password() {
Ok(_) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(error) => Err(AppError::Unexpected(format!(
"Could not read the saved credential: {error}"
))),
}
}
pub fn has_token(profile_name: &str) -> bool {
credential_present(profile_name).unwrap_or(false)
}
pub fn available_pass_directory() -> Option<std::path::PathBuf> {
pass::available_directory()
}
pub fn prepare_pass_directory(
directory: Option<&std::path::Path>,
) -> Result<std::path::PathBuf, AppError> {
pass::prepare(directory)
}
pub fn native_store_label() -> &'static str {
#[cfg(target_os = "linux")]
if wsl::is_wsl() {
return "Windows DPAPI";
}
if cfg!(target_os = "macos") {
"macOS Keychain"
} else if cfg!(windows) {
"Windows Credential Manager"
} else {
"Linux Secret Service"
}
}
pub fn native_store_error(profile_name: &str) -> Option<String> {
let result = entry_for(profile_name, None).and_then(|entry| match entry.get_password() {
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(AppError::Unexpected(error.to_string())),
});
result.err().map(|error| error.to_string())
}
pub fn check_store(profile_name: &str, profile: &Profile) -> Result<(), AppError> {
match entry_for(profile_name, Some(profile))?.get_password() {
Err(keyring::Error::NoEntry) if profile.credential_store == CredentialStore::Pass => {
pass::probe(
profile
.pass_store_dir
.as_deref()
.expect("validated pass directory"),
)
}
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(AppError::Unexpected(format!(
"Credential storage is unavailable: {error}. Sign-in was not started"
))),
}
}
pub fn granted_scopes(profile_name: &str) -> Option<Vec<String>> {
if std::env::var("TEAMS_ACCESS_TOKEN").is_ok() {
return None;
}
load(profile_name).ok().and_then(|token| {
token.scope.map(|scope| {
scope
.split_whitespace()
.map(str::to_owned)
.collect::<Vec<_>>()
})
})
}
pub fn channel_history_granted(profile_name: &str) -> Option<bool> {
granted_scopes(profile_name).map(|scopes| {
scopes
.iter()
.any(|scope| scope.eq_ignore_ascii_case(CHANNEL_HISTORY_SCOPE))
})
}
fn store(profile_name: &str, profile: &Profile, token: &TokenBundle) -> Result<(), AppError> {
let encoded = serde_json::to_string(token).map_err(|e| AppError::Unexpected(e.to_string()))?;
entry_for(profile_name, Some(profile))?
.set_password(&encoded)
.map_err(|e| {
AppError::Unexpected(format!(
"could not save credentials in the selected credential store: {e}"
))
})
}
fn load(profile_name: &str) -> Result<TokenBundle, AppError> {
let encoded = entry(profile_name)?
.get_password()
.map_err(|error| match error {
keyring::Error::NoEntry => {
AppError::Auth("not signed in; run `teams auth login`".into())
}
error => AppError::Unexpected(format!("Could not read the saved credential: {error}")),
})?;
serde_json::from_str(&encoded).map_err(|_| {
AppError::Auth("stored credential is unreadable; run `teams auth login` again".into())
})
}
pub fn logout(profile_name: &str) -> Result<(), AppError> {
match entry(profile_name)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) if secret_service_is_unavailable(&e) => Ok(()),
Err(e) => Err(AppError::Unexpected(format!(
"could not remove stored credentials: {e}"
))),
}
}
#[cfg(target_os = "linux")]
fn secret_service_is_unavailable(error: &keyring::Error) -> bool {
match error {
keyring::Error::PlatformFailure(source) => {
let message = source.to_string();
message.contains("org.freedesktop.secrets") && message.contains("not provided")
}
_ => false,
}
}
#[cfg(not(target_os = "linux"))]
fn secret_service_is_unavailable(_error: &keyring::Error) -> bool {
false
}
pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<String, AppError> {
profile.require_graph()?;
if let Ok(token) = std::env::var("TEAMS_ACCESS_TOKEN") {
return Ok(token);
}
let mut bundle = load(profile_name)?;
if bundle.expires_at > now() + 120 {
return Ok(bundle.access_token);
}
let refresh = bundle.refresh_token.as_deref().ok_or_else(|| {
AppError::Auth(
"session expired and has no refresh credential; run `teams auth login`".into(),
)
})?;
let response = reqwest::Client::new()
.post(token_url(profile))
.form(&[
("client_id", profile.client_id.as_str()),
("grant_type", "refresh_token"),
("refresh_token", refresh),
("scope", bundle.scope.as_deref().unwrap_or(BASE_SCOPES)),
])
.send()
.await
.map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
let mut replacement = parse_token_response(response).await?;
if replacement.refresh_token.is_none() {
replacement.refresh_token = bundle.refresh_token.clone();
}
bundle = replacement;
store(profile_name, profile, &bundle)?;
Ok(bundle.access_token)
}
pub async fn login(
profile_name: &str,
profile: &Profile,
device_code: bool,
channel_history: bool,
out: &Output,
) -> Result<TokenBundle, AppError> {
check_store(profile_name, profile)?;
let bundle = sign_in(profile, device_code, channel_history, out).await?;
store(profile_name, profile, &bundle)?;
Ok(bundle)
}
pub async fn sign_in(
profile: &Profile,
device_code: bool,
channel_history: bool,
out: &Output,
) -> Result<TokenBundle, AppError> {
profile.require_graph()?;
let scopes = scopes(channel_history);
if device_code {
device_login(profile, scopes, out).await
} else {
browser_login(profile, scopes, out).await
}
}
pub fn save_verified_login<T>(
profile_name: &str,
profile: &Profile,
bundle: &TokenBundle,
save_config: impl FnOnce() -> Result<T, AppError>,
) -> Result<T, AppError> {
commit_login(
&entry_for(profile_name, Some(profile))?,
bundle,
save_config,
)
}
fn commit_login<T>(
credential: &keyring::Entry,
bundle: &TokenBundle,
save_config: impl FnOnce() -> Result<T, AppError>,
) -> Result<T, AppError> {
let previous = match credential.get_password() {
Ok(previous) => Some(previous),
Err(keyring::Error::NoEntry) => None,
Err(error) => {
return Err(AppError::Unexpected(format!(
"Could not preserve the previous sign-in: {error}. No connection settings were saved."
)));
}
};
let encoded = serde_json::to_string(bundle).map_err(|e| AppError::Unexpected(e.to_string()))?;
credential.set_password(&encoded).map_err(|e| {
AppError::Unexpected(format!(
"Could not save the verified sign-in: {e}. No connection settings were saved."
))
})?;
match save_config() {
Ok(result) => Ok(result),
Err(error) => {
let restored = match previous {
Some(previous) => credential.set_password(&previous),
None => match credential.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(error),
},
};
match restored {
Ok(()) => Err(error.with_hint("Connection settings could not be saved; your previous sign-in was preserved.")),
Err(_) => Err(error.with_hint("Connection settings could not be saved, and the credential store could not restore your previous sign-in. Retry setup to reconnect this profile.")),
}
}
}
}
fn scopes(channel_history: bool) -> &'static str {
if channel_history {
CHANNEL_HISTORY_SCOPES
} else {
BASE_SCOPES
}
}
async fn browser_login(
profile: &Profile,
scopes: &str,
out: &Output,
) -> Result<TokenBundle, AppError> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let redirect = format!("http://localhost:{}", listener.local_addr()?.port());
let mut verifier_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut verifier_bytes);
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(verifier.as_bytes()));
let mut state_bytes = [0u8; 18];
rand::rng().fill_bytes(&mut state_bytes);
let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(state_bytes);
let mut authorize = url::Url::parse(&format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/authorize",
profile.tenant
))
.map_err(|e| AppError::Unexpected(e.to_string()))?;
authorize
.query_pairs_mut()
.append_pair("client_id", &profile.client_id)
.append_pair("response_type", "code")
.append_pair("redirect_uri", &redirect)
.append_pair("response_mode", "query")
.append_pair("scope", scopes)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", &state);
out.note("Opening Microsoft sign-in in your browser…");
out.note(format!(
"If it does not open, visit:\n{}",
authorize.as_str()
));
open::that(authorize.as_str())
.map_err(|e| AppError::Unexpected(format!("could not open a browser: {e}")))?;
let (mut socket, _) = tokio::time::timeout(Duration::from_secs(300), listener.accept())
.await
.map_err(|_| AppError::Auth("browser sign-in timed out after 5 minutes".into()))??;
let mut bytes = vec![0; 8192];
let count = socket.read(&mut bytes).await?;
let request = String::from_utf8_lossy(&bytes[..count]);
let target = request
.split_whitespace()
.nth(1)
.ok_or_else(|| AppError::Auth("invalid browser callback".into()))?;
let callback = url::Url::parse(&format!("http://localhost{target}"))
.map_err(|e| AppError::Auth(e.to_string()))?;
let params: HashMap<_, _> = callback.query_pairs().into_owned().collect();
let ok = params.contains_key("code");
let page = if ok {
"Sign-in complete. You can close this tab and return to teams."
} else {
"Sign-in did not complete. Return to the terminal for details."
};
let body = format!(
"<html><body style=\"font-family:system-ui;max-width:42rem;margin:10vh auto;padding:2rem\"><h1>{page}</h1></body></html>"
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await?;
if params.get("state") != Some(&state) {
return Err(AppError::Auth(
"sign-in callback state did not match".into(),
));
}
if let Some(error) = params
.get("error_description")
.or_else(|| params.get("error"))
{
return Err(AppError::Auth(error.clone()));
}
let code = params
.get("code")
.ok_or_else(|| AppError::Auth("Microsoft did not return an authorization code".into()))?;
let response = reqwest::Client::new()
.post(token_url(profile))
.form(&[
("client_id", profile.client_id.as_str()),
("grant_type", "authorization_code"),
("code", code.as_str()),
("redirect_uri", redirect.as_str()),
("code_verifier", verifier.as_str()),
("scope", scopes),
])
.send()
.await
.map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
parse_token_response(response).await
}
#[derive(Debug, Deserialize)]
struct DeviceCode {
device_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
interval: Option<u64>,
message: Option<String>,
}
async fn device_login(
profile: &Profile,
scopes: &str,
out: &Output,
) -> Result<TokenBundle, AppError> {
let response = reqwest::Client::new()
.post(format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
profile.tenant
))
.form(&[("client_id", profile.client_id.as_str()), ("scope", scopes)])
.send()
.await
.map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
if !response.status().is_success() {
return Err(oauth_response_error(response).await);
}
let device: DeviceCode = response
.json()
.await
.map_err(|e| AppError::Auth(format!("invalid device-code response: {e}")))?;
out.note(device.message.clone().unwrap_or_else(|| {
format!(
"Open {} and enter {}",
device.verification_uri, device.user_code
)
}));
let deadline = now() + device.expires_in;
let interval = device.interval.unwrap_or(5);
while now() < deadline {
tokio::time::sleep(Duration::from_secs(interval)).await;
let response = reqwest::Client::new()
.post(token_url(profile))
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", profile.client_id.as_str()),
("device_code", device.device_code.as_str()),
])
.send()
.await
.map_err(|e| AppError::Unexpected(e.to_string()))?;
if response.status().is_success() {
return parse_token_response(response).await;
}
let status = response.status();
let error: OAuthError = response.json().await.unwrap_or(OAuthError {
error: "unknown_error".into(),
error_description: None,
});
if error.error == "authorization_pending" {
continue;
}
if error.error == "slow_down" {
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
return Err(AppError::Auth(
error
.error_description
.unwrap_or_else(|| format!("{} ({status})", error.error)),
));
}
Err(AppError::Auth("device-code sign-in expired".into()))
}
fn token_url(profile: &Profile) -> String {
format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
profile.tenant
)
}
async fn parse_token_response(response: reqwest::Response) -> Result<TokenBundle, AppError> {
if !response.status().is_success() {
return Err(oauth_response_error(response).await);
}
let token: TokenResponse = response
.json()
.await
.map_err(|e| AppError::Auth(format!("invalid token response: {e}")))?;
Ok(TokenBundle {
access_token: token.access_token,
refresh_token: token.refresh_token,
expires_at: now() + token.expires_in,
scope: token.scope,
})
}
async fn oauth_response_error(response: reqwest::Response) -> AppError {
let status = response.status();
let error: Option<OAuthError> = response.json().await.ok();
AppError::Auth(
error
.and_then(|e| e.error_description.or(Some(e.error)))
.unwrap_or_else(|| format!("Microsoft sign-in failed with {status}")),
)
}
#[cfg(test)]
mod tests {
use super::{BASE_SCOPES, CHANNEL_HISTORY_SCOPE, scopes};
fn bundle() -> super::TokenBundle {
super::TokenBundle {
access_token: "new-synthetic-access".into(),
refresh_token: Some("new-synthetic-refresh".into()),
expires_at: u64::MAX,
scope: Some(BASE_SCOPES.into()),
}
}
#[test]
fn failed_config_save_restores_the_previous_protected_credential() {
let credential =
keyring::Entry::new_with_credential(Box::new(keyring::mock::MockCredential::default()));
credential
.set_password("previous-protected-credential")
.unwrap();
let error = super::commit_login(&credential, &bundle(), || {
assert!(
credential
.get_password()
.unwrap()
.contains("new-synthetic-access")
);
Err::<(), _>(crate::error::AppError::Io(std::io::Error::from(
std::io::ErrorKind::PermissionDenied,
)))
})
.unwrap_err();
assert!(error.to_string().contains("previous sign-in was preserved"));
assert_eq!(
credential.get_password().unwrap(),
"previous-protected-credential"
);
}
#[test]
fn failed_config_save_removes_a_new_credential_and_success_keeps_it() {
let credential =
keyring::Entry::new_with_credential(Box::new(keyring::mock::MockCredential::default()));
assert!(
super::commit_login(&credential, &bundle(), || {
Err::<(), _>(crate::error::AppError::Unexpected(
"config write failed".into(),
))
})
.is_err()
);
assert!(matches!(
credential.get_password(),
Err(keyring::Error::NoEntry)
));
assert_eq!(
super::commit_login(&credential, &bundle(), || Ok("saved-config")).unwrap(),
"saved-config"
);
let saved: super::TokenBundle =
serde_json::from_str(&credential.get_password().unwrap()).unwrap();
assert_eq!(saved.access_token, "new-synthetic-access");
assert_eq!(
saved.refresh_token.as_deref(),
Some("new-synthetic-refresh")
);
}
#[test]
fn default_login_is_least_privilege() {
assert!(!BASE_SCOPES.contains(CHANNEL_HISTORY_SCOPE));
assert!(
BASE_SCOPES
.split_whitespace()
.any(|scope| scope == "User.ReadBasic.All")
);
assert!(
!BASE_SCOPES
.split_whitespace()
.any(|scope| scope == "User.Read.All")
);
assert_eq!(scopes(false), BASE_SCOPES);
}
#[test]
fn channel_history_is_explicitly_opted_in() {
assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
}
#[cfg(target_os = "linux")]
#[test]
fn missing_secret_service_is_treated_as_an_empty_store() {
let error = keyring::Error::PlatformFailure(Box::new(std::io::Error::other(
"The name org.freedesktop.secrets was not provided by any .service files",
)));
assert!(super::secret_service_is_unavailable(&error));
}
}