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";
24const ENV_METADATA_DISABLED: &str = "ORIGINCLOUD_METADATA_DISABLED";
25const ENV_METADATA_ENDPOINT: &str = "ORIGINCLOUD_METADATA_ENDPOINT";
26
27pub const METADATA_ENDPOINT: &str = "http://169.254.169.254";
30
31const METADATA_SESSION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
33const METADATA_SESSION_TTL_HEADER: &str = "x-origincloud-metadata-token-ttl-seconds";
34const METADATA_SESSION_HEADER: &str = "x-origincloud-metadata-token";
35
36const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
39const METADATA_SESSION_TIMEOUT: Duration = Duration::from_secs(2);
40
41const METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
43
44const METADATA_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
47
48const METADATA_RETRY_DELAYS: [Duration; 2] =
51 [Duration::from_millis(100), Duration::from_millis(200)];
52
53const CLI_CLIENT_ID: &str = "origin-cli";
55
56const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
59
60const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
62
63const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
65
66pub type TokenFuture<'a> = Pin<Box<dyn Future<Output = Result<String, Error>> + Send + 'a>>;
68
69pub trait Credentials: Send + Sync + fmt::Debug {
71 fn token(&self) -> TokenFuture<'_>;
73}
74
75pub fn default_credentials() -> Result<Arc<dyn Credentials>, Error> {
93 from_environment(&|name| std::env::var(name).ok())
94}
95
96pub(crate) fn from_environment(
98 env: &dyn Fn(&str) -> Option<String>,
99) -> Result<Arc<dyn Credentials>, Error> {
100 let var = |name: &str| env(name).filter(|v| !v.is_empty());
101 if let Some(token) = var(ENV_ACCESS_TOKEN) {
102 return Ok(Arc::new(AccessToken::new(token)));
103 }
104 match (var(ENV_CLIENT_ID), var(ENV_CLIENT_SECRET)) {
105 (Some(id), Some(secret)) => return Ok(Arc::new(ServiceAccount::new(id, secret))),
106 (None, None) => {}
107 _ => {
108 return Err(Error::Config(format!(
109 "{ENV_CLIENT_ID} and {ENV_CLIENT_SECRET} must be set together"
110 )));
111 }
112 }
113 if let Some(path) = cli_session_path(env)
114 && let Some(session) = CliSession::load_from(path)?
115 {
116 return Ok(Arc::new(session));
117 }
118 if var(ENV_METADATA_DISABLED).is_some_and(|v| v.eq_ignore_ascii_case("true")) {
119 return Err(Error::NoCredentials);
120 }
121 let metadata = InstanceMetadata::new();
122 Ok(Arc::new(match var(ENV_METADATA_ENDPOINT) {
123 Some(endpoint) => metadata.with_endpoint(endpoint),
124 None => metadata,
125 }))
126}
127
128#[derive(Clone)]
131pub struct AccessToken(String);
132
133impl AccessToken {
134 pub fn new(token: impl Into<String>) -> Self {
136 Self(token.into())
137 }
138}
139
140impl fmt::Debug for AccessToken {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 f.write_str("AccessToken(..)")
143 }
144}
145
146impl Credentials for AccessToken {
147 fn token(&self) -> TokenFuture<'_> {
148 Box::pin(async move { Ok(self.0.clone()) })
149 }
150}
151
152pub struct ServiceAccount {
155 client_id: String,
156 client_secret: String,
157 token_url: String,
158 http: Option<reqwest::Client>,
159 cache: Mutex<Cached>,
160}
161
162impl ServiceAccount {
163 pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
165 Self {
166 client_id: client_id.into(),
167 client_secret: client_secret.into(),
168 token_url: TOKEN_URL.to_owned(),
169 http: None,
170 cache: Mutex::default(),
171 }
172 }
173
174 pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
176 self.token_url = url.into();
177 self
178 }
179
180 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
183 self.http = Some(http);
184 self
185 }
186}
187
188impl fmt::Debug for ServiceAccount {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 f.debug_struct("ServiceAccount")
191 .field("client_id", &self.client_id)
192 .field("token_url", &self.token_url)
193 .finish_non_exhaustive()
194 }
195}
196
197impl Credentials for ServiceAccount {
198 fn token(&self) -> TokenFuture<'_> {
199 Box::pin(async move {
200 let mut cache = self.cache.lock().await;
201 if let Some(token) = cache.valid() {
202 return Ok(token);
203 }
204 let granted = request_token(
205 self.http.as_ref(),
206 &self.token_url,
207 &[
208 ("grant_type", "client_credentials"),
209 ("client_id", &self.client_id),
210 ("client_secret", &self.client_secret),
211 ],
212 )
213 .await?;
214 cache.token = granted.access_token.clone();
215 cache.expiry = Some(granted.expiry());
216 Ok(granted.access_token)
217 })
218 }
219}
220
221pub struct CliSession {
225 token_url: String,
226 http: Option<reqwest::Client>,
227 state: Mutex<SessionState>,
228}
229
230struct SessionState {
231 cached: Cached,
232 refresh_token: String,
233 refresh_expiry: Option<SystemTime>,
234}
235
236#[derive(Deserialize)]
238struct StoredSession {
239 #[serde(default)]
240 access_token: String,
241 #[serde(default)]
242 expires: Option<DateTime<Utc>>,
243 #[serde(default)]
244 refresh_token: String,
245 #[serde(default)]
246 refresh_expires: Option<DateTime<Utc>>,
247}
248
249impl CliSession {
250 pub fn load() -> Result<Option<Self>, Error> {
254 match cli_session_path(&|name| std::env::var(name).ok()) {
255 Some(path) => Self::load_from(path),
256 None => Ok(None),
257 }
258 }
259
260 pub fn load_from(path: impl Into<PathBuf>) -> Result<Option<Self>, Error> {
262 let path = path.into();
263 let data = match std::fs::read(&path) {
264 Ok(data) => data,
265 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
266 Err(e) => return Err(Error::Credentials(format!("reading the CLI session: {e}"))),
267 };
268 let stored: StoredSession = serde_json::from_slice(&data).map_err(|e| {
269 Error::Credentials(format!("reading the CLI session {}: {e}", path.display()))
270 })?;
271 Ok(Some(Self {
272 token_url: TOKEN_URL.to_owned(),
273 http: None,
274 state: Mutex::new(SessionState {
275 cached: Cached {
276 token: stored.access_token,
277 expiry: stored.expires.map(SystemTime::from),
278 },
279 refresh_token: stored.refresh_token,
280 refresh_expiry: stored.refresh_expires.map(SystemTime::from),
281 }),
282 }))
283 }
284
285 pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
287 self.token_url = url.into();
288 self
289 }
290
291 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
294 self.http = Some(http);
295 self
296 }
297}
298
299impl fmt::Debug for CliSession {
300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301 f.debug_struct("CliSession")
302 .field("token_url", &self.token_url)
303 .finish_non_exhaustive()
304 }
305}
306
307impl Credentials for CliSession {
308 fn token(&self) -> TokenFuture<'_> {
309 Box::pin(async move {
310 let mut state = self.state.lock().await;
311 if let Some(token) = state.cached.valid() {
312 return Ok(token);
313 }
314 if state
315 .refresh_expiry
316 .is_none_or(|expiry| SystemTime::now() >= expiry)
317 {
318 return Err(Error::Credentials(
319 "the CLI session has expired: log in again with `origin id login`".to_owned(),
320 ));
321 }
322 let refresh_token = state.refresh_token.clone();
323 let granted = request_token(
324 self.http.as_ref(),
325 &self.token_url,
326 &[
327 ("grant_type", "refresh_token"),
328 ("client_id", CLI_CLIENT_ID),
329 ("refresh_token", &refresh_token),
330 ],
331 )
332 .await
333 .map_err(|e| match e {
334 Error::Credentials(message) => {
335 Error::Credentials(format!("renewing the CLI session: {message}"))
336 }
337 other => other,
338 })?;
339 if !granted.refresh_token.is_empty() {
340 state.refresh_token = granted.refresh_token.clone();
341 state.refresh_expiry =
342 Some(SystemTime::now() + seconds(granted.refresh_expires_in));
343 }
344 state.cached = Cached {
345 token: granted.access_token.clone(),
346 expiry: Some(granted.expiry()),
347 };
348 Ok(granted.access_token)
349 })
350 }
351}
352
353pub struct InstanceMetadata {
360 endpoint: String,
361 http: Option<reqwest::Client>,
362 state: Mutex<MetadataState>,
363}
364
365#[derive(Default)]
366struct MetadataState {
367 session: Cached,
368 cached: Cached,
369}
370
371#[derive(Deserialize)]
373struct Issued {
374 access_token: String,
375 expires_at: DateTime<Utc>,
376}
377
378impl InstanceMetadata {
379 pub fn new() -> Self {
381 Self {
382 endpoint: METADATA_ENDPOINT.to_owned(),
383 http: None,
384 state: Mutex::default(),
385 }
386 }
387
388 pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
390 self.endpoint = url.into().trim_end_matches('/').to_owned();
391 self
392 }
393
394 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
399 self.http = Some(http);
400 self
401 }
402
403 fn http(&self) -> Result<&reqwest::Client, Error> {
404 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
405 if let Some(http) = &self.http {
406 return Ok(http);
407 }
408 CLIENT
409 .get_or_init(|| {
410 reqwest::Client::builder()
411 .no_proxy()
412 .connect_timeout(METADATA_CONNECT_TIMEOUT)
413 .build()
414 .map_err(|e| e.to_string())
415 })
416 .as_ref()
417 .map_err(|e| Error::Credentials(format!("reaching the metadata service: {e}")))
418 }
419
420 async fn open_session(&self) -> Result<String, Error> {
425 let request = self
426 .http()?
427 .put(format!("{}/latest/api/token", self.endpoint))
428 .timeout(METADATA_SESSION_TIMEOUT)
429 .header(
430 METADATA_SESSION_TTL_HEADER,
431 METADATA_SESSION_TTL.as_secs().to_string(),
432 );
433 let (status, body) = send_retrying(request)
434 .await
435 .map_err(|_| Error::NoCredentials)?;
436 match status.as_u16() {
437 200 => Ok(String::from_utf8_lossy(&body).trim().to_owned()),
438 403 => Err(refused_through_a_proxy()),
439 503 => Err(Error::Credentials(
440 "the metadata service refused a session (HTTP 503)".to_owned(),
441 )),
442 _ => Err(Error::NoCredentials),
443 }
444 }
445
446 async fn request_credential(&self, session: &str) -> Result<Option<Issued>, Error> {
448 let failed =
449 |e: reqwest::Error| Error::Credentials(format!("reaching the metadata service: {e}"));
450 let request = self
451 .http()?
452 .get(format!(
453 "{}/latest/meta-data/service-account/token",
454 self.endpoint
455 ))
456 .timeout(METADATA_REQUEST_TIMEOUT)
457 .header(METADATA_SESSION_HEADER, session)
458 .header(reqwest::header::ACCEPT, "application/json");
459 let (status, body) = send_retrying(request).await.map_err(failed)?;
460 match status.as_u16() {
461 200 => {}
462 401 => return Ok(None),
463 404 => {
464 return Err(Error::Credentials(
465 "the instance runs as no service account".to_owned(),
466 ));
467 }
468 403 => return Err(refused_through_a_proxy()),
469 status => {
470 return Err(Error::Credentials(format!(
471 "the metadata service answered HTTP {status}"
472 )));
473 }
474 }
475 match serde_json::from_slice::<Issued>(&body) {
476 Ok(issued) if !issued.access_token.is_empty() => Ok(Some(issued)),
477 _ => Err(Error::Credentials(
478 "the metadata service's answer is not a credential".to_owned(),
479 )),
480 }
481 }
482}
483
484async fn send_retrying(
487 request: reqwest::RequestBuilder,
488) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
489 let mut delays = METADATA_RETRY_DELAYS.iter();
490 loop {
491 let attempt = request
492 .try_clone()
493 .expect("a metadata-service request has no streamed body");
494 let answer = read_bounded(attempt.send().await?).await?;
495 match delays.next() {
496 Some(delay) if answer.0 == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
497 tokio::time::sleep(*delay).await;
498 }
499 _ => return Ok(answer),
500 }
501 }
502}
503
504fn refused_through_a_proxy() -> Error {
505 Error::Credentials(
506 "the metadata service refused a request that came through a proxy".to_owned(),
507 )
508}
509
510impl Default for InstanceMetadata {
511 fn default() -> Self {
512 Self::new()
513 }
514}
515
516impl fmt::Debug for InstanceMetadata {
517 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
518 f.debug_struct("InstanceMetadata")
519 .field("endpoint", &self.endpoint)
520 .finish_non_exhaustive()
521 }
522}
523
524impl Credentials for InstanceMetadata {
525 fn token(&self) -> TokenFuture<'_> {
526 Box::pin(async move {
527 let mut state = self.state.lock().await;
528 if let Some(token) = state.cached.valid_for(METADATA_EXPIRY_MARGIN) {
529 return Ok(token);
530 }
531 for _ in 0..2 {
533 let session = match state.session.valid() {
534 Some(session) => session,
535 None => {
536 let session = self.open_session().await?;
537 state.session = Cached {
538 token: session.clone(),
539 expiry: Some(SystemTime::now() + METADATA_SESSION_TTL),
540 };
541 session
542 }
543 };
544 match self.request_credential(&session).await? {
545 Some(issued) => {
546 state.cached = Cached {
547 token: issued.access_token.clone(),
548 expiry: Some(issued.expires_at.into()),
549 };
550 return Ok(issued.access_token);
551 }
552 None => state.session = Cached::default(),
553 }
554 }
555 Err(Error::Credentials(
556 "the metadata service answered HTTP 401".to_owned(),
557 ))
558 })
559 }
560}
561
562fn cli_session_path(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
565 let base = match env("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
566 Some(base) => PathBuf::from(base),
567 None => home_dir(env)?.join(".local").join("share"),
568 };
569 Some(base.join("origincloud").join("session.json"))
570}
571
572fn home_dir(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
573 let name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
574 env(name).filter(|v| !v.is_empty()).map(PathBuf::from)
575}
576
577#[derive(Default)]
579struct Cached {
580 token: String,
581 expiry: Option<SystemTime>,
582}
583
584impl Cached {
585 fn valid(&self) -> Option<String> {
586 self.valid_for(EXPIRY_MARGIN)
587 }
588
589 fn valid_for(&self, margin: Duration) -> Option<String> {
591 let expiry = self.expiry?;
592 (!self.token.is_empty() && SystemTime::now() + margin < expiry).then(|| self.token.clone())
593 }
594}
595
596#[derive(Deserialize, Default)]
598#[serde(default)]
599struct Granted {
600 access_token: String,
601 expires_in: i64,
602 refresh_token: String,
603 refresh_expires_in: i64,
604 error: String,
605 error_description: String,
606}
607
608impl Granted {
609 fn expiry(&self) -> SystemTime {
610 SystemTime::now() + seconds(self.expires_in)
611 }
612}
613
614fn seconds(n: i64) -> Duration {
615 Duration::from_secs(u64::try_from(n).unwrap_or_default())
616}
617
618fn default_http_client() -> Result<&'static reqwest::Client, &'static str> {
621 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
622 CLIENT
623 .get_or_init(|| {
624 reqwest::Client::builder()
625 .build()
626 .map_err(|e| e.to_string())
627 })
628 .as_ref()
629 .map_err(String::as_str)
630}
631
632async fn request_token(
633 http: Option<&reqwest::Client>,
634 url: &str,
635 form: &[(&str, &str)],
636) -> Result<Granted, Error> {
637 let failed = |e: &dyn fmt::Display| Error::Credentials(format!("requesting a token: {e}"));
638 let http = match http {
639 Some(http) => http,
640 None => default_http_client().map_err(|e| failed(&e))?,
641 };
642 let response = http
643 .post(url)
644 .timeout(TOKEN_REQUEST_TIMEOUT)
645 .header(reqwest::header::ACCEPT, "application/json")
646 .form(form)
647 .send()
648 .await
649 .map_err(|e| failed(&e))?;
650 let (status, body) = read_bounded(response).await.map_err(|e| failed(&e))?;
651 let granted: Granted = serde_json::from_slice(&body).map_err(|_| {
652 Error::Credentials(format!(
653 "requesting a token: HTTP {}, not a token response",
654 status.as_u16()
655 ))
656 })?;
657 if status != reqwest::StatusCode::OK || granted.access_token.is_empty() {
658 let reason = if granted.error_description.is_empty() {
659 &granted.error
660 } else {
661 &granted.error_description
662 };
663 return Err(Error::Credentials(format!(
664 "the token request was refused (HTTP {}): {reason}",
665 status.as_u16()
666 )));
667 }
668 Ok(granted)
669}
670
671async fn read_bounded(
674 mut response: reqwest::Response,
675) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
676 let status = response.status();
677 let mut body = Vec::new();
678 while let Some(chunk) = response.chunk().await? {
679 body.extend_from_slice(&chunk);
680 if body.len() > MAX_TOKEN_RESPONSE_BYTES {
681 body.truncate(MAX_TOKEN_RESPONSE_BYTES);
682 break;
683 }
684 }
685 Ok((status, body))
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use std::collections::HashMap;
692
693 fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
694 let vars: HashMap<String, String> = vars
695 .iter()
696 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
697 .collect();
698 move |name| vars.get(name).cloned()
699 }
700
701 fn now() -> DateTime<Utc> {
702 SystemTime::now().into()
703 }
704
705 fn data_home() -> PathBuf {
706 let dir =
707 std::env::temp_dir().join(format!("origincloud-sdk-test-{}", uuid::Uuid::new_v4()));
708 std::fs::create_dir_all(dir.join("origincloud")).unwrap();
709 dir
710 }
711
712 fn write_session(
713 dir: &std::path::Path,
714 expires: DateTime<Utc>,
715 refresh_expires: DateTime<Utc>,
716 ) -> PathBuf {
717 let path = dir.join("origincloud").join("session.json");
718 let data = serde_json::json!({
719 "version": 1,
720 "access_token": "stored-access",
721 "expires": expires.to_rfc3339(),
722 "refresh_token": "stored-refresh",
723 "refresh_expires": refresh_expires.to_rfc3339(),
724 "token_type": "Bearer",
725 });
726 std::fs::write(&path, data.to_string()).unwrap();
727 path
728 }
729
730 #[tokio::test]
731 async fn an_access_token_in_the_environment_comes_first() {
732 let credentials = from_environment(&env(&[
733 (ENV_ACCESS_TOKEN, "from-env"),
734 (ENV_CLIENT_ID, "id"),
735 (ENV_CLIENT_SECRET, "secret"),
736 ]))
737 .unwrap();
738 assert_eq!(credentials.token().await.unwrap(), "from-env");
739 }
740
741 #[test]
742 fn a_service_account_in_the_environment_comes_before_the_cli_session() {
743 let dir = data_home();
744 let hour = chrono::Duration::hours(1);
745 write_session(&dir, now() + hour, now() + hour);
746 let credentials = from_environment(&env(&[
747 ("XDG_DATA_HOME", dir.to_str().unwrap()),
748 (ENV_CLIENT_ID, "id"),
749 (ENV_CLIENT_SECRET, "secret"),
750 ]))
751 .unwrap();
752 assert!(
753 format!("{credentials:?}").starts_with("ServiceAccount"),
754 "{credentials:?}"
755 );
756 }
757
758 #[test]
759 fn half_a_service_account_is_refused() {
760 let err = from_environment(&env(&[(ENV_CLIENT_ID, "id")])).unwrap_err();
761 assert!(err.to_string().contains("must be set together"), "{err}");
762 let err = from_environment(&env(&[(ENV_CLIENT_SECRET, "secret")])).unwrap_err();
763 assert!(err.to_string().contains("must be set together"), "{err}");
764 }
765
766 #[test]
767 fn without_others_the_metadata_service_comes_last() {
768 let dir = data_home();
769 let credentials =
770 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
771 assert!(
772 format!("{credentials:?}").starts_with("InstanceMetadata"),
773 "{credentials:?}"
774 );
775 }
776
777 #[test]
778 fn the_metadata_service_s_address_can_be_named() {
779 let dir = data_home();
780 let credentials = from_environment(&env(&[
781 ("XDG_DATA_HOME", dir.to_str().unwrap()),
782 (ENV_METADATA_ENDPOINT, "http://127.0.0.1:8080/"),
783 ]))
784 .unwrap();
785 assert!(
786 format!("{credentials:?}").contains(r#"endpoint: "http://127.0.0.1:8080""#),
787 "{credentials:?}"
788 );
789 }
790
791 #[test]
792 fn no_credentials_at_all_without_the_metadata_service() {
793 let dir = data_home();
794 let err = from_environment(&env(&[
795 ("XDG_DATA_HOME", dir.to_str().unwrap()),
796 (ENV_METADATA_DISABLED, "TRUE"),
797 ]))
798 .unwrap_err();
799 assert!(matches!(err, Error::NoCredentials), "{err}");
800 }
801
802 #[test]
803 fn the_session_is_found_under_the_home_directory_without_xdg_data_home() {
804 let path = cli_session_path(&env(&[
805 ("HOME", "/home/someone"),
806 ("USERPROFILE", "/home/someone"),
807 ]))
808 .unwrap();
809 assert_eq!(
810 path,
811 PathBuf::from("/home/someone/.local/share/origincloud/session.json")
812 );
813 }
814
815 #[tokio::test]
816 async fn the_cli_session_is_used_as_stored() {
817 let dir = data_home();
818 write_session(
819 &dir,
820 now() + chrono::Duration::hours(1),
821 now() + chrono::Duration::hours(2),
822 );
823 let credentials =
824 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
825 assert_eq!(credentials.token().await.unwrap(), "stored-access");
826 }
827
828 #[tokio::test]
829 async fn a_cli_session_past_its_refresh_expiry_asks_to_log_in() {
830 let dir = data_home();
831 let path = write_session(
832 &dir,
833 now() - chrono::Duration::hours(1),
834 now() - chrono::Duration::minutes(1),
835 );
836 let session = CliSession::load_from(path).unwrap().unwrap();
837 let err = session.token().await.unwrap_err();
838 assert!(err.to_string().contains("origin id login"), "{err}");
839 }
840}