Skip to main content

sharepoint_cli/commands/
auth.rs

1//! `sharepoint auth login | logout | status`
2
3use chrono::{Duration, Utc};
4
5use crate::auth::{device_code, require_client_id, token_cache};
6use crate::cli::{AuthCmd, Runtime};
7use crate::config;
8use crate::error::{CliError, Result};
9
10pub async fn run(rt: &Runtime, cmd: AuthCmd) -> Result<()> {
11    match cmd {
12        AuthCmd::Login => login(rt).await,
13        AuthCmd::Logout => logout(rt).await,
14        AuthCmd::Status {
15            limit,
16            page,
17            fields,
18        } => status(rt, limit, page.as_deref(), &fields).await,
19    }
20}
21
22async fn login(rt: &Runtime) -> Result<()> {
23    // `read_only` does not gate login: it only protects against config-file
24    // writes; the token cache is operational state needed for any read.
25    let tenant = rt.cfg.tenant_id.clone().ok_or_else(|| {
26        CliError::Input(
27            "no tenant configured; run `sharepoint init` or pass --tenant <domain-or-guid>".into(),
28        )
29    })?;
30    let client_id = require_client_id(&rt.cfg)?;
31    let scope = device_code::default_scope(rt.cfg.read_only);
32
33    let http = reqwest::Client::builder()
34        .user_agent(format!("sharepoint-cli/{}", env!("CARGO_PKG_VERSION")))
35        .build()
36        .expect("reqwest");
37
38    let dc =
39        device_code::request_device_code(&http, &rt.cfg.login_endpoint, &tenant, &client_id, scope)
40            .await?;
41
42    rt.out.print_required_prompt(&format!(
43        "To sign in, open {}\nand enter code: {}",
44        dc.verification_uri, dc.user_code
45    ));
46
47    let resp = device_code::poll_for_token(
48        &http,
49        &rt.cfg.login_endpoint,
50        &tenant,
51        &client_id,
52        &dc.device_code,
53        dc.interval,
54        dc.expires_in,
55    )
56    .await?;
57
58    let claims = device_code::decode_id_token(&resp.id_token)?;
59
60    // Canonicalize the configured tenant to the authoritative GUID from the id
61    // token. The user may have entered a domain (e.g. contoso.onmicrosoft.com);
62    // the cache key uses claims.tid, so the configured tenant must match.
63    if rt.cfg.tenant_id.as_deref() != Some(claims.tid.as_str()) {
64        config::write_profile_tenant_id(&rt.config_path, &rt.cfg.profile_name, &claims.tid)?;
65    }
66
67    let key = token_cache::cache_key(&claims.tid, &client_id, &claims.oid);
68    let entry = token_cache::CacheEntry {
69        account: token_cache::Account {
70            username: claims.preferred_username.clone(),
71            name: Some(claims.name.clone()),
72            tenant_id: claims.tid.clone(),
73            oid: claims.oid.clone(),
74        },
75        access_token: resp.access_token,
76        access_token_expires_at: Utc::now() + Duration::seconds(resp.expires_in as i64),
77        refresh_token: Some(resp.refresh_token),
78        scopes: resp.scope.split(' ').map(String::from).collect(),
79    };
80    token_cache::upsert(&rt.cache_path, &key, entry)?;
81
82    rt.out
83        .print_message(&format!("Signed in as {}", claims.preferred_username));
84    if rt.out.json {
85        rt.out.print_json(&serde_json::json!({
86            "username": claims.preferred_username,
87            "name": claims.name,
88            "tenant_id": claims.tid,
89        }));
90    }
91    Ok(())
92}
93
94async fn logout(rt: &Runtime) -> Result<()> {
95    let tenant = rt
96        .cfg
97        .tenant_id
98        .clone()
99        .ok_or_else(|| CliError::Input("no tenant configured".into()))?;
100    let client_id = require_client_id(&rt.cfg)?;
101    let cache = token_cache::load(&rt.cache_path)?;
102    let prefix = format!("{tenant}:{client_id}:");
103    let keys: Vec<String> = cache
104        .entries
105        .keys()
106        .filter(|k| k.starts_with(&prefix))
107        .cloned()
108        .collect();
109    let mut removed = 0;
110    for k in keys {
111        if token_cache::remove(&rt.cache_path, &k)? {
112            removed += 1;
113        }
114    }
115    rt.out
116        .print_message(&format!("Removed {removed} cached account(s)"));
117    if rt.out.json {
118        rt.out.print_json(&serde_json::json!({"removed": removed}));
119    }
120    Ok(())
121}
122
123async fn status(rt: &Runtime, limit: usize, _page: Option<&str>, fields: &[String]) -> Result<()> {
124    let cache = token_cache::load(&rt.cache_path)?;
125
126    // Build the full account list.
127    let mut all_accounts: Vec<_> = cache
128        .entries
129        .iter()
130        .map(|(key, entry)| {
131            let mut obj = serde_json::json!({
132                "key": key,
133                "username": entry.account.username,
134                "name": entry.account.name,
135                "tenant_id": entry.account.tenant_id,
136                "oid": entry.account.oid,
137                "expires_at": entry.access_token_expires_at.to_rfc3339(),
138                "scopes": entry.scopes,
139            });
140            if !fields.is_empty()
141                && let serde_json::Value::Object(ref mut map) = obj
142            {
143                map.retain(|k, _| fields.iter().any(|f| f == k));
144            }
145            obj
146        })
147        .collect();
148
149    let total = all_accounts.len();
150    all_accounts.truncate(limit);
151
152    if rt.out.json {
153        rt.out.print_json(&serde_json::json!({
154            "total": total,
155            "next": serde_json::Value::Null,
156            "items": all_accounts,
157        }));
158    } else {
159        // Always emit at least the column header so stdout is non-empty in text mode.
160        rt.out
161            .print_data(&format!("{:30}  {}", "ACCOUNT", "EXPIRES"));
162        for obj in &all_accounts {
163            let username = obj["username"].as_str().unwrap_or("");
164            let expires = obj["expires_at"].as_str().unwrap_or("");
165            rt.out.print_data(&format!("{:30}  {}", username, expires));
166        }
167        if total == 0 {
168            rt.out
169                .print_message("No cached accounts. Run `sharepoint auth login`.");
170        }
171    }
172    Ok(())
173}