Skip to main content

sharepoint_cli/auth/
mod.rs

1//! Authentication subsystem (device-code flow only in v0.1).
2//!
3//! `AuthContext` is the runtime entrypoint: every Graph call goes through
4//! `access_token()`, which automatically refreshes when the cached token is
5//! within 60 seconds of expiring.
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use chrono::{Duration, Utc};
11use tokio::sync::Mutex;
12
13use crate::config::ResolvedConfig;
14use crate::error::{CliError, Result};
15
16pub mod device_code;
17pub mod token_cache;
18
19/// Default Entra app `client_id` shipped in the binary.
20/// Replace this with the GUID captured in Prereq B before publishing.
21pub const DEFAULT_CLIENT_ID: &str = "REPLACE_WITH_REAL_CLIENT_ID";
22
23const REFRESH_MARGIN_SECS: i64 = 60;
24
25#[derive(Clone)]
26pub struct AuthContext {
27    inner: Arc<Mutex<Inner>>,
28}
29
30struct Inner {
31    cfg: ResolvedConfig,
32    cache_path: PathBuf,
33    http: reqwest::Client,
34}
35
36impl AuthContext {
37    pub fn new(cfg: ResolvedConfig, cache_path: PathBuf) -> Self {
38        let http = reqwest::Client::builder()
39            .user_agent(format!("sharepoint-cli/{}", env!("CARGO_PKG_VERSION")))
40            .build()
41            .expect("reqwest client");
42        Self {
43            inner: Arc::new(Mutex::new(Inner {
44                cfg,
45                cache_path,
46                http,
47            })),
48        }
49    }
50
51    pub async fn client_id(&self) -> String {
52        self.inner
53            .lock()
54            .await
55            .cfg
56            .client_id
57            .clone()
58            .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string())
59    }
60
61    /// Get a non-expired access token, refreshing if necessary.
62    /// Honors `SHAREPOINT_ACCESS_TOKEN` as a CI escape hatch (no refresh).
63    pub async fn access_token(&self) -> Result<String> {
64        let guard = self.inner.lock().await;
65
66        if let Some(t) = guard.cfg.access_token_override.clone() {
67            return Ok(t);
68        }
69
70        let tenant = guard.cfg.tenant_id.clone().ok_or_else(|| {
71            CliError::Auth(
72                "no tenant_id configured; run `sharepoint init` or set SHAREPOINT_TENANT_ID".into(),
73            )
74        })?;
75        let client_id = guard
76            .cfg
77            .client_id
78            .clone()
79            .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string());
80
81        let cache = token_cache::load(&guard.cache_path)?;
82
83        // Pick an entry matching this tenant+client_id (the only ones we can use).
84        let prefix = format!("{tenant}:{client_id}:");
85        let (key, entry) = cache
86            .entries
87            .iter()
88            .find(|(k, _)| k.starts_with(&prefix))
89            .map(|(k, e)| (k.clone(), e.clone()))
90            .ok_or_else(|| {
91                CliError::Auth(
92                    "no cached credentials for this tenant; run `sharepoint auth login`".into(),
93                )
94            })?;
95
96        if entry.access_token_expires_at - Utc::now() > Duration::seconds(REFRESH_MARGIN_SECS) {
97            return Ok(entry.access_token);
98        }
99
100        // Refresh.
101        let rt = entry.refresh_token.as_deref().ok_or_else(|| {
102            CliError::Auth("cached entry has no refresh_token; run `sharepoint auth login`".into())
103        })?;
104        let scope = device_code::default_scope(guard.cfg.read_only);
105        let resp = device_code::refresh(
106            &guard.http,
107            &guard.cfg.login_endpoint,
108            &tenant,
109            &client_id,
110            rt,
111            scope,
112        )
113        .await?;
114
115        let access_token = resp.access_token;
116        let new_entry = token_cache::CacheEntry {
117            account: entry.account.clone(),
118            access_token: access_token.clone(),
119            access_token_expires_at: Utc::now() + Duration::seconds(resp.expires_in as i64),
120            refresh_token: Some(resp.refresh_token),
121            scopes: resp.scope.split(' ').map(String::from).collect(),
122        };
123        token_cache::upsert(&guard.cache_path, &key, new_entry)?;
124        Ok(access_token)
125    }
126
127    pub async fn http(&self) -> reqwest::Client {
128        self.inner.lock().await.http.clone()
129    }
130
131    pub async fn config(&self) -> ResolvedConfig {
132        self.inner.lock().await.cfg.clone()
133    }
134
135    pub async fn cache_path(&self) -> PathBuf {
136        self.inner.lock().await.cache_path.clone()
137    }
138
139    /// Seed the cache from `SHAREPOINT_REFRESH_TOKEN` (CI use case). The next
140    /// `access_token()` call will refresh and persist the rotated token.
141    /// `oid_for_seed` is a synthetic identifier — the real one comes back on first refresh.
142    pub async fn seed_from_env_refresh_token(&self, refresh_token: &str) -> Result<()> {
143        let guard = self.inner.lock().await;
144        let tenant = guard
145            .cfg
146            .tenant_id
147            .clone()
148            .ok_or_else(|| CliError::Auth("seed: tenant_id required".into()))?;
149        let client_id = guard
150            .cfg
151            .client_id
152            .clone()
153            .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string());
154        let key = token_cache::cache_key(&tenant, &client_id, "seeded");
155        let entry = token_cache::CacheEntry {
156            account: token_cache::Account {
157                username: "seeded".into(),
158                name: Some("seeded".to_string()),
159                tenant_id: tenant,
160                oid: "seeded".into(),
161            },
162            access_token: String::new(),
163            access_token_expires_at: Utc::now() - Duration::seconds(1),
164            refresh_token: Some(refresh_token.to_string()),
165            scopes: vec![],
166        };
167        token_cache::upsert(&guard.cache_path, &key, entry)?;
168        // Drop guard so caller can immediately call access_token().
169        drop(guard);
170        Ok(())
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[tokio::test]
179    async fn access_token_uses_env_override_when_set() {
180        let dir = tempfile::tempdir().unwrap();
181        let cfg = crate::config::ResolvedConfig {
182            profile_name: "default".into(),
183            tenant_id: Some("contoso".into()),
184            client_id: Some("client-1".into()),
185            default_site: None,
186            read_only: false,
187            site_aliases: Default::default(),
188            graph_endpoint: "https://graph.example".into(),
189            login_endpoint: "https://login.example".into(),
190            debug_http: false,
191            access_token_override: Some("ENV-TOKEN".into()),
192            refresh_token_seed: None,
193        };
194        let ctx = AuthContext::new(cfg, dir.path().join("tokens.json"));
195        assert_eq!(ctx.access_token().await.unwrap(), "ENV-TOKEN");
196    }
197
198    #[tokio::test]
199    async fn access_token_errors_when_no_cache_and_no_env() {
200        let dir = tempfile::tempdir().unwrap();
201        let cfg = crate::config::ResolvedConfig {
202            profile_name: "default".into(),
203            tenant_id: Some("contoso".into()),
204            client_id: Some("client-1".into()),
205            default_site: None,
206            read_only: false,
207            site_aliases: Default::default(),
208            graph_endpoint: "https://graph.example".into(),
209            login_endpoint: "https://login.example".into(),
210            debug_http: false,
211            access_token_override: None,
212            refresh_token_seed: None,
213        };
214        let ctx = AuthContext::new(cfg, dir.path().join("tokens.json"));
215        let err = ctx.access_token().await.unwrap_err();
216        assert!(matches!(err, CliError::Auth(_)));
217    }
218}