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> {
423 let request = self
424 .http()?
425 .put(format!("{}/latest/api/token", self.endpoint))
426 .timeout(METADATA_SESSION_TIMEOUT)
427 .header(
428 METADATA_SESSION_TTL_HEADER,
429 METADATA_SESSION_TTL.as_secs().to_string(),
430 );
431 let (status, body) = send_retrying(request)
432 .await
433 .map_err(|_| Error::NoCredentials)?;
434 match status.as_u16() {
435 200 => Ok(String::from_utf8_lossy(&body).trim().to_owned()),
436 404 => Err(Error::NoCredentials),
437 403 => Err(refused_through_a_proxy()),
438 status => Err(Error::Credentials(format!(
439 "the metadata service refused a session (HTTP {status})"
440 ))),
441 }
442 }
443
444 async fn request_credential(&self, session: &str) -> Result<Option<Issued>, Error> {
446 let failed =
447 |e: reqwest::Error| Error::Credentials(format!("reaching the metadata service: {e}"));
448 let request = self
449 .http()?
450 .get(format!(
451 "{}/latest/meta-data/service-account/token",
452 self.endpoint
453 ))
454 .timeout(METADATA_REQUEST_TIMEOUT)
455 .header(METADATA_SESSION_HEADER, session)
456 .header(reqwest::header::ACCEPT, "application/json");
457 let (status, body) = send_retrying(request).await.map_err(failed)?;
458 match status.as_u16() {
459 200 => {}
460 401 => return Ok(None),
461 404 => {
462 return Err(Error::Credentials(
463 "the instance runs as no service account".to_owned(),
464 ));
465 }
466 403 => return Err(refused_through_a_proxy()),
467 status => {
468 return Err(Error::Credentials(format!(
469 "the metadata service answered HTTP {status}"
470 )));
471 }
472 }
473 match serde_json::from_slice::<Issued>(&body) {
474 Ok(issued) if !issued.access_token.is_empty() => Ok(Some(issued)),
475 _ => Err(Error::Credentials(
476 "the metadata service's answer is not a credential".to_owned(),
477 )),
478 }
479 }
480}
481
482async fn send_retrying(
485 request: reqwest::RequestBuilder,
486) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
487 let mut delays = METADATA_RETRY_DELAYS.iter();
488 loop {
489 let attempt = request
490 .try_clone()
491 .expect("a metadata-service request has no streamed body");
492 let answer = read_bounded(attempt.send().await?).await?;
493 match delays.next() {
494 Some(delay) if answer.0 == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
495 tokio::time::sleep(*delay).await;
496 }
497 _ => return Ok(answer),
498 }
499 }
500}
501
502fn refused_through_a_proxy() -> Error {
503 Error::Credentials(
504 "the metadata service refused a request that came through a proxy".to_owned(),
505 )
506}
507
508impl Default for InstanceMetadata {
509 fn default() -> Self {
510 Self::new()
511 }
512}
513
514impl fmt::Debug for InstanceMetadata {
515 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516 f.debug_struct("InstanceMetadata")
517 .field("endpoint", &self.endpoint)
518 .finish_non_exhaustive()
519 }
520}
521
522impl Credentials for InstanceMetadata {
523 fn token(&self) -> TokenFuture<'_> {
524 Box::pin(async move {
525 let mut state = self.state.lock().await;
526 if let Some(token) = state.cached.valid_for(METADATA_EXPIRY_MARGIN) {
527 return Ok(token);
528 }
529 for _ in 0..2 {
531 let session = match state.session.valid() {
532 Some(session) => session,
533 None => {
534 let session = self.open_session().await?;
535 state.session = Cached {
536 token: session.clone(),
537 expiry: Some(SystemTime::now() + METADATA_SESSION_TTL),
538 };
539 session
540 }
541 };
542 match self.request_credential(&session).await? {
543 Some(issued) => {
544 state.cached = Cached {
545 token: issued.access_token.clone(),
546 expiry: Some(issued.expires_at.into()),
547 };
548 return Ok(issued.access_token);
549 }
550 None => state.session = Cached::default(),
551 }
552 }
553 Err(Error::Credentials(
554 "the metadata service answered HTTP 401".to_owned(),
555 ))
556 })
557 }
558}
559
560fn cli_session_path(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
563 let base = match env("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
564 Some(base) => PathBuf::from(base),
565 None => home_dir(env)?.join(".local").join("share"),
566 };
567 Some(base.join("origincloud").join("session.json"))
568}
569
570fn home_dir(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
571 let name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
572 env(name).filter(|v| !v.is_empty()).map(PathBuf::from)
573}
574
575#[derive(Default)]
577struct Cached {
578 token: String,
579 expiry: Option<SystemTime>,
580}
581
582impl Cached {
583 fn valid(&self) -> Option<String> {
584 self.valid_for(EXPIRY_MARGIN)
585 }
586
587 fn valid_for(&self, margin: Duration) -> Option<String> {
589 let expiry = self.expiry?;
590 (!self.token.is_empty() && SystemTime::now() + margin < expiry).then(|| self.token.clone())
591 }
592}
593
594#[derive(Deserialize, Default)]
596#[serde(default)]
597struct Granted {
598 access_token: String,
599 expires_in: i64,
600 refresh_token: String,
601 refresh_expires_in: i64,
602 error: String,
603 error_description: String,
604}
605
606impl Granted {
607 fn expiry(&self) -> SystemTime {
608 SystemTime::now() + seconds(self.expires_in)
609 }
610}
611
612fn seconds(n: i64) -> Duration {
613 Duration::from_secs(u64::try_from(n).unwrap_or_default())
614}
615
616fn default_http_client() -> Result<&'static reqwest::Client, &'static str> {
619 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
620 CLIENT
621 .get_or_init(|| {
622 reqwest::Client::builder()
623 .build()
624 .map_err(|e| e.to_string())
625 })
626 .as_ref()
627 .map_err(String::as_str)
628}
629
630async fn request_token(
631 http: Option<&reqwest::Client>,
632 url: &str,
633 form: &[(&str, &str)],
634) -> Result<Granted, Error> {
635 let failed = |e: &dyn fmt::Display| Error::Credentials(format!("requesting a token: {e}"));
636 let http = match http {
637 Some(http) => http,
638 None => default_http_client().map_err(|e| failed(&e))?,
639 };
640 let response = http
641 .post(url)
642 .timeout(TOKEN_REQUEST_TIMEOUT)
643 .header(reqwest::header::ACCEPT, "application/json")
644 .form(form)
645 .send()
646 .await
647 .map_err(|e| failed(&e))?;
648 let (status, body) = read_bounded(response).await.map_err(|e| failed(&e))?;
649 let granted: Granted = serde_json::from_slice(&body).map_err(|_| {
650 Error::Credentials(format!(
651 "requesting a token: HTTP {}, not a token response",
652 status.as_u16()
653 ))
654 })?;
655 if status != reqwest::StatusCode::OK || granted.access_token.is_empty() {
656 let reason = if granted.error_description.is_empty() {
657 &granted.error
658 } else {
659 &granted.error_description
660 };
661 return Err(Error::Credentials(format!(
662 "the token request was refused (HTTP {}): {reason}",
663 status.as_u16()
664 )));
665 }
666 Ok(granted)
667}
668
669async fn read_bounded(
672 mut response: reqwest::Response,
673) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
674 let status = response.status();
675 let mut body = Vec::new();
676 while let Some(chunk) = response.chunk().await? {
677 body.extend_from_slice(&chunk);
678 if body.len() > MAX_TOKEN_RESPONSE_BYTES {
679 body.truncate(MAX_TOKEN_RESPONSE_BYTES);
680 break;
681 }
682 }
683 Ok((status, body))
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use std::collections::HashMap;
690
691 fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
692 let vars: HashMap<String, String> = vars
693 .iter()
694 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
695 .collect();
696 move |name| vars.get(name).cloned()
697 }
698
699 fn now() -> DateTime<Utc> {
700 SystemTime::now().into()
701 }
702
703 fn data_home() -> PathBuf {
704 let dir =
705 std::env::temp_dir().join(format!("origincloud-sdk-test-{}", uuid::Uuid::new_v4()));
706 std::fs::create_dir_all(dir.join("origincloud")).unwrap();
707 dir
708 }
709
710 fn write_session(
711 dir: &std::path::Path,
712 expires: DateTime<Utc>,
713 refresh_expires: DateTime<Utc>,
714 ) -> PathBuf {
715 let path = dir.join("origincloud").join("session.json");
716 let data = serde_json::json!({
717 "version": 1,
718 "access_token": "stored-access",
719 "expires": expires.to_rfc3339(),
720 "refresh_token": "stored-refresh",
721 "refresh_expires": refresh_expires.to_rfc3339(),
722 "token_type": "Bearer",
723 });
724 std::fs::write(&path, data.to_string()).unwrap();
725 path
726 }
727
728 #[tokio::test]
729 async fn an_access_token_in_the_environment_comes_first() {
730 let credentials = from_environment(&env(&[
731 (ENV_ACCESS_TOKEN, "from-env"),
732 (ENV_CLIENT_ID, "id"),
733 (ENV_CLIENT_SECRET, "secret"),
734 ]))
735 .unwrap();
736 assert_eq!(credentials.token().await.unwrap(), "from-env");
737 }
738
739 #[test]
740 fn a_service_account_in_the_environment_comes_before_the_cli_session() {
741 let dir = data_home();
742 let hour = chrono::Duration::hours(1);
743 write_session(&dir, now() + hour, now() + hour);
744 let credentials = from_environment(&env(&[
745 ("XDG_DATA_HOME", dir.to_str().unwrap()),
746 (ENV_CLIENT_ID, "id"),
747 (ENV_CLIENT_SECRET, "secret"),
748 ]))
749 .unwrap();
750 assert!(
751 format!("{credentials:?}").starts_with("ServiceAccount"),
752 "{credentials:?}"
753 );
754 }
755
756 #[test]
757 fn half_a_service_account_is_refused() {
758 let err = from_environment(&env(&[(ENV_CLIENT_ID, "id")])).unwrap_err();
759 assert!(err.to_string().contains("must be set together"), "{err}");
760 let err = from_environment(&env(&[(ENV_CLIENT_SECRET, "secret")])).unwrap_err();
761 assert!(err.to_string().contains("must be set together"), "{err}");
762 }
763
764 #[test]
765 fn without_others_the_metadata_service_comes_last() {
766 let dir = data_home();
767 let credentials =
768 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
769 assert!(
770 format!("{credentials:?}").starts_with("InstanceMetadata"),
771 "{credentials:?}"
772 );
773 }
774
775 #[test]
776 fn the_metadata_service_s_address_can_be_named() {
777 let dir = data_home();
778 let credentials = from_environment(&env(&[
779 ("XDG_DATA_HOME", dir.to_str().unwrap()),
780 (ENV_METADATA_ENDPOINT, "http://127.0.0.1:8080/"),
781 ]))
782 .unwrap();
783 assert!(
784 format!("{credentials:?}").contains(r#"endpoint: "http://127.0.0.1:8080""#),
785 "{credentials:?}"
786 );
787 }
788
789 #[test]
790 fn no_credentials_at_all_without_the_metadata_service() {
791 let dir = data_home();
792 let err = from_environment(&env(&[
793 ("XDG_DATA_HOME", dir.to_str().unwrap()),
794 (ENV_METADATA_DISABLED, "TRUE"),
795 ]))
796 .unwrap_err();
797 assert!(matches!(err, Error::NoCredentials), "{err}");
798 }
799
800 #[test]
801 fn the_session_is_found_under_the_home_directory_without_xdg_data_home() {
802 let path = cli_session_path(&env(&[
803 ("HOME", "/home/someone"),
804 ("USERPROFILE", "/home/someone"),
805 ]))
806 .unwrap();
807 assert_eq!(
808 path,
809 PathBuf::from("/home/someone/.local/share/origincloud/session.json")
810 );
811 }
812
813 #[tokio::test]
814 async fn the_cli_session_is_used_as_stored() {
815 let dir = data_home();
816 write_session(
817 &dir,
818 now() + chrono::Duration::hours(1),
819 now() + chrono::Duration::hours(2),
820 );
821 let credentials =
822 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
823 assert_eq!(credentials.token().await.unwrap(), "stored-access");
824 }
825
826 #[tokio::test]
827 async fn a_cli_session_past_its_refresh_expiry_asks_to_log_in() {
828 let dir = data_home();
829 let path = write_session(
830 &dir,
831 now() - chrono::Duration::hours(1),
832 now() - chrono::Duration::minutes(1),
833 );
834 let session = CliSession::load_from(path).unwrap().unwrap();
835 let err = session.token().await.unwrap_err();
836 assert!(err.to_string().contains("origin id login"), "{err}");
837 }
838}