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//!
7//! # Lock discipline
8//!
9//! The `Mutex` protects only in-memory configuration (`cfg`, `cache_path`,
10//! `http`) and the `refreshing` guard flag. It is never held across file I/O
11//! or network calls to avoid serializing all Graph requests behind a
12//! potentially-stalled refresh.
13//!
14//! # Single-flight refresh
15//!
16//! When the cached token is stale, exactly one caller becomes the designated
17//! refresher (`refreshing = true`). All other concurrent callers detect the
18//! flag, release the lock, and wait on the shared `Notify`. When the refresh
19//! completes (success or failure) the refresher wakes all waiters, which then
20//! re-enter the loop and read the now-updated cache from disk.
21
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use chrono::{Duration, Utc};
26use tokio::sync::{Mutex, Notify};
27
28use crate::config::ResolvedConfig;
29use crate::error::{CliError, Result};
30
31pub mod device_code;
32pub mod token_cache;
33
34const REFRESH_MARGIN_SECS: i64 = 60;
35
36/// Pull `client_id` out of resolved config or return a helpful Auth error.
37/// Used by every codepath that needs to talk to the Entra token endpoints.
38pub(crate) fn require_client_id(cfg: &ResolvedConfig) -> Result<String> {
39    cfg.client_id.clone().ok_or_else(|| {
40        CliError::Auth(
41            "client_id is required: register an Entra public-client app and set it via \
42             --client-id, SHAREPOINT_CLIENT_ID, or `sharepoint init`"
43                .into(),
44        )
45    })
46}
47
48/// In-memory mutable state — the only thing the mutex protects.
49struct State {
50    cfg: ResolvedConfig,
51    cache_path: PathBuf,
52    http: reqwest::Client,
53    /// True while a refresh network call is in flight; other callers should
54    /// wait on the accompanying `Notify` rather than issuing a duplicate call.
55    refreshing: bool,
56}
57
58#[derive(Clone)]
59pub struct AuthContext {
60    /// Tuple of (mutex-guarded state, notify for single-flight coordination).
61    inner: Arc<(Mutex<State>, Notify)>,
62}
63
64impl AuthContext {
65    pub fn new(cfg: ResolvedConfig, cache_path: PathBuf) -> Self {
66        let http = reqwest::Client::builder()
67            .user_agent(format!("sharepoint-cli/{}", env!("CARGO_PKG_VERSION")))
68            .build()
69            .expect("reqwest client");
70        let state = State {
71            cfg,
72            cache_path,
73            http,
74            refreshing: false,
75        };
76        Self {
77            inner: Arc::new((Mutex::new(state), Notify::new())),
78        }
79    }
80
81    /// Get a non-expired access token, refreshing if necessary.
82    ///
83    /// Honors `SHAREPOINT_ACCESS_TOKEN` as a CI escape hatch (no refresh).
84    ///
85    /// When `SHAREPOINT_REFRESH_TOKEN` is set in the environment (propagated
86    /// via `ResolvedConfig::refresh_token_seed`) and no valid cached token
87    /// exists, the refresh token is used to bootstrap an access token via the
88    /// OAuth refresh exchange.  This supports headless/CI usage where a
89    /// long-lived refresh token is injected at runtime rather than stored in a
90    /// persistent cache.  If the bootstrap refresh exchange fails (e.g. the
91    /// token has been revoked), the error is surfaced immediately with auth
92    /// exit code 3 — there is no silent fallback to interactive device-code
93    /// flow, because the caller explicitly provided a token.
94    pub async fn access_token(&self) -> Result<String> {
95        let (mutex, notify) = &*self.inner;
96
97        // Fast path: static env override; no disk or network needed.
98        {
99            let guard = mutex.lock().await;
100            if let Some(t) = guard.cfg.access_token_override.clone() {
101                return Ok(t);
102            }
103        }
104
105        // Whether we have already seeded the cache from `SHAREPOINT_REFRESH_TOKEN`
106        // this call.  Guards against an infinite loop in case the seeded entry
107        // is somehow still stale after one refresh attempt.
108        let mut env_seed_applied = false;
109
110        loop {
111            // --- Phase 1: snapshot config (lock held briefly) ---
112            let (tenant_opt, client_id_opt, cache_path, http, login_endpoint, read_only, rt_seed) = {
113                let guard = mutex.lock().await;
114                (
115                    guard.cfg.tenant_id.clone(),
116                    guard.cfg.client_id.clone(),
117                    guard.cache_path.clone(),
118                    guard.http.clone(),
119                    guard.cfg.login_endpoint.clone(),
120                    guard.cfg.read_only,
121                    guard.cfg.refresh_token_seed.clone(),
122                )
123                // lock released here
124            };
125
126            let tenant = tenant_opt.ok_or_else(|| {
127                CliError::Auth(
128                    "no tenant_id configured; run `sharepoint init` or set SHAREPOINT_TENANT_ID"
129                        .into(),
130                )
131            })?;
132            let client_id = client_id_opt.ok_or_else(|| {
133                CliError::Auth(
134                    "client_id is required: register an Entra public-client app and set it via \
135                     --client-id, SHAREPOINT_CLIENT_ID, or `sharepoint init`"
136                        .into(),
137                )
138            })?;
139
140            // --- Phase 2: read token cache from disk (no lock held) ---
141            let cache = token_cache::load(&cache_path)?;
142            let prefix = format!("{tenant}:{client_id}:");
143            let cache_hit = cache
144                .entries
145                .iter()
146                .find(|(k, _)| k.starts_with(&prefix))
147                .map(|(k, e)| (k.clone(), e.clone()));
148
149            // When no cache entry exists, try bootstrapping from the env-var
150            // refresh token (CI/headless use case).  Only attempt once per
151            // `access_token()` call to avoid an infinite retry loop.
152            let (key, entry) = match cache_hit {
153                Some(pair) => pair,
154                None => {
155                    if !env_seed_applied && let Some(rt) = rt_seed {
156                        // Seed the cache with an expired placeholder so the
157                        // normal refresh path below can exchange it for real tokens.
158                        let seed_key = token_cache::cache_key(&tenant, &client_id, "seeded");
159                        let seed_entry = token_cache::CacheEntry {
160                            account: token_cache::Account {
161                                username: "seeded".into(),
162                                name: Some("seeded".to_string()),
163                                tenant_id: tenant.clone(),
164                                oid: "seeded".into(),
165                            },
166                            access_token: String::new(),
167                            access_token_expires_at: Utc::now() - Duration::seconds(1),
168                            refresh_token: Some(rt),
169                            scopes: vec![],
170                        };
171                        token_cache::upsert(&cache_path, &seed_key, seed_entry)?;
172                        env_seed_applied = true;
173                        continue; // Re-enter the loop to pick up the seeded entry.
174                    }
175                    return Err(CliError::Auth(
176                        "no cached credentials for this tenant; run `sharepoint auth login`".into(),
177                    ));
178                }
179            };
180
181            // Token still fresh — return immediately.
182            if entry.access_token_expires_at - Utc::now() > Duration::seconds(REFRESH_MARGIN_SECS) {
183                return Ok(entry.access_token);
184            }
185
186            // --- Phase 3: claim the refresh slot or wait for another caller ---
187            {
188                let mut guard = mutex.lock().await;
189                if guard.refreshing {
190                    // Another caller is already refreshing. Release lock and wait.
191                    drop(guard);
192                    notify.notified().await;
193                    // Re-enter the outer loop to re-read the (now-updated) cache.
194                    continue;
195                }
196                // We are the designated refresher.
197                guard.refreshing = true;
198                // lock released here
199            }
200
201            // --- Phase 4: refresh network call (no lock held) ---
202            let rt_owned = match entry.refresh_token.clone() {
203                Some(rt) => rt,
204                None => {
205                    // Release the single-flight guard before returning.
206                    let mut guard = mutex.lock().await;
207                    guard.refreshing = false;
208                    notify.notify_waiters();
209                    drop(guard);
210                    return Err(CliError::Auth(
211                        "cached entry has no refresh_token; run `sharepoint auth login`".into(),
212                    ));
213                }
214            };
215            let scope = device_code::default_scope(read_only);
216            let refresh_result = device_code::refresh(
217                &http,
218                &login_endpoint,
219                &tenant,
220                &client_id,
221                &rt_owned,
222                scope,
223            )
224            .await;
225
226            // --- Phase 5: commit result (lock held briefly) ---
227            {
228                let mut guard = mutex.lock().await;
229                guard.refreshing = false;
230                notify.notify_waiters();
231                // lock released here
232            }
233
234            let resp = refresh_result?;
235            let access_token = resp.access_token.clone();
236
237            // --- Phase 6: persist rotated token to disk (no lock held) ---
238            let new_entry = token_cache::CacheEntry {
239                account: entry.account.clone(),
240                access_token: resp.access_token,
241                access_token_expires_at: Utc::now() + Duration::seconds(resp.expires_in as i64),
242                refresh_token: Some(resp.refresh_token),
243                scopes: resp.scope.split(' ').map(String::from).collect(),
244            };
245            // Best-effort: if the write fails, callers still get the fresh token
246            // this time; the next call will attempt a disk refresh again.
247            let _ = token_cache::upsert(&cache_path, &key, new_entry);
248
249            return Ok(access_token);
250        }
251    }
252
253    pub(crate) async fn http(&self) -> reqwest::Client {
254        self.inner.0.lock().await.http.clone()
255    }
256
257    pub(crate) async fn config(&self) -> ResolvedConfig {
258        self.inner.0.lock().await.cfg.clone()
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[tokio::test]
267    async fn access_token_uses_env_override_when_set() {
268        let dir = tempfile::tempdir().unwrap();
269        let cfg = crate::config::ResolvedConfig {
270            profile_name: "default".into(),
271            tenant_id: Some("contoso".into()),
272            client_id: Some("client-1".into()),
273            default_site: None,
274            read_only: false,
275            site_aliases: Default::default(),
276            graph_endpoint: "https://graph.example".into(),
277            login_endpoint: "https://login.example".into(),
278            debug_http: false,
279            access_token_override: Some("ENV-TOKEN".into()),
280            refresh_token_seed: None,
281        };
282        let ctx = AuthContext::new(cfg, dir.path().join("tokens.json"));
283        assert_eq!(ctx.access_token().await.unwrap(), "ENV-TOKEN");
284    }
285
286    #[tokio::test]
287    async fn access_token_errors_when_no_cache_and_no_env() {
288        let dir = tempfile::tempdir().unwrap();
289        let cfg = crate::config::ResolvedConfig {
290            profile_name: "default".into(),
291            tenant_id: Some("contoso".into()),
292            client_id: Some("client-1".into()),
293            default_site: None,
294            read_only: false,
295            site_aliases: Default::default(),
296            graph_endpoint: "https://graph.example".into(),
297            login_endpoint: "https://login.example".into(),
298            debug_http: false,
299            access_token_override: None,
300            refresh_token_seed: None,
301        };
302        let ctx = AuthContext::new(cfg, dir.path().join("tokens.json"));
303        let err = ctx.access_token().await.unwrap_err();
304        assert!(matches!(err, CliError::Auth(_)));
305    }
306}