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";
26const ENV_OIDC_DISABLED: &str = "ORIGINCLOUD_OIDC_DISABLED";
27const ENV_OIDC_TOKEN_FILE: &str = "ORIGINCLOUD_OIDC_TOKEN_FILE";
28const ENV_OIDC_AUDIENCE: &str = "ORIGINCLOUD_OIDC_AUDIENCE";
29const ENV_STS_ENDPOINT: &str = "ORIGINCLOUD_STS_ENDPOINT";
30const ENV_GITHUB_REQUEST_URL: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
31const ENV_GITHUB_REQUEST_TOKEN: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
32
33pub const STS_ENDPOINT: &str = "https://sts.origincloudapis.eu";
36
37pub const OIDC_AUDIENCE: &str = "https://sts.origincloudapis.eu";
39
40const FEDERATED_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
42
43const STS_RETRY_DELAYS: [Duration; 2] = [Duration::from_millis(200), Duration::from_millis(400)];
46
47const TOKEN_EXCHANGE_GRANT: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
48const JWT_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:jwt";
49
50pub const METADATA_ENDPOINT: &str = "http://169.254.169.254";
53
54const METADATA_SESSION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
56const METADATA_SESSION_TTL_HEADER: &str = "x-origincloud-metadata-token-ttl-seconds";
57const METADATA_SESSION_HEADER: &str = "x-origincloud-metadata-token";
58
59const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
62const METADATA_SESSION_TIMEOUT: Duration = Duration::from_secs(2);
63
64const METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
66
67const METADATA_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
70
71const METADATA_RETRY_DELAYS: [Duration; 2] =
74 [Duration::from_millis(100), Duration::from_millis(200)];
75
76const CLI_CLIENT_ID: &str = "origin-cli";
78
79const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
82
83const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
85
86const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
88
89pub type TokenFuture<'a> = Pin<Box<dyn Future<Output = Result<String, Error>> + Send + 'a>>;
91
92pub trait Credentials: Send + Sync + fmt::Debug {
94 fn token(&self) -> TokenFuture<'_>;
96}
97
98pub fn default_credentials() -> Result<Arc<dyn Credentials>, Error> {
124 from_environment(&|name| std::env::var(name).ok())
125}
126
127pub(crate) fn from_environment(
129 env: &dyn Fn(&str) -> Option<String>,
130) -> Result<Arc<dyn Credentials>, Error> {
131 let var = |name: &str| env(name).filter(|v| !v.is_empty());
132 if let Some(token) = var(ENV_ACCESS_TOKEN) {
133 return Ok(Arc::new(AccessToken::new(token)));
134 }
135 match (var(ENV_CLIENT_ID), var(ENV_CLIENT_SECRET)) {
136 (Some(id), Some(secret)) => return Ok(Arc::new(ServiceAccount::new(id, secret))),
137 (None, None) => {}
138 _ => {
139 return Err(Error::Config(format!(
140 "{ENV_CLIENT_ID} and {ENV_CLIENT_SECRET} must be set together"
141 )));
142 }
143 }
144 if let Some(federated) = federated_from(&var) {
145 return Ok(Arc::new(federated));
146 }
147 if let Some(path) = cli_session_path(env)
148 && let Some(session) = CliSession::load_from(path)?
149 {
150 return Ok(Arc::new(session));
151 }
152 if var(ENV_METADATA_DISABLED).is_some_and(|v| v.eq_ignore_ascii_case("true")) {
153 return Err(Error::NoCredentials);
154 }
155 let metadata = InstanceMetadata::new();
156 Ok(Arc::new(match var(ENV_METADATA_ENDPOINT) {
157 Some(endpoint) => metadata.with_endpoint(endpoint),
158 None => metadata,
159 }))
160}
161
162fn federated_from(var: &dyn Fn(&str) -> Option<String>) -> Option<Federated> {
165 if var(ENV_OIDC_DISABLED).is_some_and(|v| v.eq_ignore_ascii_case("true")) {
166 return None;
167 }
168 let mut federated = match (
169 var(ENV_OIDC_TOKEN_FILE),
170 var(ENV_GITHUB_REQUEST_URL),
171 var(ENV_GITHUB_REQUEST_TOKEN),
172 ) {
173 (Some(path), _, _) => Federated::from_file(path),
174 (None, Some(url), Some(token)) => Federated::github_actions(url, token),
175 _ => return None,
176 };
177 if let Some(endpoint) = var(ENV_STS_ENDPOINT) {
178 federated = federated.with_endpoint(endpoint);
179 }
180 if let Some(audience) = var(ENV_OIDC_AUDIENCE) {
181 federated = federated.with_audience(audience);
182 }
183 Some(federated)
184}
185
186#[derive(Clone)]
189pub struct AccessToken(String);
190
191impl AccessToken {
192 pub fn new(token: impl Into<String>) -> Self {
194 Self(token.into())
195 }
196}
197
198impl fmt::Debug for AccessToken {
199 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200 f.write_str("AccessToken(..)")
201 }
202}
203
204impl Credentials for AccessToken {
205 fn token(&self) -> TokenFuture<'_> {
206 Box::pin(async move { Ok(self.0.clone()) })
207 }
208}
209
210pub struct ServiceAccount {
213 client_id: String,
214 client_secret: String,
215 token_url: String,
216 http: Option<reqwest::Client>,
217 cache: Mutex<Cached>,
218}
219
220impl ServiceAccount {
221 pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
223 Self {
224 client_id: client_id.into(),
225 client_secret: client_secret.into(),
226 token_url: TOKEN_URL.to_owned(),
227 http: None,
228 cache: Mutex::default(),
229 }
230 }
231
232 pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
234 self.token_url = url.into();
235 self
236 }
237
238 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
241 self.http = Some(http);
242 self
243 }
244}
245
246impl fmt::Debug for ServiceAccount {
247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248 f.debug_struct("ServiceAccount")
249 .field("client_id", &self.client_id)
250 .field("token_url", &self.token_url)
251 .finish_non_exhaustive()
252 }
253}
254
255impl Credentials for ServiceAccount {
256 fn token(&self) -> TokenFuture<'_> {
257 Box::pin(async move {
258 let mut cache = self.cache.lock().await;
259 if let Some(token) = cache.valid() {
260 return Ok(token);
261 }
262 let granted = request_token(
263 self.http.as_ref(),
264 &self.token_url,
265 &[
266 ("grant_type", "client_credentials"),
267 ("client_id", &self.client_id),
268 ("client_secret", &self.client_secret),
269 ],
270 )
271 .await?;
272 cache.token = granted.access_token.clone();
273 cache.expiry = Some(granted.expiry());
274 Ok(granted.access_token)
275 })
276 }
277}
278
279pub struct CliSession {
283 token_url: String,
284 http: Option<reqwest::Client>,
285 state: Mutex<SessionState>,
286}
287
288struct SessionState {
289 cached: Cached,
290 refresh_token: String,
291 refresh_expiry: Option<SystemTime>,
292}
293
294#[derive(Deserialize)]
296struct StoredSession {
297 #[serde(default)]
298 access_token: String,
299 #[serde(default)]
300 expires: Option<DateTime<Utc>>,
301 #[serde(default)]
302 refresh_token: String,
303 #[serde(default)]
304 refresh_expires: Option<DateTime<Utc>>,
305}
306
307impl CliSession {
308 pub fn load() -> Result<Option<Self>, Error> {
312 match cli_session_path(&|name| std::env::var(name).ok()) {
313 Some(path) => Self::load_from(path),
314 None => Ok(None),
315 }
316 }
317
318 pub fn load_from(path: impl Into<PathBuf>) -> Result<Option<Self>, Error> {
320 let path = path.into();
321 let data = match std::fs::read(&path) {
322 Ok(data) => data,
323 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
324 Err(e) => return Err(Error::Credentials(format!("reading the CLI session: {e}"))),
325 };
326 let stored: StoredSession = serde_json::from_slice(&data).map_err(|e| {
327 Error::Credentials(format!("reading the CLI session {}: {e}", path.display()))
328 })?;
329 Ok(Some(Self {
330 token_url: TOKEN_URL.to_owned(),
331 http: None,
332 state: Mutex::new(SessionState {
333 cached: Cached {
334 token: stored.access_token,
335 expiry: stored.expires.map(SystemTime::from),
336 },
337 refresh_token: stored.refresh_token,
338 refresh_expiry: stored.refresh_expires.map(SystemTime::from),
339 }),
340 }))
341 }
342
343 pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
345 self.token_url = url.into();
346 self
347 }
348
349 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
352 self.http = Some(http);
353 self
354 }
355}
356
357impl fmt::Debug for CliSession {
358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359 f.debug_struct("CliSession")
360 .field("token_url", &self.token_url)
361 .finish_non_exhaustive()
362 }
363}
364
365impl Credentials for CliSession {
366 fn token(&self) -> TokenFuture<'_> {
367 Box::pin(async move {
368 let mut state = self.state.lock().await;
369 if let Some(token) = state.cached.valid() {
370 return Ok(token);
371 }
372 if state
373 .refresh_expiry
374 .is_none_or(|expiry| SystemTime::now() >= expiry)
375 {
376 return Err(Error::Credentials(
377 "the CLI session has expired: log in again with `origin id login`".to_owned(),
378 ));
379 }
380 let refresh_token = state.refresh_token.clone();
381 let granted = request_token(
382 self.http.as_ref(),
383 &self.token_url,
384 &[
385 ("grant_type", "refresh_token"),
386 ("client_id", CLI_CLIENT_ID),
387 ("refresh_token", &refresh_token),
388 ],
389 )
390 .await
391 .map_err(|e| match e {
392 Error::Credentials(message) => {
393 Error::Credentials(format!("renewing the CLI session: {message}"))
394 }
395 other => other,
396 })?;
397 if !granted.refresh_token.is_empty() {
398 state.refresh_token = granted.refresh_token.clone();
399 state.refresh_expiry =
400 Some(SystemTime::now() + seconds(granted.refresh_expires_in));
401 }
402 state.cached = Cached {
403 token: granted.access_token.clone(),
404 expiry: Some(granted.expiry()),
405 };
406 Ok(granted.access_token)
407 })
408 }
409}
410
411pub struct InstanceMetadata {
418 endpoint: String,
419 http: Option<reqwest::Client>,
420 state: Mutex<MetadataState>,
421}
422
423#[derive(Default)]
424struct MetadataState {
425 session: Cached,
426 cached: Cached,
427}
428
429#[derive(Deserialize)]
431struct Issued {
432 access_token: String,
433 expires_at: DateTime<Utc>,
434}
435
436impl InstanceMetadata {
437 pub fn new() -> Self {
439 Self {
440 endpoint: METADATA_ENDPOINT.to_owned(),
441 http: None,
442 state: Mutex::default(),
443 }
444 }
445
446 pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
448 self.endpoint = url.into().trim_end_matches('/').to_owned();
449 self
450 }
451
452 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
457 self.http = Some(http);
458 self
459 }
460
461 fn http(&self) -> Result<&reqwest::Client, Error> {
462 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
463 if let Some(http) = &self.http {
464 return Ok(http);
465 }
466 CLIENT
467 .get_or_init(|| {
468 reqwest::Client::builder()
469 .no_proxy()
470 .connect_timeout(METADATA_CONNECT_TIMEOUT)
471 .build()
472 .map_err(|e| e.to_string())
473 })
474 .as_ref()
475 .map_err(|e| Error::Credentials(format!("reaching the metadata service: {e}")))
476 }
477
478 async fn open_session(&self) -> Result<String, Error> {
483 let request = self
484 .http()?
485 .put(format!("{}/latest/api/token", self.endpoint))
486 .timeout(METADATA_SESSION_TIMEOUT)
487 .header(
488 METADATA_SESSION_TTL_HEADER,
489 METADATA_SESSION_TTL.as_secs().to_string(),
490 );
491 let (status, body) = send_retrying(request, &METADATA_RETRY_DELAYS)
492 .await
493 .map_err(|_| Error::NoCredentials)?;
494 match status.as_u16() {
495 200 => Ok(String::from_utf8_lossy(&body).trim().to_owned()),
496 403 => Err(refused_through_a_proxy()),
497 503 => Err(Error::Credentials(
498 "the metadata service refused a session (HTTP 503)".to_owned(),
499 )),
500 _ => Err(Error::NoCredentials),
501 }
502 }
503
504 async fn request_credential(&self, session: &str) -> Result<Option<Issued>, Error> {
506 let failed =
507 |e: reqwest::Error| Error::Credentials(format!("reaching the metadata service: {e}"));
508 let request = self
509 .http()?
510 .get(format!(
511 "{}/latest/meta-data/service-account/token",
512 self.endpoint
513 ))
514 .timeout(METADATA_REQUEST_TIMEOUT)
515 .header(METADATA_SESSION_HEADER, session)
516 .header(reqwest::header::ACCEPT, "application/json");
517 let (status, body) = send_retrying(request, &METADATA_RETRY_DELAYS)
518 .await
519 .map_err(failed)?;
520 match status.as_u16() {
521 200 => {}
522 401 => return Ok(None),
523 404 => {
524 return Err(Error::Credentials(
525 "the instance runs as no service account".to_owned(),
526 ));
527 }
528 403 => return Err(refused_through_a_proxy()),
529 status => {
530 return Err(Error::Credentials(format!(
531 "the metadata service answered HTTP {status}"
532 )));
533 }
534 }
535 match serde_json::from_slice::<Issued>(&body) {
536 Ok(issued) if !issued.access_token.is_empty() => Ok(Some(issued)),
537 _ => Err(Error::Credentials(
538 "the metadata service's answer is not a credential".to_owned(),
539 )),
540 }
541 }
542}
543
544async fn send_retrying(
547 request: reqwest::RequestBuilder,
548 delays: &[Duration],
549) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
550 let mut delays = delays.iter();
551 loop {
552 let attempt = request
553 .try_clone()
554 .expect("a credential request has no streamed body");
555 let answer = read_bounded(attempt.send().await?).await?;
556 match delays.next() {
557 Some(delay) if answer.0 == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
558 tokio::time::sleep(*delay).await;
559 }
560 _ => return Ok(answer),
561 }
562 }
563}
564
565fn refused_through_a_proxy() -> Error {
566 Error::Credentials(
567 "the metadata service refused a request that came through a proxy".to_owned(),
568 )
569}
570
571impl Default for InstanceMetadata {
572 fn default() -> Self {
573 Self::new()
574 }
575}
576
577impl fmt::Debug for InstanceMetadata {
578 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579 f.debug_struct("InstanceMetadata")
580 .field("endpoint", &self.endpoint)
581 .finish_non_exhaustive()
582 }
583}
584
585impl Credentials for InstanceMetadata {
586 fn token(&self) -> TokenFuture<'_> {
587 Box::pin(async move {
588 let mut state = self.state.lock().await;
589 if let Some(token) = state.cached.valid_for(METADATA_EXPIRY_MARGIN) {
590 return Ok(token);
591 }
592 for _ in 0..2 {
594 let session = match state.session.valid() {
595 Some(session) => session,
596 None => {
597 let session = self.open_session().await?;
598 state.session = Cached {
599 token: session.clone(),
600 expiry: Some(SystemTime::now() + METADATA_SESSION_TTL),
601 };
602 session
603 }
604 };
605 match self.request_credential(&session).await? {
606 Some(issued) => {
607 state.cached = Cached {
608 token: issued.access_token.clone(),
609 expiry: Some(issued.expires_at.into()),
610 };
611 return Ok(issued.access_token);
612 }
613 None => state.session = Cached::default(),
614 }
615 }
616 Err(Error::Credentials(
617 "the metadata service answered HTTP 401".to_owned(),
618 ))
619 })
620 }
621}
622
623pub struct Federated {
629 source: JobToken,
630 endpoint: String,
631 audience: String,
632 http: Option<reqwest::Client>,
633 cache: Mutex<Cached>,
634}
635
636enum JobToken {
638 File(PathBuf),
639 GitHubActions {
640 request_url: String,
641 request_token: String,
642 },
643}
644
645#[derive(Deserialize)]
647struct GitHubToken {
648 value: String,
649}
650
651impl Federated {
652 pub fn from_file(path: impl Into<PathBuf>) -> Self {
656 Self::with_source(JobToken::File(path.into()))
657 }
658
659 pub fn github_actions(
664 request_url: impl Into<String>,
665 request_token: impl Into<String>,
666 ) -> Self {
667 Self::with_source(JobToken::GitHubActions {
668 request_url: request_url.into(),
669 request_token: request_token.into(),
670 })
671 }
672
673 fn with_source(source: JobToken) -> Self {
674 Self {
675 source,
676 endpoint: STS_ENDPOINT.to_owned(),
677 audience: OIDC_AUDIENCE.to_owned(),
678 http: None,
679 cache: Mutex::default(),
680 }
681 }
682
683 pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
685 self.endpoint = url.into().trim_end_matches('/').to_owned();
686 self
687 }
688
689 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
692 self.audience = audience.into();
693 self
694 }
695
696 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
699 self.http = Some(http);
700 self
701 }
702
703 fn http(&self) -> Result<&reqwest::Client, Error> {
704 match &self.http {
705 Some(http) => Ok(http),
706 None => default_http_client()
707 .map_err(|e| Error::Credentials(format!("exchanging the CI job's token: {e}"))),
708 }
709 }
710
711 async fn job_token(&self, http: &reqwest::Client) -> Result<String, Error> {
713 match &self.source {
714 JobToken::File(path) => {
715 let read = std::fs::read_to_string(path).map_err(|e| {
716 Error::Credentials(format!(
717 "reading the OIDC token file {}: {e}",
718 path.display()
719 ))
720 })?;
721 let token = read.trim();
722 if token.is_empty() {
723 return Err(Error::Credentials(format!(
724 "the OIDC token file {} is empty",
725 path.display()
726 )));
727 }
728 Ok(token.to_owned())
729 }
730 JobToken::GitHubActions {
731 request_url,
732 request_token,
733 } => {
734 let failed = |reason: &dyn fmt::Display| {
735 Error::Credentials(format!(
736 "requesting the GitHub Actions job's OIDC token: {reason}; \
737 the workflow needs `permissions: id-token: write`"
738 ))
739 };
740 let separator = if request_url.contains('?') { '&' } else { '?' };
741 let audience: String =
742 url::form_urlencoded::byte_serialize(self.audience.as_bytes()).collect();
743 let response = http
744 .get(format!("{request_url}{separator}audience={audience}"))
745 .timeout(TOKEN_REQUEST_TIMEOUT)
746 .header(
747 reqwest::header::AUTHORIZATION,
748 format!("bearer {request_token}"),
749 )
750 .header(reqwest::header::ACCEPT, "application/json")
751 .send()
752 .await
753 .map_err(|e| failed(&e))?;
754 let (status, body) = read_bounded(response).await.map_err(|e| failed(&e))?;
755 if status != reqwest::StatusCode::OK {
756 return Err(failed(&format_args!("HTTP {}", status.as_u16())));
757 }
758 match serde_json::from_slice::<GitHubToken>(&body) {
759 Ok(token) if !token.value.is_empty() => Ok(token.value),
760 _ => Err(Error::Credentials(
761 "requesting the GitHub Actions job's OIDC token: the answer holds no token"
762 .to_owned(),
763 )),
764 }
765 }
766 }
767 }
768
769 async fn exchange(&self, http: &reqwest::Client, job_token: &str) -> Result<Granted, Error> {
771 let request = http
772 .post(format!("{}/v1/token", self.endpoint))
773 .timeout(TOKEN_REQUEST_TIMEOUT)
774 .header(reqwest::header::ACCEPT, "application/json")
775 .form(&[
776 ("grant_type", TOKEN_EXCHANGE_GRANT),
777 ("subject_token", job_token),
778 ("subject_token_type", JWT_TOKEN_TYPE),
779 ]);
780 let (status, body) = send_retrying(request, &STS_RETRY_DELAYS)
781 .await
782 .map_err(|e| Error::Credentials(format!("exchanging the CI job's token: {e}")))?;
783 let parsed = serde_json::from_slice::<Granted>(&body);
784 let granted = parsed.as_ref().ok();
785 let error = granted.map_or("", |g| g.error.as_str());
786 match status.as_u16() {
787 200 => match parsed {
788 Ok(granted) if !granted.access_token.is_empty() => Ok(granted),
789 _ => Err(Error::Credentials(
790 "exchanging the CI job's token: the STS's answer is not a token response"
791 .to_owned(),
792 )),
793 },
794 503 => Err(Error::Credentials(
795 "exchanging the CI job's token: the STS can't check it right now \
796 (HTTP 503, 3 attempts); try again later"
797 .to_owned(),
798 )),
799 _ if error == "invalid_grant" => Err(Error::Credentials(
800 "the STS refused the CI job's token (invalid_grant): check the federated \
801 credential's issuer, audience and conditions against the token's claims"
802 .to_owned(),
803 )),
804 status => {
805 let reason = match granted {
806 Some(g) if !g.error_description.is_empty() => &g.error_description,
807 Some(g) if !g.error.is_empty() => &g.error,
808 _ => "not a token response",
809 };
810 Err(Error::Credentials(format!(
811 "exchanging the CI job's token: the STS answered HTTP {status}: {reason}"
812 )))
813 }
814 }
815 }
816}
817
818impl fmt::Debug for Federated {
819 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
820 let mut debug = f.debug_struct("Federated");
821 match &self.source {
822 JobToken::File(path) => debug.field("token_file", path),
823 JobToken::GitHubActions { .. } => debug.field("source", &"GitHub Actions"),
824 };
825 debug
826 .field("endpoint", &self.endpoint)
827 .field("audience", &self.audience)
828 .finish_non_exhaustive()
829 }
830}
831
832impl Credentials for Federated {
833 fn token(&self) -> TokenFuture<'_> {
834 Box::pin(async move {
835 let mut cache = self.cache.lock().await;
836 if let Some(token) = cache.valid_for(FEDERATED_EXPIRY_MARGIN) {
837 return Ok(token);
838 }
839 let http = self.http()?;
840 let job_token = self.job_token(http).await?;
841 let granted = self.exchange(http, &job_token).await?;
842 *cache = Cached {
843 token: granted.access_token.clone(),
844 expiry: Some(granted.expiry()),
845 };
846 Ok(granted.access_token)
847 })
848 }
849}
850
851fn cli_session_path(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
854 let base = match env("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
855 Some(base) => PathBuf::from(base),
856 None => home_dir(env)?.join(".local").join("share"),
857 };
858 Some(base.join("origincloud").join("session.json"))
859}
860
861fn home_dir(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
862 let name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
863 env(name).filter(|v| !v.is_empty()).map(PathBuf::from)
864}
865
866#[derive(Default)]
868struct Cached {
869 token: String,
870 expiry: Option<SystemTime>,
871}
872
873impl Cached {
874 fn valid(&self) -> Option<String> {
875 self.valid_for(EXPIRY_MARGIN)
876 }
877
878 fn valid_for(&self, margin: Duration) -> Option<String> {
880 let expiry = self.expiry?;
881 (!self.token.is_empty() && SystemTime::now() + margin < expiry).then(|| self.token.clone())
882 }
883}
884
885#[derive(Deserialize, Default)]
887#[serde(default)]
888struct Granted {
889 access_token: String,
890 expires_in: i64,
891 refresh_token: String,
892 refresh_expires_in: i64,
893 error: String,
894 error_description: String,
895}
896
897impl Granted {
898 fn expiry(&self) -> SystemTime {
899 SystemTime::now() + seconds(self.expires_in)
900 }
901}
902
903fn seconds(n: i64) -> Duration {
904 Duration::from_secs(u64::try_from(n).unwrap_or_default())
905}
906
907fn default_http_client() -> Result<&'static reqwest::Client, &'static str> {
910 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
911 CLIENT
912 .get_or_init(|| {
913 reqwest::Client::builder()
914 .build()
915 .map_err(|e| e.to_string())
916 })
917 .as_ref()
918 .map_err(String::as_str)
919}
920
921async fn request_token(
922 http: Option<&reqwest::Client>,
923 url: &str,
924 form: &[(&str, &str)],
925) -> Result<Granted, Error> {
926 let failed = |e: &dyn fmt::Display| Error::Credentials(format!("requesting a token: {e}"));
927 let http = match http {
928 Some(http) => http,
929 None => default_http_client().map_err(|e| failed(&e))?,
930 };
931 let response = http
932 .post(url)
933 .timeout(TOKEN_REQUEST_TIMEOUT)
934 .header(reqwest::header::ACCEPT, "application/json")
935 .form(form)
936 .send()
937 .await
938 .map_err(|e| failed(&e))?;
939 let (status, body) = read_bounded(response).await.map_err(|e| failed(&e))?;
940 let granted: Granted = serde_json::from_slice(&body).map_err(|_| {
941 Error::Credentials(format!(
942 "requesting a token: HTTP {}, not a token response",
943 status.as_u16()
944 ))
945 })?;
946 if status != reqwest::StatusCode::OK || granted.access_token.is_empty() {
947 let reason = if granted.error_description.is_empty() {
948 &granted.error
949 } else {
950 &granted.error_description
951 };
952 return Err(Error::Credentials(format!(
953 "the token request was refused (HTTP {}): {reason}",
954 status.as_u16()
955 )));
956 }
957 Ok(granted)
958}
959
960async fn read_bounded(
963 mut response: reqwest::Response,
964) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
965 let status = response.status();
966 let mut body = Vec::new();
967 while let Some(chunk) = response.chunk().await? {
968 body.extend_from_slice(&chunk);
969 if body.len() > MAX_TOKEN_RESPONSE_BYTES {
970 body.truncate(MAX_TOKEN_RESPONSE_BYTES);
971 break;
972 }
973 }
974 Ok((status, body))
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980 use std::collections::HashMap;
981
982 fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
983 let vars: HashMap<String, String> = vars
984 .iter()
985 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
986 .collect();
987 move |name| vars.get(name).cloned()
988 }
989
990 fn now() -> DateTime<Utc> {
991 SystemTime::now().into()
992 }
993
994 fn data_home() -> PathBuf {
995 let dir =
996 std::env::temp_dir().join(format!("origincloud-sdk-test-{}", uuid::Uuid::new_v4()));
997 std::fs::create_dir_all(dir.join("origincloud")).unwrap();
998 dir
999 }
1000
1001 fn write_session(
1002 dir: &std::path::Path,
1003 expires: DateTime<Utc>,
1004 refresh_expires: DateTime<Utc>,
1005 ) -> PathBuf {
1006 let path = dir.join("origincloud").join("session.json");
1007 let data = serde_json::json!({
1008 "version": 1,
1009 "access_token": "stored-access",
1010 "expires": expires.to_rfc3339(),
1011 "refresh_token": "stored-refresh",
1012 "refresh_expires": refresh_expires.to_rfc3339(),
1013 "token_type": "Bearer",
1014 });
1015 std::fs::write(&path, data.to_string()).unwrap();
1016 path
1017 }
1018
1019 #[tokio::test]
1020 async fn an_access_token_in_the_environment_comes_first() {
1021 let credentials = from_environment(&env(&[
1022 (ENV_ACCESS_TOKEN, "from-env"),
1023 (ENV_CLIENT_ID, "id"),
1024 (ENV_CLIENT_SECRET, "secret"),
1025 ]))
1026 .unwrap();
1027 assert_eq!(credentials.token().await.unwrap(), "from-env");
1028 }
1029
1030 #[test]
1031 fn a_service_account_in_the_environment_comes_before_the_cli_session() {
1032 let dir = data_home();
1033 let hour = chrono::Duration::hours(1);
1034 write_session(&dir, now() + hour, now() + hour);
1035 let credentials = from_environment(&env(&[
1036 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1037 (ENV_CLIENT_ID, "id"),
1038 (ENV_CLIENT_SECRET, "secret"),
1039 ]))
1040 .unwrap();
1041 assert!(
1042 format!("{credentials:?}").starts_with("ServiceAccount"),
1043 "{credentials:?}"
1044 );
1045 }
1046
1047 const GITHUB: [(&str, &str); 2] = [
1048 (
1049 ENV_GITHUB_REQUEST_URL,
1050 "https://github.invalid/idtoken?api-version=2.0",
1051 ),
1052 (ENV_GITHUB_REQUEST_TOKEN, "github-request-token"),
1053 ];
1054
1055 fn with<'a>(
1056 base: &[(&'a str, &'a str)],
1057 more: &[(&'a str, &'a str)],
1058 ) -> Vec<(&'a str, &'a str)> {
1059 base.iter().chain(more).copied().collect()
1060 }
1061
1062 #[test]
1063 fn a_service_account_comes_before_a_ci_job_s_token() {
1064 let credentials = from_environment(&env(&with(
1065 &GITHUB,
1066 &[(ENV_CLIENT_ID, "id"), (ENV_CLIENT_SECRET, "secret")],
1067 )))
1068 .unwrap();
1069 assert!(
1070 format!("{credentials:?}").starts_with("ServiceAccount"),
1071 "{credentials:?}"
1072 );
1073 }
1074
1075 #[test]
1076 fn a_ci_job_s_token_comes_before_the_cli_session() {
1077 let dir = data_home();
1078 let hour = chrono::Duration::hours(1);
1079 write_session(&dir, now() + hour, now() + hour);
1080 let credentials = from_environment(&env(&with(
1081 &GITHUB,
1082 &[("XDG_DATA_HOME", dir.to_str().unwrap())],
1083 )))
1084 .unwrap();
1085 let debug = format!("{credentials:?}");
1086 assert!(debug.starts_with("Federated"), "{debug}");
1087 assert!(debug.contains("GitHub Actions"), "{debug}");
1088 assert!(debug.contains(&format!("{STS_ENDPOINT:?}")), "{debug}");
1089 assert!(!debug.contains("github-request-token"), "{debug}");
1090 assert!(!debug.contains("github.invalid"), "{debug}");
1091 }
1092
1093 #[test]
1094 fn a_token_file_comes_before_github_actions() {
1095 let credentials = from_environment(&env(&with(
1096 &GITHUB,
1097 &[(ENV_OIDC_TOKEN_FILE, "/var/run/secrets/token")],
1098 )))
1099 .unwrap();
1100 let debug = format!("{credentials:?}");
1101 assert!(
1102 debug.starts_with(r#"Federated { token_file: "/var/run/secrets/token""#),
1103 "{debug}"
1104 );
1105 }
1106
1107 #[test]
1108 fn github_actions_needs_both_its_variables() {
1109 let dir = data_home();
1110 for half in GITHUB {
1111 let credentials =
1112 from_environment(&env(&[half, ("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
1113 assert!(
1114 format!("{credentials:?}").starts_with("InstanceMetadata"),
1115 "{credentials:?}"
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn the_federated_step_can_be_left_out() {
1122 let dir = data_home();
1123 let credentials = from_environment(&env(&with(
1124 &GITHUB,
1125 &[
1126 (ENV_OIDC_TOKEN_FILE, "/var/run/secrets/token"),
1127 (ENV_OIDC_DISABLED, "True"),
1128 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1129 ],
1130 )))
1131 .unwrap();
1132 assert!(
1133 format!("{credentials:?}").starts_with("InstanceMetadata"),
1134 "{credentials:?}"
1135 );
1136 }
1137
1138 #[test]
1139 fn the_sts_and_the_audience_can_be_named() {
1140 let credentials = from_environment(&env(&with(
1141 &GITHUB,
1142 &[
1143 (ENV_STS_ENDPOINT, "http://127.0.0.1:8080/"),
1144 (ENV_OIDC_AUDIENCE, "https://sts.example"),
1145 ],
1146 )))
1147 .unwrap();
1148 let debug = format!("{credentials:?}");
1149 assert!(
1150 debug.contains(r#"endpoint: "http://127.0.0.1:8080", audience: "https://sts.example""#),
1151 "{debug}"
1152 );
1153 }
1154
1155 #[test]
1156 fn half_a_service_account_is_refused() {
1157 let err = from_environment(&env(&[(ENV_CLIENT_ID, "id")])).unwrap_err();
1158 assert!(err.to_string().contains("must be set together"), "{err}");
1159 let err = from_environment(&env(&[(ENV_CLIENT_SECRET, "secret")])).unwrap_err();
1160 assert!(err.to_string().contains("must be set together"), "{err}");
1161 }
1162
1163 #[test]
1164 fn without_others_the_metadata_service_comes_last() {
1165 let dir = data_home();
1166 let credentials =
1167 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
1168 assert!(
1169 format!("{credentials:?}").starts_with("InstanceMetadata"),
1170 "{credentials:?}"
1171 );
1172 }
1173
1174 #[test]
1175 fn the_metadata_service_s_address_can_be_named() {
1176 let dir = data_home();
1177 let credentials = from_environment(&env(&[
1178 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1179 (ENV_METADATA_ENDPOINT, "http://127.0.0.1:8080/"),
1180 ]))
1181 .unwrap();
1182 assert!(
1183 format!("{credentials:?}").contains(r#"endpoint: "http://127.0.0.1:8080""#),
1184 "{credentials:?}"
1185 );
1186 }
1187
1188 #[test]
1189 fn no_credentials_at_all_without_the_metadata_service() {
1190 let dir = data_home();
1191 let err = from_environment(&env(&[
1192 ("XDG_DATA_HOME", dir.to_str().unwrap()),
1193 (ENV_METADATA_DISABLED, "TRUE"),
1194 ]))
1195 .unwrap_err();
1196 assert!(matches!(err, Error::NoCredentials), "{err}");
1197 }
1198
1199 #[test]
1200 fn the_session_is_found_under_the_home_directory_without_xdg_data_home() {
1201 let path = cli_session_path(&env(&[
1202 ("HOME", "/home/someone"),
1203 ("USERPROFILE", "/home/someone"),
1204 ]))
1205 .unwrap();
1206 assert_eq!(
1207 path,
1208 PathBuf::from("/home/someone/.local/share/origincloud/session.json")
1209 );
1210 }
1211
1212 #[tokio::test]
1213 async fn the_cli_session_is_used_as_stored() {
1214 let dir = data_home();
1215 write_session(
1216 &dir,
1217 now() + chrono::Duration::hours(1),
1218 now() + chrono::Duration::hours(2),
1219 );
1220 let credentials =
1221 from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
1222 assert_eq!(credentials.token().await.unwrap(), "stored-access");
1223 }
1224
1225 #[tokio::test]
1226 async fn a_cli_session_past_its_refresh_expiry_asks_to_log_in() {
1227 let dir = data_home();
1228 let path = write_session(
1229 &dir,
1230 now() - chrono::Duration::hours(1),
1231 now() - chrono::Duration::minutes(1),
1232 );
1233 let session = CliSession::load_from(path).unwrap().unwrap();
1234 let err = session.token().await.unwrap_err();
1235 assert!(err.to_string().contains("origin id login"), "{err}");
1236 }
1237}