use std::path::PathBuf;
use std::sync::Arc;
use chrono::{Duration, Utc};
use tokio::sync::{Mutex, Notify};
use crate::config::ResolvedConfig;
use crate::error::{CliError, Result};
pub mod device_code;
pub mod token_cache;
const REFRESH_MARGIN_SECS: i64 = 60;
pub(crate) fn require_client_id(cfg: &ResolvedConfig) -> Result<String> {
cfg.client_id.clone().ok_or_else(|| {
CliError::Auth(
"client_id is required: register an Entra public-client app and set it via \
--client-id, SHAREPOINT_CLIENT_ID, or `sharepoint init`"
.into(),
)
})
}
struct State {
cfg: ResolvedConfig,
cache_path: PathBuf,
http: reqwest::Client,
refreshing: bool,
}
#[derive(Clone)]
pub struct AuthContext {
inner: Arc<(Mutex<State>, Notify)>,
}
impl AuthContext {
pub fn new(cfg: ResolvedConfig, cache_path: PathBuf) -> Self {
let http = reqwest::Client::builder()
.user_agent(format!("sharepoint-cli/{}", env!("CARGO_PKG_VERSION")))
.build()
.expect("reqwest client");
let state = State {
cfg,
cache_path,
http,
refreshing: false,
};
Self {
inner: Arc::new((Mutex::new(state), Notify::new())),
}
}
pub async fn access_token(&self) -> Result<String> {
let (mutex, notify) = &*self.inner;
{
let guard = mutex.lock().await;
if let Some(t) = guard.cfg.access_token_override.clone() {
return Ok(t);
}
}
let mut env_seed_applied = false;
loop {
let (tenant_opt, client_id_opt, cache_path, http, login_endpoint, read_only, rt_seed) = {
let guard = mutex.lock().await;
(
guard.cfg.tenant_id.clone(),
guard.cfg.client_id.clone(),
guard.cache_path.clone(),
guard.http.clone(),
guard.cfg.login_endpoint.clone(),
guard.cfg.read_only,
guard.cfg.refresh_token_seed.clone(),
)
};
let tenant = tenant_opt.ok_or_else(|| {
CliError::Auth(
"no tenant_id configured; run `sharepoint init` or set SHAREPOINT_TENANT_ID"
.into(),
)
})?;
let client_id = client_id_opt.ok_or_else(|| {
CliError::Auth(
"client_id is required: register an Entra public-client app and set it via \
--client-id, SHAREPOINT_CLIENT_ID, or `sharepoint init`"
.into(),
)
})?;
let cache = token_cache::load(&cache_path)?;
let prefix = format!("{tenant}:{client_id}:");
let cache_hit = cache
.entries
.iter()
.find(|(k, _)| k.starts_with(&prefix))
.map(|(k, e)| (k.clone(), e.clone()));
let (key, entry) = match cache_hit {
Some(pair) => pair,
None => {
if !env_seed_applied && let Some(rt) = rt_seed {
let seed_key = token_cache::cache_key(&tenant, &client_id, "seeded");
let seed_entry = token_cache::CacheEntry {
account: token_cache::Account {
username: "seeded".into(),
name: Some("seeded".to_string()),
tenant_id: tenant.clone(),
oid: "seeded".into(),
},
access_token: String::new(),
access_token_expires_at: Utc::now() - Duration::seconds(1),
refresh_token: Some(rt),
scopes: vec![],
};
token_cache::upsert(&cache_path, &seed_key, seed_entry)?;
env_seed_applied = true;
continue; }
return Err(CliError::Auth(
"no cached credentials for this tenant; run `sharepoint auth login`".into(),
));
}
};
if entry.access_token_expires_at - Utc::now() > Duration::seconds(REFRESH_MARGIN_SECS) {
return Ok(entry.access_token);
}
{
let mut guard = mutex.lock().await;
if guard.refreshing {
drop(guard);
notify.notified().await;
continue;
}
guard.refreshing = true;
}
let rt_owned = match entry.refresh_token.clone() {
Some(rt) => rt,
None => {
let mut guard = mutex.lock().await;
guard.refreshing = false;
notify.notify_waiters();
drop(guard);
return Err(CliError::Auth(
"cached entry has no refresh_token; run `sharepoint auth login`".into(),
));
}
};
let scope = device_code::default_scope(read_only);
let refresh_result = device_code::refresh(
&http,
&login_endpoint,
&tenant,
&client_id,
&rt_owned,
scope,
)
.await;
{
let mut guard = mutex.lock().await;
guard.refreshing = false;
notify.notify_waiters();
}
let resp = refresh_result?;
let access_token = resp.access_token.clone();
let new_entry = token_cache::CacheEntry {
account: entry.account.clone(),
access_token: resp.access_token,
access_token_expires_at: Utc::now() + Duration::seconds(resp.expires_in as i64),
refresh_token: Some(resp.refresh_token),
scopes: resp.scope.split(' ').map(String::from).collect(),
};
let _ = token_cache::upsert(&cache_path, &key, new_entry);
return Ok(access_token);
}
}
pub(crate) async fn http(&self) -> reqwest::Client {
self.inner.0.lock().await.http.clone()
}
pub(crate) async fn config(&self) -> ResolvedConfig {
self.inner.0.lock().await.cfg.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn access_token_uses_env_override_when_set() {
let dir = tempfile::tempdir().unwrap();
let cfg = crate::config::ResolvedConfig {
profile_name: "default".into(),
tenant_id: Some("contoso".into()),
client_id: Some("client-1".into()),
default_site: None,
read_only: false,
site_aliases: Default::default(),
graph_endpoint: "https://graph.example".into(),
login_endpoint: "https://login.example".into(),
debug_http: false,
access_token_override: Some("ENV-TOKEN".into()),
refresh_token_seed: None,
};
let ctx = AuthContext::new(cfg, dir.path().join("tokens.json"));
assert_eq!(ctx.access_token().await.unwrap(), "ENV-TOKEN");
}
#[tokio::test]
async fn access_token_errors_when_no_cache_and_no_env() {
let dir = tempfile::tempdir().unwrap();
let cfg = crate::config::ResolvedConfig {
profile_name: "default".into(),
tenant_id: Some("contoso".into()),
client_id: Some("client-1".into()),
default_site: None,
read_only: false,
site_aliases: Default::default(),
graph_endpoint: "https://graph.example".into(),
login_endpoint: "https://login.example".into(),
debug_http: false,
access_token_override: None,
refresh_token_seed: None,
};
let ctx = AuthContext::new(cfg, dir.path().join("tokens.json"));
let err = ctx.access_token().await.unwrap_err();
assert!(matches!(err, CliError::Auth(_)));
}
}