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}