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