1use std::fmt;
14#[cfg(not(target_arch = "wasm32"))]
15use std::path::PathBuf;
16use std::sync::Arc;
17#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
18use std::sync::OnceLock;
19#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
20use std::time::{Duration, SystemTime};
21
22#[cfg(not(target_arch = "wasm32"))]
23use chrono::{DateTime, Utc};
24#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
25use serde::Deserialize;
26
27use crate::Error;
28use crate::runtime::MaybeSendFuture;
29#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
30use crate::runtime::{Mutex, now};
31#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
32use crate::transport::read_capped;
33
34pub const TOKEN_URL: &str =
37 "https://auth.origincloud.eu/realms/origincloud/protocol/openid-connect/token";
38
39#[cfg(not(target_arch = "wasm32"))]
40const ENV_ACCESS_TOKEN: &str = "ORIGINCLOUD_ACCESS_TOKEN";
41#[cfg(not(target_arch = "wasm32"))]
42const ENV_CLIENT_ID: &str = "ORIGINCLOUD_CLIENT_ID";
43#[cfg(not(target_arch = "wasm32"))]
44const ENV_CLIENT_SECRET: &str = "ORIGINCLOUD_CLIENT_SECRET";
45#[cfg(not(target_arch = "wasm32"))]
46const ENV_METADATA_DISABLED: &str = "ORIGINCLOUD_METADATA_DISABLED";
47#[cfg(not(target_arch = "wasm32"))]
48const ENV_METADATA_ENDPOINT: &str = "ORIGINCLOUD_METADATA_ENDPOINT";
49#[cfg(not(target_arch = "wasm32"))]
50const ENV_OIDC_DISABLED: &str = "ORIGINCLOUD_OIDC_DISABLED";
51#[cfg(not(target_arch = "wasm32"))]
52const ENV_OIDC_TOKEN_FILE: &str = "ORIGINCLOUD_OIDC_TOKEN_FILE";
53#[cfg(not(target_arch = "wasm32"))]
54const ENV_OIDC_TOKEN: &str = "ORIGINCLOUD_OIDC_TOKEN";
55#[cfg(not(target_arch = "wasm32"))]
56const ENV_OIDC_AUDIENCE: &str = "ORIGINCLOUD_OIDC_AUDIENCE";
57#[cfg(not(target_arch = "wasm32"))]
58const ENV_STS_ENDPOINT: &str = "ORIGINCLOUD_STS_ENDPOINT";
59#[cfg(not(target_arch = "wasm32"))]
60const ENV_GITHUB_REQUEST_URL: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
61#[cfg(not(target_arch = "wasm32"))]
62const ENV_GITHUB_REQUEST_TOKEN: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
63
64pub const STS_ENDPOINT: &str = "https://sts.origincloudapis.eu";
67
68pub const OIDC_AUDIENCE: &str = "https://sts.origincloudapis.eu";
70
71#[cfg(not(target_arch = "wasm32"))]
73const FEDERATED_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
74
75#[cfg(not(target_arch = "wasm32"))]
78const STS_RETRY_DELAYS: [Duration; 2] = [Duration::from_millis(200), Duration::from_millis(400)];
79
80#[cfg(not(target_arch = "wasm32"))]
81const TOKEN_EXCHANGE_GRANT: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
82#[cfg(not(target_arch = "wasm32"))]
83const JWT_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:jwt";
84
85pub const METADATA_ENDPOINT: &str = "http://169.254.169.254";
88
89#[cfg(not(target_arch = "wasm32"))]
91const METADATA_SESSION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
92#[cfg(not(target_arch = "wasm32"))]
93const METADATA_SESSION_TTL_HEADER: &str = "x-origincloud-metadata-token-ttl-seconds";
94#[cfg(not(target_arch = "wasm32"))]
95const METADATA_SESSION_HEADER: &str = "x-origincloud-metadata-token";
96
97#[cfg(not(target_arch = "wasm32"))]
100const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
101#[cfg(not(target_arch = "wasm32"))]
102const METADATA_SESSION_TIMEOUT: Duration = Duration::from_secs(2);
103
104#[cfg(not(target_arch = "wasm32"))]
106const METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
107
108#[cfg(not(target_arch = "wasm32"))]
111const METADATA_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
112
113#[cfg(not(target_arch = "wasm32"))]
116const METADATA_RETRY_DELAYS: [Duration; 2] =
117 [Duration::from_millis(100), Duration::from_millis(200)];
118
119#[cfg(not(target_arch = "wasm32"))]
121const CLI_CLIENT_ID: &str = "origin-cli";
122
123#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
126const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
127
128#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
130const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
131
132#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
134const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
135
136pub type TokenFuture<'a> = MaybeSendFuture<'a, Result<String, Error>>;
139
140pub trait Credentials: Send + Sync + fmt::Debug {
145 fn token(&self) -> TokenFuture<'_>;
147}
148
149#[cfg(not(target_arch = "wasm32"))]
176pub fn default_credentials() -> Result<Arc<dyn Credentials>, Error> {
177 from_environment(&|name| std::env::var(name).ok())
178}
179
180#[cfg(not(target_arch = "wasm32"))]
182pub(crate) fn from_environment(
183 env: &dyn Fn(&str) -> Option<String>,
184) -> Result<Arc<dyn Credentials>, Error> {
185 let var = |name: &str| env(name).filter(|v| !v.is_empty());
186 if let Some(token) = var(ENV_ACCESS_TOKEN) {
187 return Ok(Arc::new(AccessToken::new(token)));
188 }
189 match (var(ENV_CLIENT_ID), var(ENV_CLIENT_SECRET)) {
190 (Some(id), Some(secret)) => return Ok(Arc::new(ServiceAccount::new(id, secret))),
191 (None, None) => {}
192 _ => {
193 return Err(Error::Config(format!(
194 "{ENV_CLIENT_ID} and {ENV_CLIENT_SECRET} must be set together"
195 )));
196 }
197 }
198 if let Some(federated) = federated_from(&var) {
199 return Ok(Arc::new(federated));
200 }
201 if let Some(path) = cli_session_path(env)
202 && let Some(session) = CliSession::load_from(path)?
203 {
204 return Ok(Arc::new(session));
205 }
206 if var(ENV_METADATA_DISABLED).is_some_and(|v| v.eq_ignore_ascii_case("true")) {
207 return Err(Error::NoCredentials);
208 }
209 let metadata = InstanceMetadata::new();
210 Ok(Arc::new(match var(ENV_METADATA_ENDPOINT) {
211 Some(endpoint) => metadata.with_endpoint(endpoint),
212 None => metadata,
213 }))
214}
215
216#[cfg(target_arch = "wasm32")]
220pub(crate) fn from_environment(
221 _env: &dyn Fn(&str) -> Option<String>,
222) -> Result<Arc<dyn Credentials>, Error> {
223 Err(Error::NoCredentials)
224}
225
226#[cfg(not(target_arch = "wasm32"))]
230fn federated_from(var: &dyn Fn(&str) -> Option<String>) -> Option<Federated> {
231 if var(ENV_OIDC_DISABLED).is_some_and(|v| v.eq_ignore_ascii_case("true")) {
232 return None;
233 }
234 let mut federated = if let Some(path) = var(ENV_OIDC_TOKEN_FILE) {
235 Federated::from_file(path)
236 } else if let Some(token) = var(ENV_OIDC_TOKEN) {
237 Federated::with_source(JobToken::Given {
238 token,
239 variable: Some(ENV_OIDC_TOKEN),
240 })
241 } else if let (Some(url), Some(token)) =
242 (var(ENV_GITHUB_REQUEST_URL), var(ENV_GITHUB_REQUEST_TOKEN))
243 {
244 Federated::github_actions(url, token)
245 } else {
246 return None;
247 };
248 if let Some(endpoint) = var(ENV_STS_ENDPOINT) {
249 federated = federated.with_endpoint(endpoint);
250 }
251 if let Some(audience) = var(ENV_OIDC_AUDIENCE) {
252 federated = federated.with_audience(audience);
253 }
254 Some(federated)
255}
256
257#[derive(Clone)]
260pub struct AccessToken(String);
261
262impl AccessToken {
263 pub fn new(token: impl Into<String>) -> Self {
265 Self(token.into())
266 }
267}
268
269impl fmt::Debug for AccessToken {
270 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271 f.write_str("AccessToken(..)")
272 }
273}
274
275impl Credentials for AccessToken {
276 fn token(&self) -> TokenFuture<'_> {
277 Box::pin(async move { Ok(self.0.clone()) })
278 }
279}
280
281#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
289pub struct ServiceAccount {
290 client_id: String,
291 client_secret: String,
292 token_url: String,
293 http: Option<reqwest::Client>,
294 cache: Mutex<Cached>,
295}
296
297#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
298impl ServiceAccount {
299 pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
301 Self {
302 client_id: client_id.into(),
303 client_secret: client_secret.into(),
304 token_url: TOKEN_URL.to_owned(),
305 http: None,
306 cache: Mutex::default(),
307 }
308 }
309
310 pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
312 self.token_url = url.into();
313 self
314 }
315
316 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
319 self.http = Some(http);
320 self
321 }
322}
323
324#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
325impl fmt::Debug for ServiceAccount {
326 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327 f.debug_struct("ServiceAccount")
328 .field("client_id", &self.client_id)
329 .field("token_url", &self.token_url)
330 .finish_non_exhaustive()
331 }
332}
333
334#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
335impl Credentials for ServiceAccount {
336 fn token(&self) -> TokenFuture<'_> {
337 Box::pin(async move {
338 let mut cache = self.cache.lock().await;
339 if let Some(token) = cache.valid() {
340 return Ok(token);
341 }
342 let granted = request_token(
343 self.http.as_ref(),
344 &self.token_url,
345 &[
346 ("grant_type", "client_credentials"),
347 ("client_id", &self.client_id),
348 ("client_secret", &self.client_secret),
349 ],
350 )
351 .await?;
352 cache.token = granted.access_token.clone();
353 cache.expiry = Some(granted.expiry());
354 Ok(granted.access_token)
355 })
356 }
357}
358
359#[cfg(not(target_arch = "wasm32"))]
363pub struct CliSession {
364 token_url: String,
365 http: Option<reqwest::Client>,
366 state: Mutex<SessionState>,
367}
368
369#[cfg(not(target_arch = "wasm32"))]
370struct SessionState {
371 cached: Cached,
372 refresh_token: String,
373 refresh_expiry: Option<SystemTime>,
374}
375
376#[cfg(not(target_arch = "wasm32"))]
378#[derive(Deserialize)]
379struct StoredSession {
380 #[serde(default)]
381 access_token: String,
382 #[serde(default)]
383 expires: Option<DateTime<Utc>>,
384 #[serde(default)]
385 refresh_token: String,
386 #[serde(default)]
387 refresh_expires: Option<DateTime<Utc>>,
388}
389
390#[cfg(not(target_arch = "wasm32"))]
391impl CliSession {
392 pub fn load() -> Result<Option<Self>, Error> {
396 match cli_session_path(&|name| std::env::var(name).ok()) {
397 Some(path) => Self::load_from(path),
398 None => Ok(None),
399 }
400 }
401
402 pub fn load_from(path: impl Into<PathBuf>) -> Result<Option<Self>, Error> {
404 let path = path.into();
405 let data = match std::fs::read(&path) {
406 Ok(data) => data,
407 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
408 Err(e) => return Err(Error::Credentials(format!("reading the CLI session: {e}"))),
409 };
410 let stored: StoredSession = serde_json::from_slice(&data).map_err(|e| {
411 Error::Credentials(format!("reading the CLI session {}: {e}", path.display()))
412 })?;
413 Ok(Some(Self {
414 token_url: TOKEN_URL.to_owned(),
415 http: None,
416 state: Mutex::new(SessionState {
417 cached: Cached {
418 token: stored.access_token,
419 expiry: stored.expires.map(SystemTime::from),
420 },
421 refresh_token: stored.refresh_token,
422 refresh_expiry: stored.refresh_expires.map(SystemTime::from),
423 }),
424 }))
425 }
426
427 pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
429 self.token_url = url.into();
430 self
431 }
432
433 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
436 self.http = Some(http);
437 self
438 }
439}
440
441#[cfg(not(target_arch = "wasm32"))]
442impl fmt::Debug for CliSession {
443 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444 f.debug_struct("CliSession")
445 .field("token_url", &self.token_url)
446 .finish_non_exhaustive()
447 }
448}
449
450#[cfg(not(target_arch = "wasm32"))]
451impl Credentials for CliSession {
452 fn token(&self) -> TokenFuture<'_> {
453 Box::pin(async move {
454 let mut state = self.state.lock().await;
455 if let Some(token) = state.cached.valid() {
456 return Ok(token);
457 }
458 if state
459 .refresh_expiry
460 .is_none_or(|expiry| SystemTime::now() >= expiry)
461 {
462 return Err(Error::Credentials(
463 "the CLI session has expired: log in again with `origin id login`".to_owned(),
464 ));
465 }
466 let refresh_token = state.refresh_token.clone();
467 let granted = request_token(
468 self.http.as_ref(),
469 &self.token_url,
470 &[
471 ("grant_type", "refresh_token"),
472 ("client_id", CLI_CLIENT_ID),
473 ("refresh_token", &refresh_token),
474 ],
475 )
476 .await
477 .map_err(|e| match e {
478 Error::Credentials(message) => {
479 Error::Credentials(format!("renewing the CLI session: {message}"))
480 }
481 other => other,
482 })?;
483 if !granted.refresh_token.is_empty() {
484 state.refresh_token = granted.refresh_token.clone();
485 state.refresh_expiry =
486 Some(SystemTime::now() + seconds(granted.refresh_expires_in));
487 }
488 state.cached = Cached {
489 token: granted.access_token.clone(),
490 expiry: Some(granted.expiry()),
491 };
492 Ok(granted.access_token)
493 })
494 }
495}
496
497#[cfg(not(target_arch = "wasm32"))]
504pub struct InstanceMetadata {
505 endpoint: String,
506 http: Option<reqwest::Client>,
507 state: Mutex<MetadataState>,
508}
509
510#[cfg(not(target_arch = "wasm32"))]
511#[derive(Default)]
512struct MetadataState {
513 session: Cached,
514 cached: Cached,
515}
516
517#[cfg(not(target_arch = "wasm32"))]
519#[derive(Deserialize)]
520struct Issued {
521 access_token: String,
522 expires_at: DateTime<Utc>,
523}
524
525#[cfg(not(target_arch = "wasm32"))]
526impl InstanceMetadata {
527 pub fn new() -> Self {
529 Self {
530 endpoint: METADATA_ENDPOINT.to_owned(),
531 http: None,
532 state: Mutex::default(),
533 }
534 }
535
536 pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
538 self.endpoint = url.into().trim_end_matches('/').to_owned();
539 self
540 }
541
542 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
547 self.http = Some(http);
548 self
549 }
550
551 fn http(&self) -> Result<&reqwest::Client, Error> {
552 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
553 if let Some(http) = &self.http {
554 return Ok(http);
555 }
556 CLIENT
557 .get_or_init(|| {
558 reqwest::Client::builder()
559 .no_proxy()
560 .connect_timeout(METADATA_CONNECT_TIMEOUT)
561 .build()
562 .map_err(|e| e.to_string())
563 })
564 .as_ref()
565 .map_err(|e| Error::Credentials(format!("reaching the metadata service: {e}")))
566 }
567
568 async fn open_session(&self) -> Result<String, Error> {
573 let request = self
574 .http()?
575 .put(format!("{}/latest/api/token", self.endpoint))
576 .timeout(METADATA_SESSION_TIMEOUT)
577 .header(
578 METADATA_SESSION_TTL_HEADER,
579 METADATA_SESSION_TTL.as_secs().to_string(),
580 );
581 let (status, body) = send_retrying(request, &METADATA_RETRY_DELAYS)
582 .await
583 .map_err(|_| Error::NoCredentials)?;
584 match status.as_u16() {
585 200 => Ok(String::from_utf8_lossy(&body).trim().to_owned()),
586 403 => Err(refused_through_a_proxy()),
587 503 => Err(Error::Credentials(
588 "the metadata service refused a session (HTTP 503)".to_owned(),
589 )),
590 _ => Err(Error::NoCredentials),
591 }
592 }
593
594 async fn request_credential(&self, session: &str) -> Result<Option<Issued>, Error> {
596 let failed =
597 |e: reqwest::Error| Error::Credentials(format!("reaching the metadata service: {e}"));
598 let request = self
599 .http()?
600 .get(format!(
601 "{}/latest/meta-data/service-account/token",
602 self.endpoint
603 ))
604 .timeout(METADATA_REQUEST_TIMEOUT)
605 .header(METADATA_SESSION_HEADER, session)
606 .header(reqwest::header::ACCEPT, "application/json");
607 let (status, body) = send_retrying(request, &METADATA_RETRY_DELAYS)
608 .await
609 .map_err(failed)?;
610 match status.as_u16() {
611 200 => {}
612 401 => return Ok(None),
613 404 => {
614 return Err(Error::Credentials(
615 "the instance runs as no service account".to_owned(),
616 ));
617 }
618 403 => return Err(refused_through_a_proxy()),
619 status => {
620 return Err(Error::Credentials(format!(
621 "the metadata service answered HTTP {status}"
622 )));
623 }
624 }
625 match serde_json::from_slice::<Issued>(&body) {
626 Ok(issued) if !issued.access_token.is_empty() => Ok(Some(issued)),
627 _ => Err(Error::Credentials(
628 "the metadata service's answer is not a credential".to_owned(),
629 )),
630 }
631 }
632}
633
634#[cfg(not(target_arch = "wasm32"))]
637async fn send_retrying(
638 request: reqwest::RequestBuilder,
639 delays: &[Duration],
640) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
641 let mut delays = delays.iter();
642 loop {
643 let attempt = request
644 .try_clone()
645 .expect("a credential request has no streamed body");
646 let answer = read_bounded(attempt.send().await?).await?;
647 match delays.next() {
648 Some(delay) if answer.0 == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
649 tokio::time::sleep(*delay).await;
650 }
651 _ => return Ok(answer),
652 }
653 }
654}
655
656#[cfg(not(target_arch = "wasm32"))]
657fn refused_through_a_proxy() -> Error {
658 Error::Credentials(
659 "the metadata service refused a request that came through a proxy".to_owned(),
660 )
661}
662
663#[cfg(not(target_arch = "wasm32"))]
664impl Default for InstanceMetadata {
665 fn default() -> Self {
666 Self::new()
667 }
668}
669
670#[cfg(not(target_arch = "wasm32"))]
671impl fmt::Debug for InstanceMetadata {
672 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
673 f.debug_struct("InstanceMetadata")
674 .field("endpoint", &self.endpoint)
675 .finish_non_exhaustive()
676 }
677}
678
679#[cfg(not(target_arch = "wasm32"))]
680impl Credentials for InstanceMetadata {
681 fn token(&self) -> TokenFuture<'_> {
682 Box::pin(async move {
683 let mut state = self.state.lock().await;
684 if let Some(token) = state.cached.valid_for(METADATA_EXPIRY_MARGIN) {
685 return Ok(token);
686 }
687 for _ in 0..2 {
689 let session = match state.session.valid() {
690 Some(session) => session,
691 None => {
692 let session = self.open_session().await?;
693 state.session = Cached {
694 token: session.clone(),
695 expiry: Some(SystemTime::now() + METADATA_SESSION_TTL),
696 };
697 session
698 }
699 };
700 match self.request_credential(&session).await? {
701 Some(issued) => {
702 state.cached = Cached {
703 token: issued.access_token.clone(),
704 expiry: Some(issued.expires_at.into()),
705 };
706 return Ok(issued.access_token);
707 }
708 None => state.session = Cached::default(),
709 }
710 }
711 Err(Error::Credentials(
712 "the metadata service answered HTTP 401".to_owned(),
713 ))
714 })
715 }
716}
717
718#[cfg(not(target_arch = "wasm32"))]
725pub struct Federated {
726 source: JobToken,
727 endpoint: String,
728 audience: String,
729 http: Option<reqwest::Client>,
730 cache: Mutex<Cached>,
731}
732
733#[cfg(not(target_arch = "wasm32"))]
735enum JobToken {
736 File(PathBuf),
737 Given {
740 token: String,
741 variable: Option<&'static str>,
742 },
743 GitHubActions {
744 request_url: String,
745 request_token: String,
746 },
747}
748
749#[cfg(not(target_arch = "wasm32"))]
751#[derive(Deserialize)]
752struct JobTokenClaims {
753 exp: Option<f64>,
754}
755
756#[cfg(not(target_arch = "wasm32"))]
758#[derive(Deserialize)]
759struct GitHubToken {
760 value: String,
761}
762
763#[cfg(not(target_arch = "wasm32"))]
764impl Federated {
765 pub fn from_file(path: impl Into<PathBuf>) -> Self {
769 Self::with_source(JobToken::File(path.into()))
770 }
771
772 pub fn from_token(token: impl Into<String>) -> Self {
778 Self::with_source(JobToken::Given {
779 token: token.into(),
780 variable: None,
781 })
782 }
783
784 pub fn github_actions(
789 request_url: impl Into<String>,
790 request_token: impl Into<String>,
791 ) -> Self {
792 Self::with_source(JobToken::GitHubActions {
793 request_url: request_url.into(),
794 request_token: request_token.into(),
795 })
796 }
797
798 fn with_source(source: JobToken) -> Self {
799 Self {
800 source,
801 endpoint: STS_ENDPOINT.to_owned(),
802 audience: OIDC_AUDIENCE.to_owned(),
803 http: None,
804 cache: Mutex::default(),
805 }
806 }
807
808 pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
810 self.endpoint = url.into().trim_end_matches('/').to_owned();
811 self
812 }
813
814 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
818 self.audience = audience.into();
819 self
820 }
821
822 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
825 self.http = Some(http);
826 self
827 }
828
829 fn http(&self) -> Result<&reqwest::Client, Error> {
830 match &self.http {
831 Some(http) => Ok(http),
832 None => default_http_client()
833 .map_err(|e| Error::Credentials(format!("exchanging the CI job's token: {e}"))),
834 }
835 }
836
837 async fn job_token(&self, http: &reqwest::Client) -> Result<String, Error> {
839 match &self.source {
840 JobToken::File(path) => {
841 let read = std::fs::read_to_string(path).map_err(|e| {
842 Error::Credentials(format!(
843 "reading the OIDC token file {}: {e}",
844 path.display()
845 ))
846 })?;
847 let token = read.trim();
848 if token.is_empty() {
849 return Err(Error::Credentials(format!(
850 "the OIDC token file {} is empty",
851 path.display()
852 )));
853 }
854 Ok(token.to_owned())
855 }
856 JobToken::Given { token, variable } => {
857 let token = token.trim();
858 if token.is_empty() {
859 return Err(Error::Credentials(format!(
860 "the CI job's OIDC token{} is empty",
861 in_variable(*variable)
862 )));
863 }
864 if let Some(expiry) = job_token_expiry(token)
865 && expiry <= SystemTime::now()
866 {
867 let expiry = DateTime::<Utc>::from(expiry)
868 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
869 return Err(Error::Credentials(format!(
870 "the CI job's OIDC token{} expired at {expiry}; a token given as it is \
871 can't be renewed: ask the CI provider for a longer-lived one, or use \
872 {ENV_OIDC_TOKEN_FILE}",
873 in_variable(*variable)
874 )));
875 }
876 Ok(token.to_owned())
877 }
878 JobToken::GitHubActions {
879 request_url,
880 request_token,
881 } => {
882 let failed = |reason: &dyn fmt::Display| {
883 Error::Credentials(format!(
884 "requesting the GitHub Actions job's OIDC token: {reason}; \
885 the workflow needs `permissions: id-token: write`"
886 ))
887 };
888 let separator = if request_url.contains('?') { '&' } else { '?' };
889 let audience: String =
890 url::form_urlencoded::byte_serialize(self.audience.as_bytes()).collect();
891 let response = http
892 .get(format!("{request_url}{separator}audience={audience}"))
893 .timeout(TOKEN_REQUEST_TIMEOUT)
894 .header(
895 reqwest::header::AUTHORIZATION,
896 format!("bearer {request_token}"),
897 )
898 .header(reqwest::header::ACCEPT, "application/json")
899 .send()
900 .await
901 .map_err(|e| failed(&e))?;
902 let (status, body) = read_bounded(response).await.map_err(|e| failed(&e))?;
903 if status != reqwest::StatusCode::OK {
904 return Err(failed(&format_args!("HTTP {}", status.as_u16())));
905 }
906 match serde_json::from_slice::<GitHubToken>(&body) {
907 Ok(token) if !token.value.is_empty() => Ok(token.value),
908 _ => Err(Error::Credentials(
909 "requesting the GitHub Actions job's OIDC token: the answer holds no token"
910 .to_owned(),
911 )),
912 }
913 }
914 }
915 }
916
917 async fn exchange(&self, http: &reqwest::Client, job_token: &str) -> Result<Granted, Error> {
919 let request = http
920 .post(format!("{}/v1/token", self.endpoint))
921 .timeout(TOKEN_REQUEST_TIMEOUT)
922 .header(reqwest::header::ACCEPT, "application/json")
923 .form(&[
924 ("grant_type", TOKEN_EXCHANGE_GRANT),
925 ("subject_token", job_token),
926 ("subject_token_type", JWT_TOKEN_TYPE),
927 ]);
928 let (status, body) = send_retrying(request, &STS_RETRY_DELAYS)
929 .await
930 .map_err(|e| Error::Credentials(format!("exchanging the CI job's token: {e}")))?;
931 let parsed = serde_json::from_slice::<Granted>(&body);
932 let granted = parsed.as_ref().ok();
933 let error = granted.map_or("", |g| g.error.as_str());
934 match status.as_u16() {
935 200 => match parsed {
936 Ok(granted) if !granted.access_token.is_empty() => Ok(granted),
937 _ => Err(Error::Credentials(
938 "exchanging the CI job's token: the STS's answer is not a token response"
939 .to_owned(),
940 )),
941 },
942 503 => Err(Error::Credentials(
943 "exchanging the CI job's token: the STS can't check it right now \
944 (HTTP 503, 3 attempts); try again later"
945 .to_owned(),
946 )),
947 _ if error == "invalid_grant" => Err(Error::Credentials(
948 "the STS refused the CI job's token (invalid_grant): check the federated \
949 credential's issuer, audience and conditions against the token's claims"
950 .to_owned(),
951 )),
952 status => {
953 let reason = match granted {
954 Some(g) if !g.error_description.is_empty() => &g.error_description,
955 Some(g) if !g.error.is_empty() => &g.error,
956 _ => "not a token response",
957 };
958 Err(Error::Credentials(format!(
959 "exchanging the CI job's token: the STS answered HTTP {status}: {reason}"
960 )))
961 }
962 }
963 }
964}
965
966#[cfg(not(target_arch = "wasm32"))]
967impl fmt::Debug for Federated {
968 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
969 let mut debug = f.debug_struct("Federated");
970 match &self.source {
971 JobToken::File(path) => debug.field("token_file", path),
972 JobToken::Given { .. } => debug.field("source", &"token"),
973 JobToken::GitHubActions { .. } => debug.field("source", &"GitHub Actions"),
974 };
975 debug
976 .field("endpoint", &self.endpoint)
977 .field("audience", &self.audience)
978 .finish_non_exhaustive()
979 }
980}
981
982#[cfg(not(target_arch = "wasm32"))]
983impl Credentials for Federated {
984 fn token(&self) -> TokenFuture<'_> {
985 Box::pin(async move {
986 let mut cache = self.cache.lock().await;
987 if let Some(token) = cache.valid_for(FEDERATED_EXPIRY_MARGIN) {
988 return Ok(token);
989 }
990 let http = self.http()?;
991 let job_token = self.job_token(http).await?;
992 let granted = self.exchange(http, &job_token).await?;
993 *cache = Cached {
994 token: granted.access_token.clone(),
995 expiry: Some(granted.expiry()),
996 };
997 Ok(granted.access_token)
998 })
999 }
1000}
1001
1002#[cfg(not(target_arch = "wasm32"))]
1004fn in_variable(variable: Option<&str>) -> String {
1005 variable.map_or_else(String::new, |name| format!(" in {name}"))
1006}
1007
1008#[cfg(not(target_arch = "wasm32"))]
1012fn job_token_expiry(token: &str) -> Option<SystemTime> {
1013 use base64::Engine as _;
1014 use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
1015
1016 const BASE64URL: GeneralPurpose = GeneralPurpose::new(
1017 &base64::alphabet::URL_SAFE,
1018 GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
1019 );
1020 let payload = token.split('.').nth(1)?;
1021 let claims: JobTokenClaims = serde_json::from_slice(&BASE64URL.decode(payload).ok()?).ok()?;
1022 let exp = claims.exp?;
1023 if !exp.is_finite() || exp < 0.0 {
1024 return None;
1025 }
1026 SystemTime::UNIX_EPOCH.checked_add(Duration::try_from_secs_f64(exp).ok()?)
1027}
1028
1029#[cfg(not(target_arch = "wasm32"))]
1032fn cli_session_path(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
1033 let base = match env("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
1034 Some(base) => PathBuf::from(base),
1035 None => home_dir(env)?.join(".local").join("share"),
1036 };
1037 Some(base.join("origincloud").join("session.json"))
1038}
1039
1040#[cfg(not(target_arch = "wasm32"))]
1041fn home_dir(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
1042 let name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
1043 env(name).filter(|v| !v.is_empty()).map(PathBuf::from)
1044}
1045
1046#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
1048#[derive(Default)]
1049struct Cached {
1050 token: String,
1051 expiry: Option<SystemTime>,
1052}
1053
1054#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
1055impl Cached {
1056 fn valid(&self) -> Option<String> {
1057 self.valid_for(EXPIRY_MARGIN)
1058 }
1059
1060 fn valid_for(&self, margin: Duration) -> Option<String> {
1062 let expiry = self.expiry?;
1063 (!self.token.is_empty() && now() + margin < expiry).then(|| self.token.clone())
1064 }
1065}
1066
1067#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
1069#[derive(Deserialize, Default)]
1070#[serde(default)]
1071struct Granted {
1072 access_token: String,
1073 expires_in: i64,
1074 refresh_token: String,
1075 refresh_expires_in: i64,
1076 error: String,
1077 error_description: String,
1078}
1079
1080#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
1081impl Granted {
1082 fn expiry(&self) -> SystemTime {
1083 now() + seconds(self.expires_in)
1084 }
1085}
1086
1087#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
1088fn seconds(n: i64) -> Duration {
1089 Duration::from_secs(u64::try_from(n).unwrap_or_default())
1090}
1091
1092#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
1095fn default_http_client() -> Result<&'static reqwest::Client, &'static str> {
1096 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
1097 CLIENT
1098 .get_or_init(|| {
1099 reqwest::Client::builder()
1100 .build()
1101 .map_err(|e| e.to_string())
1102 })
1103 .as_ref()
1104 .map_err(String::as_str)
1105}
1106
1107#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
1108async fn request_token(
1109 http: Option<&reqwest::Client>,
1110 url: &str,
1111 form: &[(&str, &str)],
1112) -> Result<Granted, Error> {
1113 let failed = |e: &dyn fmt::Display| Error::Credentials(format!("requesting a token: {e}"));
1114 let http = match http {
1115 Some(http) => http,
1116 None => default_http_client().map_err(|e| failed(&e))?,
1117 };
1118 let response = http
1119 .post(url)
1120 .timeout(TOKEN_REQUEST_TIMEOUT)
1121 .header(reqwest::header::ACCEPT, "application/json")
1122 .form(form)
1123 .send()
1124 .await
1125 .map_err(|e| failed(&e))?;
1126 let (status, body) = read_bounded(response).await.map_err(|e| failed(&e))?;
1127 let granted: Granted = serde_json::from_slice(&body).map_err(|_| {
1128 Error::Credentials(format!(
1129 "requesting a token: HTTP {}, not a token response",
1130 status.as_u16()
1131 ))
1132 })?;
1133 if status != reqwest::StatusCode::OK || granted.access_token.is_empty() {
1134 let reason = if granted.error_description.is_empty() {
1135 &granted.error
1136 } else {
1137 &granted.error_description
1138 };
1139 return Err(Error::Credentials(format!(
1140 "the token request was refused (HTTP {}): {reason}",
1141 status.as_u16()
1142 )));
1143 }
1144 Ok(granted)
1145}
1146
1147#[cfg(any(not(target_arch = "wasm32"), feature = "service-account-wasm"))]
1150async fn read_bounded(
1151 response: reqwest::Response,
1152) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
1153 let status = response.status();
1154 let (body, _truncated) = read_capped(response, MAX_TOKEN_RESPONSE_BYTES).await?;
1155 Ok((status, body))
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::*;
1161 use std::collections::HashMap;
1162
1163 fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1164 let vars: HashMap<String, String> = vars
1165 .iter()
1166 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1167 .collect();
1168 move |name| vars.get(name).cloned()
1169 }
1170
1171 fn now() -> DateTime<Utc> {
1172 SystemTime::now().into()
1173 }
1174
1175 fn data_home() -> PathBuf {
1176 let dir =
1177 std::env::temp_dir().join(format!("origincloud-sdk-test-{}", uuid::Uuid::new_v4()));
1178 std::fs::create_dir_all(dir.join("origincloud")).unwrap();
1179 dir
1180 }
1181
1182 fn write_session(
1183 dir: &std::path::Path,
1184 expires: DateTime<Utc>,
1185 refresh_expires: DateTime<Utc>,
1186 ) -> PathBuf {
1187 let path = dir.join("origincloud").join("session.json");
1188 let data = serde_json::json!({
1189 "version": 1,
1190 "access_token": "stored-access",
1191 "expires": expires.to_rfc3339(),
1192 "refresh_token": "stored-refresh",
1193 "refresh_expires": refresh_expires.to_rfc3339(),
1194 "token_type": "Bearer",
1195 });
1196 std::fs::write(&path, data.to_string()).unwrap();
1197 path
1198 }
1199
1200 #[tokio::test]
1201 async fn an_access_token_in_the_environment_comes_first() {
1202 let credentials = from_environment(&env(&[
1203 (ENV_ACCESS_TOKEN, "from-env"),
1204 (ENV_CLIENT_ID, "id"),
1205 (ENV_CLIENT_SECRET, "secret"),
1206 ]))
1207 .unwrap();
1208 assert_eq!(credentials.token().await.unwrap(), "from-env");
1209 }
1210
1211 #[test]
1212 fn a_service_account_in_the_environment_comes_before_the_cli_session() {
1213 let dir = data_home();
1214 let hour = chrono::Duration::hours(1);
1215 write_session(&dir, now() + hour, now() + hour);
1216 let credentials = from_environment(&env(&[
1217 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1218 (ENV_CLIENT_ID, "id"),
1219 (ENV_CLIENT_SECRET, "secret"),
1220 ]))
1221 .unwrap();
1222 assert!(
1223 format!("{credentials:?}").starts_with("ServiceAccount"),
1224 "{credentials:?}"
1225 );
1226 }
1227
1228 const GITHUB: [(&str, &str); 2] = [
1229 (
1230 ENV_GITHUB_REQUEST_URL,
1231 "https://github.invalid/idtoken?api-version=2.0",
1232 ),
1233 (ENV_GITHUB_REQUEST_TOKEN, "github-request-token"),
1234 ];
1235
1236 fn with<'a>(
1237 base: &[(&'a str, &'a str)],
1238 more: &[(&'a str, &'a str)],
1239 ) -> Vec<(&'a str, &'a str)> {
1240 base.iter().chain(more).copied().collect()
1241 }
1242
1243 #[test]
1244 fn a_service_account_comes_before_a_ci_job_s_token() {
1245 let credentials = from_environment(&env(&with(
1246 &GITHUB,
1247 &[(ENV_CLIENT_ID, "id"), (ENV_CLIENT_SECRET, "secret")],
1248 )))
1249 .unwrap();
1250 assert!(
1251 format!("{credentials:?}").starts_with("ServiceAccount"),
1252 "{credentials:?}"
1253 );
1254 }
1255
1256 #[test]
1257 fn a_ci_job_s_token_comes_before_the_cli_session() {
1258 let dir = data_home();
1259 let hour = chrono::Duration::hours(1);
1260 write_session(&dir, now() + hour, now() + hour);
1261 let credentials = from_environment(&env(&with(
1262 &GITHUB,
1263 &[("XDG_DATA_HOME", dir.to_str().unwrap())],
1264 )))
1265 .unwrap();
1266 let debug = format!("{credentials:?}");
1267 assert!(debug.starts_with("Federated"), "{debug}");
1268 assert!(debug.contains("GitHub Actions"), "{debug}");
1269 assert!(debug.contains(&format!("{STS_ENDPOINT:?}")), "{debug}");
1270 assert!(!debug.contains("github-request-token"), "{debug}");
1271 assert!(!debug.contains("github.invalid"), "{debug}");
1272 }
1273
1274 #[test]
1275 fn a_token_file_comes_before_github_actions() {
1276 let credentials = from_environment(&env(&with(
1277 &GITHUB,
1278 &[(ENV_OIDC_TOKEN_FILE, "/var/run/secrets/token")],
1279 )))
1280 .unwrap();
1281 let debug = format!("{credentials:?}");
1282 assert!(
1283 debug.starts_with(r#"Federated { token_file: "/var/run/secrets/token""#),
1284 "{debug}"
1285 );
1286 }
1287
1288 #[test]
1289 fn a_token_in_the_environment_is_exchanged() {
1290 let credentials = from_environment(&env(&[(ENV_OIDC_TOKEN, "gitlab-job-token")])).unwrap();
1291 let debug = format!("{credentials:?}");
1292 assert!(
1293 debug.starts_with(r#"Federated { source: "token""#),
1294 "{debug}"
1295 );
1296 assert!(!debug.contains("gitlab-job-token"), "{debug}");
1297 }
1298
1299 #[test]
1300 fn a_token_file_comes_before_a_token_in_the_environment() {
1301 let credentials = from_environment(&env(&[
1302 (ENV_OIDC_TOKEN, "gitlab-job-token"),
1303 (ENV_OIDC_TOKEN_FILE, "/var/run/secrets/token"),
1304 ]))
1305 .unwrap();
1306 let debug = format!("{credentials:?}");
1307 assert!(
1308 debug.starts_with(r#"Federated { token_file: "/var/run/secrets/token""#),
1309 "{debug}"
1310 );
1311 }
1312
1313 #[test]
1314 fn a_token_in_the_environment_comes_before_github_actions() {
1315 let credentials = from_environment(&env(&with(
1316 &GITHUB,
1317 &[(ENV_OIDC_TOKEN, "gitlab-job-token")],
1318 )))
1319 .unwrap();
1320 let debug = format!("{credentials:?}");
1321 assert!(
1322 debug.starts_with(r#"Federated { source: "token""#),
1323 "{debug}"
1324 );
1325 }
1326
1327 #[test]
1328 fn a_token_in_the_environment_is_left_out_with_the_federated_step() {
1329 let dir = data_home();
1330 let credentials = from_environment(&env(&[
1331 (ENV_OIDC_TOKEN, "gitlab-job-token"),
1332 (ENV_OIDC_DISABLED, "true"),
1333 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1334 ]))
1335 .unwrap();
1336 assert!(
1337 format!("{credentials:?}").starts_with("InstanceMetadata"),
1338 "{credentials:?}"
1339 );
1340 }
1341
1342 fn jwt(claims: &str) -> String {
1344 use base64::Engine as _;
1345 let encode = |part: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(part);
1346 format!(
1347 "{}.{}.",
1348 encode(r#"{"alg":"none","typ":"JWT"}"#),
1349 encode(claims)
1350 )
1351 }
1352
1353 #[tokio::test]
1354 async fn an_expired_token_in_the_environment_names_the_variable() {
1355 let token = jwt(r#"{"sub":"project_path:group/project","exp":1577836800}"#);
1356 let credentials = from_environment(&env(&[
1357 (ENV_OIDC_TOKEN, &token),
1358 (ENV_STS_ENDPOINT, "http://127.0.0.1:9"),
1359 ]))
1360 .unwrap();
1361 let message = credentials.token().await.unwrap_err().to_string();
1362 assert!(
1363 message.contains(
1364 "the CI job's OIDC token in ORIGINCLOUD_OIDC_TOKEN expired at 2020-01-01T00:00:00Z"
1365 ),
1366 "{message}"
1367 );
1368 assert!(message.contains("ORIGINCLOUD_OIDC_TOKEN_FILE"), "{message}");
1369 assert!(!message.contains(&token), "{message}");
1370 }
1371
1372 #[test]
1373 fn a_job_token_s_expiry_is_read_from_its_claims() {
1374 let at = |secs| Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs));
1375 assert_eq!(
1376 job_token_expiry(&jwt(r#"{"exp":1577836800}"#)),
1377 at(1577836800)
1378 );
1379 assert_eq!(
1380 job_token_expiry(&jwt(r#"{"exp":1577836800.5}"#)),
1381 at(1577836800).map(|t| t + Duration::from_millis(500))
1382 );
1383 let padded = {
1385 use base64::Engine as _;
1386 let claims = base64::engine::general_purpose::URL_SAFE.encode(r#"{"exp":12}"#);
1387 assert!(claims.ends_with("=="), "{claims}");
1388 format!("e30.{claims}.")
1389 };
1390 assert_eq!(job_token_expiry(&padded), at(12));
1391 for unreadable in [
1392 jwt(r#"{"sub":"no expiry"}"#),
1393 jwt(r#"{"exp":"tomorrow"}"#),
1394 jwt(r#"{"exp":-1}"#),
1395 jwt("not json"),
1396 "not-a-jwt".to_owned(),
1397 "a.%%%.c".to_owned(),
1398 ] {
1399 assert_eq!(job_token_expiry(&unreadable), None, "{unreadable}");
1400 }
1401 }
1402
1403 #[test]
1404 fn github_actions_needs_both_its_variables() {
1405 let dir = data_home();
1406 for half in GITHUB {
1407 let credentials =
1408 from_environment(&env(&[half, ("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
1409 assert!(
1410 format!("{credentials:?}").starts_with("InstanceMetadata"),
1411 "{credentials:?}"
1412 );
1413 }
1414 }
1415
1416 #[test]
1417 fn the_federated_step_can_be_left_out() {
1418 let dir = data_home();
1419 let credentials = from_environment(&env(&with(
1420 &GITHUB,
1421 &[
1422 (ENV_OIDC_TOKEN_FILE, "/var/run/secrets/token"),
1423 (ENV_OIDC_DISABLED, "True"),
1424 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1425 ],
1426 )))
1427 .unwrap();
1428 assert!(
1429 format!("{credentials:?}").starts_with("InstanceMetadata"),
1430 "{credentials:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn the_sts_and_the_audience_can_be_named() {
1436 let credentials = from_environment(&env(&with(
1437 &GITHUB,
1438 &[
1439 (ENV_STS_ENDPOINT, "http://127.0.0.1:8080/"),
1440 (ENV_OIDC_AUDIENCE, "https://sts.example"),
1441 ],
1442 )))
1443 .unwrap();
1444 let debug = format!("{credentials:?}");
1445 assert!(
1446 debug.contains(r#"endpoint: "http://127.0.0.1:8080", audience: "https://sts.example""#),
1447 "{debug}"
1448 );
1449 }
1450
1451 #[test]
1452 fn half_a_service_account_is_refused() {
1453 let err = from_environment(&env(&[(ENV_CLIENT_ID, "id")])).unwrap_err();
1454 assert!(err.to_string().contains("must be set together"), "{err}");
1455 let err = from_environment(&env(&[(ENV_CLIENT_SECRET, "secret")])).unwrap_err();
1456 assert!(err.to_string().contains("must be set together"), "{err}");
1457 }
1458
1459 #[test]
1460 fn without_others_the_metadata_service_comes_last() {
1461 let dir = data_home();
1462 let credentials =
1463 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
1464 assert!(
1465 format!("{credentials:?}").starts_with("InstanceMetadata"),
1466 "{credentials:?}"
1467 );
1468 }
1469
1470 #[test]
1471 fn the_metadata_service_s_address_can_be_named() {
1472 let dir = data_home();
1473 let credentials = from_environment(&env(&[
1474 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1475 (ENV_METADATA_ENDPOINT, "http://127.0.0.1:8080/"),
1476 ]))
1477 .unwrap();
1478 assert!(
1479 format!("{credentials:?}").contains(r#"endpoint: "http://127.0.0.1:8080""#),
1480 "{credentials:?}"
1481 );
1482 }
1483
1484 #[test]
1485 fn no_credentials_at_all_without_the_metadata_service() {
1486 let dir = data_home();
1487 let err = from_environment(&env(&[
1488 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1489 (ENV_METADATA_DISABLED, "TRUE"),
1490 ]))
1491 .unwrap_err();
1492 assert!(matches!(err, Error::NoCredentials), "{err}");
1493 }
1494
1495 #[test]
1496 fn the_session_is_found_under_the_home_directory_without_xdg_data_home() {
1497 let path = cli_session_path(&env(&[
1498 ("HOME", "/home/someone"),
1499 ("USERPROFILE", "/home/someone"),
1500 ]))
1501 .unwrap();
1502 assert_eq!(
1503 path,
1504 PathBuf::from("/home/someone/.local/share/origincloud/session.json")
1505 );
1506 }
1507
1508 #[tokio::test]
1509 async fn the_cli_session_is_used_as_stored() {
1510 let dir = data_home();
1511 write_session(
1512 &dir,
1513 now() + chrono::Duration::hours(1),
1514 now() + chrono::Duration::hours(2),
1515 );
1516 let credentials =
1517 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
1518 assert_eq!(credentials.token().await.unwrap(), "stored-access");
1519 }
1520
1521 #[tokio::test]
1522 async fn a_cli_session_past_its_refresh_expiry_asks_to_log_in() {
1523 let dir = data_home();
1524 let path = write_session(
1525 &dir,
1526 now() - chrono::Duration::hours(1),
1527 now() - chrono::Duration::minutes(1),
1528 );
1529 let session = CliSession::load_from(path).unwrap().unwrap();
1530 let err = session.token().await.unwrap_err();
1531 assert!(err.to_string().contains("origin id login"), "{err}");
1532 }
1533}