sharepoint_cli/auth/
mod.rs1use 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
19pub 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 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 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 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 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);
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}