sharepoint-cli 0.0.8

Agent-friendly SharePoint Online CLI with JSON output, structured exit codes, and schema introspection
Documentation
//! `sharepoint auth login | logout | status`

use chrono::{Duration, Utc};

use crate::auth::{device_code, require_client_id, token_cache};
use crate::cli::{AuthCmd, Runtime};
use crate::config;
use crate::error::{CliError, Result};

pub async fn run(rt: &Runtime, cmd: AuthCmd) -> Result<()> {
    match cmd {
        AuthCmd::Login => login(rt).await,
        AuthCmd::Logout => logout(rt).await,
        AuthCmd::Status {
            limit,
            page,
            fields,
        } => status(rt, limit, page.as_deref(), &fields).await,
    }
}

async fn login(rt: &Runtime) -> Result<()> {
    // `read_only` does not gate login: it only protects against config-file
    // writes; the token cache is operational state needed for any read.
    let tenant = rt.cfg.tenant_id.clone().ok_or_else(|| {
        CliError::Input(
            "no tenant configured; run `sharepoint init` or pass --tenant <domain-or-guid>".into(),
        )
    })?;
    let client_id = require_client_id(&rt.cfg)?;
    let scope = device_code::default_scope(rt.cfg.read_only);

    let http = reqwest::Client::builder()
        .user_agent(format!("sharepoint-cli/{}", env!("CARGO_PKG_VERSION")))
        .build()
        .expect("reqwest");

    let dc =
        device_code::request_device_code(&http, &rt.cfg.login_endpoint, &tenant, &client_id, scope)
            .await?;

    rt.out.print_required_prompt(&format!(
        "To sign in, open {}\nand enter code: {}",
        dc.verification_uri, dc.user_code
    ));

    let resp = device_code::poll_for_token(
        &http,
        &rt.cfg.login_endpoint,
        &tenant,
        &client_id,
        &dc.device_code,
        dc.interval,
        dc.expires_in,
    )
    .await?;

    let claims = device_code::decode_id_token(&resp.id_token)?;

    // Canonicalize the configured tenant to the authoritative GUID from the id
    // token. The user may have entered a domain (e.g. contoso.onmicrosoft.com);
    // the cache key uses claims.tid, so the configured tenant must match.
    if rt.cfg.tenant_id.as_deref() != Some(claims.tid.as_str()) {
        config::write_profile_tenant_id(&rt.config_path, &rt.cfg.profile_name, &claims.tid)?;
    }

    let key = token_cache::cache_key(&claims.tid, &client_id, &claims.oid);
    let entry = token_cache::CacheEntry {
        account: token_cache::Account {
            username: claims.preferred_username.clone(),
            name: Some(claims.name.clone()),
            tenant_id: claims.tid.clone(),
            oid: claims.oid.clone(),
        },
        access_token: resp.access_token,
        access_token_expires_at: Utc::now() + Duration::seconds(resp.expires_in as i64),
        refresh_token: Some(resp.refresh_token),
        scopes: resp.scope.split(' ').map(String::from).collect(),
    };
    token_cache::upsert(&rt.cache_path, &key, entry)?;

    rt.out
        .print_message(&format!("Signed in as {}", claims.preferred_username));
    if rt.out.json {
        rt.out.print_json(&serde_json::json!({
            "username": claims.preferred_username,
            "name": claims.name,
            "tenant_id": claims.tid,
        }));
    }
    Ok(())
}

async fn logout(rt: &Runtime) -> Result<()> {
    let tenant = rt
        .cfg
        .tenant_id
        .clone()
        .ok_or_else(|| CliError::Input("no tenant configured".into()))?;
    let client_id = require_client_id(&rt.cfg)?;
    let cache = token_cache::load(&rt.cache_path)?;
    let prefix = format!("{tenant}:{client_id}:");
    let keys: Vec<String> = cache
        .entries
        .keys()
        .filter(|k| k.starts_with(&prefix))
        .cloned()
        .collect();
    let mut removed = 0;
    for k in keys {
        if token_cache::remove(&rt.cache_path, &k)? {
            removed += 1;
        }
    }
    rt.out
        .print_message(&format!("Removed {removed} cached account(s)"));
    if rt.out.json {
        rt.out.print_json(&serde_json::json!({"removed": removed}));
    }
    Ok(())
}

async fn status(rt: &Runtime, limit: usize, _page: Option<&str>, fields: &[String]) -> Result<()> {
    let cache = token_cache::load(&rt.cache_path)?;

    // Build the full account list.
    let mut all_accounts: Vec<_> = cache
        .entries
        .iter()
        .map(|(key, entry)| {
            let mut obj = serde_json::json!({
                "key": key,
                "username": entry.account.username,
                "name": entry.account.name,
                "tenant_id": entry.account.tenant_id,
                "oid": entry.account.oid,
                "expires_at": entry.access_token_expires_at.to_rfc3339(),
                "scopes": entry.scopes,
            });
            if !fields.is_empty()
                && let serde_json::Value::Object(ref mut map) = obj
            {
                map.retain(|k, _| fields.iter().any(|f| f == k));
            }
            obj
        })
        .collect();

    let total = all_accounts.len();
    all_accounts.truncate(limit);

    if rt.out.json {
        rt.out.print_json(&serde_json::json!({
            "total": total,
            "next": serde_json::Value::Null,
            "items": all_accounts,
        }));
    } else {
        // Always emit at least the column header so stdout is non-empty in text mode.
        rt.out
            .print_data(&format!("{:30}  {}", "ACCOUNT", "EXPIRES"));
        for obj in &all_accounts {
            let username = obj["username"].as_str().unwrap_or("");
            let expires = obj["expires_at"].as_str().unwrap_or("");
            rt.out.print_data(&format!("{:30}  {}", username, expires));
        }
        if total == 0 {
            rt.out
                .print_message("No cached accounts. Run `sharepoint auth login`.");
        }
    }
    Ok(())
}