Skip to main content

sharepoint_cli/commands/
auth.rs

1//! `sharepoint auth login | logout | status`
2
3use chrono::{Duration, Utc};
4
5use crate::auth::{DEFAULT_CLIENT_ID, device_code, token_cache};
6use crate::cli::{AuthCmd, Runtime};
7use crate::error::{CliError, Result};
8
9pub async fn run(rt: &Runtime, cmd: AuthCmd) -> Result<()> {
10    match cmd {
11        AuthCmd::Login => login(rt).await,
12        AuthCmd::Logout => logout(rt).await,
13        AuthCmd::Status => status(rt).await,
14    }
15}
16
17async fn login(rt: &Runtime) -> Result<()> {
18    // `read_only` does not gate login: it only protects against config-file
19    // writes; the token cache is operational state needed for any read.
20    let tenant = rt.cfg.tenant_id.clone().ok_or_else(|| {
21        CliError::Input(
22            "no tenant configured; run `sharepoint init` or pass --tenant <domain-or-guid>".into(),
23        )
24    })?;
25    let client_id = rt
26        .cfg
27        .client_id
28        .clone()
29        .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string());
30    let scope = device_code::default_scope(rt.cfg.read_only);
31
32    let http = reqwest::Client::builder()
33        .user_agent(format!("sharepoint-cli/{}", env!("CARGO_PKG_VERSION")))
34        .build()
35        .expect("reqwest");
36
37    let dc =
38        device_code::request_device_code(&http, &rt.cfg.login_endpoint, &tenant, &client_id, scope)
39            .await?;
40
41    rt.out.print_message(&format!(
42        "To sign in, open {}\nand enter code: {}",
43        dc.verification_uri, dc.user_code
44    ));
45
46    let resp = device_code::poll_for_token(
47        &http,
48        &rt.cfg.login_endpoint,
49        &tenant,
50        &client_id,
51        &dc.device_code,
52        dc.interval,
53        dc.expires_in,
54    )
55    .await?;
56
57    let claims = device_code::decode_id_token(&resp.id_token)?;
58    let key = token_cache::cache_key(&claims.tid, &client_id, &claims.oid);
59    let entry = token_cache::CacheEntry {
60        account: token_cache::Account {
61            username: claims.preferred_username.clone(),
62            name: Some(claims.name.clone()),
63            tenant_id: claims.tid.clone(),
64            oid: claims.oid.clone(),
65        },
66        access_token: resp.access_token,
67        access_token_expires_at: Utc::now() + Duration::seconds(resp.expires_in as i64),
68        refresh_token: Some(resp.refresh_token),
69        scopes: resp.scope.split(' ').map(String::from).collect(),
70    };
71    token_cache::upsert(&rt.cache_path, &key, entry)?;
72
73    rt.out
74        .print_message(&format!("Signed in as {}", claims.preferred_username));
75    if rt.out.json {
76        rt.out.print_json(&serde_json::json!({
77            "username": claims.preferred_username,
78            "name": claims.name,
79            "tenant_id": claims.tid,
80        }));
81    }
82    Ok(())
83}
84
85async fn logout(rt: &Runtime) -> Result<()> {
86    let tenant = rt
87        .cfg
88        .tenant_id
89        .clone()
90        .ok_or_else(|| CliError::Input("no tenant configured".into()))?;
91    let client_id = rt
92        .cfg
93        .client_id
94        .clone()
95        .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string());
96    let cache = token_cache::load(&rt.cache_path)?;
97    let prefix = format!("{tenant}:{client_id}:");
98    let keys: Vec<String> = cache
99        .entries
100        .keys()
101        .filter(|k| k.starts_with(&prefix))
102        .cloned()
103        .collect();
104    let mut removed = 0;
105    for k in keys {
106        if token_cache::remove(&rt.cache_path, &k)? {
107            removed += 1;
108        }
109    }
110    rt.out
111        .print_message(&format!("Removed {removed} cached account(s)"));
112    if rt.out.json {
113        rt.out.print_json(&serde_json::json!({"removed": removed}));
114    }
115    Ok(())
116}
117
118async fn status(rt: &Runtime) -> Result<()> {
119    let cache = token_cache::load(&rt.cache_path)?;
120    if rt.out.json {
121        let accounts: Vec<_> = cache
122            .entries
123            .iter()
124            .map(|(key, entry)| {
125                serde_json::json!({
126                    "key": key,
127                    "username": entry.account.username,
128                    "name": entry.account.name,
129                    "tenant_id": entry.account.tenant_id,
130                    "oid": entry.account.oid,
131                    "expires_at": entry.access_token_expires_at.to_rfc3339(),
132                    "scopes": entry.scopes,
133                })
134            })
135            .collect();
136        rt.out
137            .print_json(&serde_json::json!({"accounts": accounts}));
138    } else if cache.entries.is_empty() {
139        rt.out
140            .print_message("No cached accounts. Run `sharepoint auth login`.");
141    } else {
142        for entry in cache.entries.values() {
143            rt.out.print_data(&format!(
144                "{:30}  expires {}",
145                entry.account.username,
146                entry.access_token_expires_at.to_rfc3339()
147            ));
148        }
149    }
150    Ok(())
151}