1use std::fmt;
4use std::future::Future;
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::sync::{Arc, OnceLock};
8use std::time::{Duration, SystemTime};
9
10use chrono::{DateTime, Utc};
11use serde::Deserialize;
12use tokio::sync::Mutex;
13
14use crate::Error;
15
16pub const TOKEN_URL: &str =
19 "https://auth.origincloud.eu/realms/origincloud/protocol/openid-connect/token";
20
21const ENV_ACCESS_TOKEN: &str = "ORIGINCLOUD_ACCESS_TOKEN";
22const ENV_CLIENT_ID: &str = "ORIGINCLOUD_CLIENT_ID";
23const ENV_CLIENT_SECRET: &str = "ORIGINCLOUD_CLIENT_SECRET";
24
25const CLI_CLIENT_ID: &str = "origin-cli";
27
28const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
31
32const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
34
35const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
37
38pub type TokenFuture<'a> = Pin<Box<dyn Future<Output = Result<String, Error>> + Send + 'a>>;
40
41pub trait Credentials: Send + Sync + fmt::Debug {
43 fn token(&self) -> TokenFuture<'_>;
45}
46
47pub fn default_credentials() -> Result<Arc<dyn Credentials>, Error> {
58 from_environment(&|name| std::env::var(name).ok())
59}
60
61pub(crate) fn from_environment(
63 env: &dyn Fn(&str) -> Option<String>,
64) -> Result<Arc<dyn Credentials>, Error> {
65 let var = |name: &str| env(name).filter(|v| !v.is_empty());
66 if let Some(token) = var(ENV_ACCESS_TOKEN) {
67 return Ok(Arc::new(AccessToken::new(token)));
68 }
69 match (var(ENV_CLIENT_ID), var(ENV_CLIENT_SECRET)) {
70 (Some(id), Some(secret)) => return Ok(Arc::new(ServiceAccount::new(id, secret))),
71 (None, None) => {}
72 _ => {
73 return Err(Error::Config(format!(
74 "{ENV_CLIENT_ID} and {ENV_CLIENT_SECRET} must be set together"
75 )));
76 }
77 }
78 let path = cli_session_path(env).ok_or(Error::NoCredentials)?;
79 match CliSession::load_from(path)? {
80 Some(session) => Ok(Arc::new(session)),
81 None => Err(Error::NoCredentials),
82 }
83}
84
85#[derive(Clone)]
88pub struct AccessToken(String);
89
90impl AccessToken {
91 pub fn new(token: impl Into<String>) -> Self {
93 Self(token.into())
94 }
95}
96
97impl fmt::Debug for AccessToken {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.write_str("AccessToken(..)")
100 }
101}
102
103impl Credentials for AccessToken {
104 fn token(&self) -> TokenFuture<'_> {
105 Box::pin(async move { Ok(self.0.clone()) })
106 }
107}
108
109pub struct ServiceAccount {
112 client_id: String,
113 client_secret: String,
114 token_url: String,
115 http: Option<reqwest::Client>,
116 cache: Mutex<Cached>,
117}
118
119impl ServiceAccount {
120 pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
122 Self {
123 client_id: client_id.into(),
124 client_secret: client_secret.into(),
125 token_url: TOKEN_URL.to_owned(),
126 http: None,
127 cache: Mutex::default(),
128 }
129 }
130
131 pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
133 self.token_url = url.into();
134 self
135 }
136
137 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
140 self.http = Some(http);
141 self
142 }
143}
144
145impl fmt::Debug for ServiceAccount {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.debug_struct("ServiceAccount")
148 .field("client_id", &self.client_id)
149 .field("token_url", &self.token_url)
150 .finish_non_exhaustive()
151 }
152}
153
154impl Credentials for ServiceAccount {
155 fn token(&self) -> TokenFuture<'_> {
156 Box::pin(async move {
157 let mut cache = self.cache.lock().await;
158 if let Some(token) = cache.valid() {
159 return Ok(token);
160 }
161 let granted = request_token(
162 self.http.as_ref(),
163 &self.token_url,
164 &[
165 ("grant_type", "client_credentials"),
166 ("client_id", &self.client_id),
167 ("client_secret", &self.client_secret),
168 ],
169 )
170 .await?;
171 cache.token = granted.access_token.clone();
172 cache.expiry = Some(granted.expiry());
173 Ok(granted.access_token)
174 })
175 }
176}
177
178pub struct CliSession {
182 token_url: String,
183 http: Option<reqwest::Client>,
184 state: Mutex<SessionState>,
185}
186
187struct SessionState {
188 cached: Cached,
189 refresh_token: String,
190 refresh_expiry: Option<SystemTime>,
191}
192
193#[derive(Deserialize)]
195struct StoredSession {
196 #[serde(default)]
197 access_token: String,
198 #[serde(default)]
199 expires: Option<DateTime<Utc>>,
200 #[serde(default)]
201 refresh_token: String,
202 #[serde(default)]
203 refresh_expires: Option<DateTime<Utc>>,
204}
205
206impl CliSession {
207 pub fn load() -> Result<Option<Self>, Error> {
211 match cli_session_path(&|name| std::env::var(name).ok()) {
212 Some(path) => Self::load_from(path),
213 None => Ok(None),
214 }
215 }
216
217 pub fn load_from(path: impl Into<PathBuf>) -> Result<Option<Self>, Error> {
219 let path = path.into();
220 let data = match std::fs::read(&path) {
221 Ok(data) => data,
222 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
223 Err(e) => return Err(Error::Credentials(format!("reading the CLI session: {e}"))),
224 };
225 let stored: StoredSession = serde_json::from_slice(&data).map_err(|e| {
226 Error::Credentials(format!("reading the CLI session {}: {e}", path.display()))
227 })?;
228 Ok(Some(Self {
229 token_url: TOKEN_URL.to_owned(),
230 http: None,
231 state: Mutex::new(SessionState {
232 cached: Cached {
233 token: stored.access_token,
234 expiry: stored.expires.map(SystemTime::from),
235 },
236 refresh_token: stored.refresh_token,
237 refresh_expiry: stored.refresh_expires.map(SystemTime::from),
238 }),
239 }))
240 }
241
242 pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
244 self.token_url = url.into();
245 self
246 }
247
248 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
251 self.http = Some(http);
252 self
253 }
254}
255
256impl fmt::Debug for CliSession {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 f.debug_struct("CliSession")
259 .field("token_url", &self.token_url)
260 .finish_non_exhaustive()
261 }
262}
263
264impl Credentials for CliSession {
265 fn token(&self) -> TokenFuture<'_> {
266 Box::pin(async move {
267 let mut state = self.state.lock().await;
268 if let Some(token) = state.cached.valid() {
269 return Ok(token);
270 }
271 if state
272 .refresh_expiry
273 .is_none_or(|expiry| SystemTime::now() >= expiry)
274 {
275 return Err(Error::Credentials(
276 "the CLI session has expired: log in again with `origin id login`".to_owned(),
277 ));
278 }
279 let refresh_token = state.refresh_token.clone();
280 let granted = request_token(
281 self.http.as_ref(),
282 &self.token_url,
283 &[
284 ("grant_type", "refresh_token"),
285 ("client_id", CLI_CLIENT_ID),
286 ("refresh_token", &refresh_token),
287 ],
288 )
289 .await
290 .map_err(|e| match e {
291 Error::Credentials(message) => {
292 Error::Credentials(format!("renewing the CLI session: {message}"))
293 }
294 other => other,
295 })?;
296 if !granted.refresh_token.is_empty() {
297 state.refresh_token = granted.refresh_token.clone();
298 state.refresh_expiry =
299 Some(SystemTime::now() + seconds(granted.refresh_expires_in));
300 }
301 state.cached = Cached {
302 token: granted.access_token.clone(),
303 expiry: Some(granted.expiry()),
304 };
305 Ok(granted.access_token)
306 })
307 }
308}
309
310fn cli_session_path(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
313 let base = match env("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
314 Some(base) => PathBuf::from(base),
315 None => home_dir(env)?.join(".local").join("share"),
316 };
317 Some(base.join("origincloud").join("session.json"))
318}
319
320fn home_dir(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
321 let name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
322 env(name).filter(|v| !v.is_empty()).map(PathBuf::from)
323}
324
325#[derive(Default)]
327struct Cached {
328 token: String,
329 expiry: Option<SystemTime>,
330}
331
332impl Cached {
333 fn valid(&self) -> Option<String> {
334 let expiry = self.expiry?;
335 (!self.token.is_empty() && SystemTime::now() + EXPIRY_MARGIN < expiry)
336 .then(|| self.token.clone())
337 }
338}
339
340#[derive(Deserialize, Default)]
342#[serde(default)]
343struct Granted {
344 access_token: String,
345 expires_in: i64,
346 refresh_token: String,
347 refresh_expires_in: i64,
348 error: String,
349 error_description: String,
350}
351
352impl Granted {
353 fn expiry(&self) -> SystemTime {
354 SystemTime::now() + seconds(self.expires_in)
355 }
356}
357
358fn seconds(n: i64) -> Duration {
359 Duration::from_secs(u64::try_from(n).unwrap_or_default())
360}
361
362fn default_http_client() -> Result<&'static reqwest::Client, &'static str> {
365 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
366 CLIENT
367 .get_or_init(|| {
368 reqwest::Client::builder()
369 .build()
370 .map_err(|e| e.to_string())
371 })
372 .as_ref()
373 .map_err(String::as_str)
374}
375
376async fn request_token(
377 http: Option<&reqwest::Client>,
378 url: &str,
379 form: &[(&str, &str)],
380) -> Result<Granted, Error> {
381 let failed = |e: &dyn fmt::Display| Error::Credentials(format!("requesting a token: {e}"));
382 let http = match http {
383 Some(http) => http,
384 None => default_http_client().map_err(|e| failed(&e))?,
385 };
386 let mut response = http
387 .post(url)
388 .timeout(TOKEN_REQUEST_TIMEOUT)
389 .header(reqwest::header::ACCEPT, "application/json")
390 .form(form)
391 .send()
392 .await
393 .map_err(|e| failed(&e))?;
394 let status = response.status();
395 let mut body = Vec::new();
396 while let Some(chunk) = response.chunk().await.map_err(|e| failed(&e))? {
397 body.extend_from_slice(&chunk);
398 if body.len() > MAX_TOKEN_RESPONSE_BYTES {
399 body.truncate(MAX_TOKEN_RESPONSE_BYTES);
400 break;
401 }
402 }
403 let granted: Granted = serde_json::from_slice(&body).map_err(|_| {
404 Error::Credentials(format!(
405 "requesting a token: HTTP {}, not a token response",
406 status.as_u16()
407 ))
408 })?;
409 if status != reqwest::StatusCode::OK || granted.access_token.is_empty() {
410 let reason = if granted.error_description.is_empty() {
411 &granted.error
412 } else {
413 &granted.error_description
414 };
415 return Err(Error::Credentials(format!(
416 "the token request was refused (HTTP {}): {reason}",
417 status.as_u16()
418 )));
419 }
420 Ok(granted)
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use std::collections::HashMap;
427
428 fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
429 let vars: HashMap<String, String> = vars
430 .iter()
431 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
432 .collect();
433 move |name| vars.get(name).cloned()
434 }
435
436 fn now() -> DateTime<Utc> {
437 SystemTime::now().into()
438 }
439
440 fn data_home() -> PathBuf {
441 let dir =
442 std::env::temp_dir().join(format!("origincloud-sdk-test-{}", uuid::Uuid::new_v4()));
443 std::fs::create_dir_all(dir.join("origincloud")).unwrap();
444 dir
445 }
446
447 fn write_session(
448 dir: &std::path::Path,
449 expires: DateTime<Utc>,
450 refresh_expires: DateTime<Utc>,
451 ) -> PathBuf {
452 let path = dir.join("origincloud").join("session.json");
453 let data = serde_json::json!({
454 "version": 1,
455 "access_token": "stored-access",
456 "expires": expires.to_rfc3339(),
457 "refresh_token": "stored-refresh",
458 "refresh_expires": refresh_expires.to_rfc3339(),
459 "token_type": "Bearer",
460 });
461 std::fs::write(&path, data.to_string()).unwrap();
462 path
463 }
464
465 #[tokio::test]
466 async fn an_access_token_in_the_environment_comes_first() {
467 let credentials = from_environment(&env(&[
468 (ENV_ACCESS_TOKEN, "from-env"),
469 (ENV_CLIENT_ID, "id"),
470 (ENV_CLIENT_SECRET, "secret"),
471 ]))
472 .unwrap();
473 assert_eq!(credentials.token().await.unwrap(), "from-env");
474 }
475
476 #[test]
477 fn a_service_account_in_the_environment_comes_before_the_cli_session() {
478 let dir = data_home();
479 let hour = chrono::Duration::hours(1);
480 write_session(&dir, now() + hour, now() + hour);
481 let credentials = from_environment(&env(&[
482 ("XDG_DATA_HOME", dir.to_str().unwrap()),
483 (ENV_CLIENT_ID, "id"),
484 (ENV_CLIENT_SECRET, "secret"),
485 ]))
486 .unwrap();
487 assert!(
488 format!("{credentials:?}").starts_with("ServiceAccount"),
489 "{credentials:?}"
490 );
491 }
492
493 #[test]
494 fn half_a_service_account_is_refused() {
495 let err = from_environment(&env(&[(ENV_CLIENT_ID, "id")])).unwrap_err();
496 assert!(err.to_string().contains("must be set together"), "{err}");
497 let err = from_environment(&env(&[(ENV_CLIENT_SECRET, "secret")])).unwrap_err();
498 assert!(err.to_string().contains("must be set together"), "{err}");
499 }
500
501 #[test]
502 fn no_credentials_at_all() {
503 let dir = data_home();
504 let err = from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap_err();
505 assert!(matches!(err, Error::NoCredentials), "{err}");
506 }
507
508 #[test]
509 fn the_session_is_found_under_the_home_directory_without_xdg_data_home() {
510 let path = cli_session_path(&env(&[
511 ("HOME", "/home/someone"),
512 ("USERPROFILE", "/home/someone"),
513 ]))
514 .unwrap();
515 assert_eq!(
516 path,
517 PathBuf::from("/home/someone/.local/share/origincloud/session.json")
518 );
519 }
520
521 #[tokio::test]
522 async fn the_cli_session_is_used_as_stored() {
523 let dir = data_home();
524 write_session(
525 &dir,
526 now() + chrono::Duration::hours(1),
527 now() + chrono::Duration::hours(2),
528 );
529 let credentials =
530 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
531 assert_eq!(credentials.token().await.unwrap(), "stored-access");
532 }
533
534 #[tokio::test]
535 async fn a_cli_session_past_its_refresh_expiry_asks_to_log_in() {
536 let dir = data_home();
537 let path = write_session(
538 &dir,
539 now() - chrono::Duration::hours(1),
540 now() - chrono::Duration::minutes(1),
541 );
542 let session = CliSession::load_from(path).unwrap().unwrap();
543 let err = session.token().await.unwrap_err();
544 assert!(err.to_string().contains("origin id login"), "{err}");
545 }
546}