teams-cli 0.1.4

A Microsoft Teams CLI and TUI designed equally for humans and agents
Documentation
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::Profile;
use crate::error::AppError;
use crate::output::Output;

pub const BASE_SCOPES: &str = "openid profile offline_access User.Read 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 Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send ChannelMessage.Read.All";
const SERVICE: &str = "teams-cli";

#[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> {
    keyring::Entry::new(SERVICE, profile_name)
        .map_err(|e| AppError::Unexpected(format!("credential store is unavailable: {e}")))
}

pub fn has_token(profile_name: &str) -> bool {
    std::env::var("TEAMS_ACCESS_TOKEN").is_ok()
        || entry(profile_name)
            .and_then(|e| {
                e.get_password()
                    .map_err(|err| AppError::Unexpected(err.to_string()))
            })
            .is_ok()
}

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, token: &TokenBundle) -> Result<(), AppError> {
    let encoded = serde_json::to_string(token).map_err(|e| AppError::Unexpected(e.to_string()))?;
    entry(profile_name)?.set_password(&encoded).map_err(|e| {
        AppError::Unexpected(format!(
            "could not save credentials in the OS credential store: {e}"
        ))
    })
}

fn load(profile_name: &str) -> Result<TokenBundle, AppError> {
    let encoded = entry(profile_name)?
        .get_password()
        .map_err(|_| AppError::Auth("not signed in; run `teams auth login`".into()))?;
    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) => Err(AppError::Unexpected(format!(
            "could not remove stored credentials: {e}"
        ))),
    }
}

pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<String, AppError> {
    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, &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> {
    let scopes = scopes(channel_history);
    let bundle = if device_code {
        device_login(profile, scopes, out).await?
    } else {
        browser_login(profile, scopes, out).await?
    };
    store(profile_name, &bundle)?;
    Ok(bundle)
}

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};

    #[test]
    fn default_login_is_least_privilege() {
        assert!(!BASE_SCOPES.contains(CHANNEL_HISTORY_SCOPE));
        assert_eq!(scopes(false), BASE_SCOPES);
    }

    #[test]
    fn channel_history_is_explicitly_opted_in() {
        assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
    }
}