Skip to main content

origincloud_core/
credentials.rs

1//! Credentials: where the access token each call is sent with comes from.
2
3use 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
16/// The identity provider's token endpoint, where service account
17/// credentials and the CLI's session are exchanged for access tokens.
18pub 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
33/// The security token service, where a CI job's OIDC token is exchanged
34/// for an access token.
35pub const STS_ENDPOINT: &str = "https://sts.origincloudapis.eu";
36
37/// The audience a GitHub Actions job's OIDC token is requested for.
38pub const OIDC_AUDIENCE: &str = "https://sts.origincloudapis.eu";
39
40/// How long before its expiry an exchanged access token is replaced.
41const FEDERATED_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
42
43/// The waits before an exchange the STS answered with `503` (it can't
44/// check the token right now) is sent again.
45const 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
50/// The instance metadata service, which software on an instance reaches
51/// at this link-local address.
52pub const METADATA_ENDPOINT: &str = "http://169.254.169.254";
53
54/// How long a metadata-service session is asked for: the most it grants.
55const 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
59/// How long reaching the metadata service may take, and opening a session
60/// in all: off an instance, the chain's last step fails within them.
61const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
62const METADATA_SESSION_TIMEOUT: Duration = Duration::from_secs(2);
63
64/// How long a credential request may take.
65const METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
66
67/// How long before its expiry the metadata service's credential, which
68/// lives five minutes, is replaced.
69const METADATA_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
70
71/// The waits before a request the metadata service answered with `503`
72/// (it can't sign right now) is sent again.
73const METADATA_RETRY_DELAYS: [Duration; 2] =
74    [Duration::from_millis(100), Duration::from_millis(200)];
75
76/// The OAuth client the CLI logs in as, which its refresh token belongs to.
77const CLI_CLIENT_ID: &str = "origin-cli";
78
79/// How long before its expiry a token is replaced, so that it doesn't
80/// expire on the way.
81const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
82
83/// The most of a token response that is read.
84const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
85
86/// How long a token request may take.
87const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
88
89/// What [`Credentials::token`] returns.
90pub type TokenFuture<'a> = Pin<Box<dyn Future<Output = Result<String, Error>> + Send + 'a>>;
91
92/// Credentials supply the access token each call is sent with.
93pub trait Credentials: Send + Sync + fmt::Debug {
94    /// A valid access token.
95    fn token(&self) -> TokenFuture<'_>;
96}
97
98/// The first credentials found, in this order:
99///
100/// 1. `ORIGINCLOUD_ACCESS_TOKEN`: an access token, sent as it is.
101/// 2. `ORIGINCLOUD_CLIENT_ID` and `ORIGINCLOUD_CLIENT_SECRET`: a service
102///    account's credential, exchanged for access tokens as they expire.
103///    Only one of the two set is an error.
104/// 3. A CI job's OIDC token, exchanged at the security token service
105///    ([`Federated`]): from the file `ORIGINCLOUD_OIDC_TOKEN_FILE` names,
106///    or else from GitHub Actions, in a job granted
107///    `permissions: id-token: write`. Once found, its errors are returned,
108///    not passed over. `ORIGINCLOUD_OIDC_DISABLED=true` leaves this step
109///    out; `ORIGINCLOUD_STS_ENDPOINT` asks another address than
110///    [`STS_ENDPOINT`], and `ORIGINCLOUD_OIDC_AUDIENCE` requests GitHub's
111///    token for another audience than [`OIDC_AUDIENCE`].
112/// 4. The session the `origin` CLI stored at its last login
113///    (`origin id login`).
114/// 5. The instance's metadata service, on an instance that runs as a
115///    service account ([`InstanceMetadata`]). Nothing is requested until a
116///    token is; off an instance, that fails with [`Error::NoCredentials`]
117///    within about two seconds. `ORIGINCLOUD_METADATA_DISABLED=true` leaves
118///    this step out; `ORIGINCLOUD_METADATA_ENDPOINT` asks another address
119///    than [`METADATA_ENDPOINT`].
120///
121/// It fails with [`Error::NoCredentials`] when the metadata service is
122/// left out and there are no others.
123pub fn default_credentials() -> Result<Arc<dyn Credentials>, Error> {
124    from_environment(&|name| std::env::var(name).ok())
125}
126
127/// [`default_credentials`] with the environment read through `env`.
128pub(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
162/// The federated step of the chain, when the environment holds a CI job's
163/// OIDC token: a token file before GitHub Actions.
164fn 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/// An access token, sent as it is. It is not renewed: a service account's
187/// lives five minutes.
188#[derive(Clone)]
189pub struct AccessToken(String);
190
191impl AccessToken {
192    /// Credentials that always send `token`.
193    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
210/// A service account's client ID and secret, exchanged for access tokens
211/// with the client credentials grant as they expire.
212pub 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    /// The service account `client_id` with the secret `client_secret`.
222    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    /// Requests tokens at `url` instead of [`TOKEN_URL`].
233    pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
234        self.token_url = url.into();
235        self
236    }
237
238    /// Sends the token requests with `http`, instead of the client all
239    /// credentials share. Each request is bounded by 30 seconds either way.
240    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
279/// The session the `origin` CLI stored at its last login, renewed from its
280/// refresh token when its access token has expired. The renewed session
281/// stays in memory: the CLI's file is never written.
282pub 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/// The file the CLI stores its session in.
295#[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    /// The session the CLI stored, or `None` when it stored none: under
309    /// `$XDG_DATA_HOME`, or `~/.local/share`, on every platform, at
310    /// `origincloud/session.json`.
311    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    /// The session stored at `path`, or `None` when there is no file.
319    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    /// Renews the session at `url` instead of [`TOKEN_URL`].
344    pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
345        self.token_url = url.into();
346        self
347    }
348
349    /// Sends the renewals with `http`, instead of the client all
350    /// credentials share. Each renewal is bounded by 30 seconds either way.
351    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
411/// The credential of the service account the instance runs as, from the
412/// instance's metadata service: a session is opened for six hours, and each
413/// access token, which lives five minutes, requested with it a minute
414/// before the last one expires. The requests never go through a proxy,
415/// which the metadata service refuses, and one it can't answer right now
416/// (`503`) is sent again twice.
417pub 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/// The metadata service's credential.
430#[derive(Deserialize)]
431struct Issued {
432    access_token: String,
433    expires_at: DateTime<Utc>,
434}
435
436impl InstanceMetadata {
437    /// The metadata service at [`METADATA_ENDPOINT`].
438    pub fn new() -> Self {
439        Self {
440            endpoint: METADATA_ENDPOINT.to_owned(),
441            http: None,
442            state: Mutex::default(),
443        }
444    }
445
446    /// Asks the metadata service at `url` instead of [`METADATA_ENDPOINT`].
447    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    /// Sends the requests with `http`, as it is configured, instead of a
453    /// client without a proxy that gives up connecting after a second.
454    /// Opening a session is bounded by two seconds either way, and
455    /// requesting a credential by five.
456    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    /// A new session. Off an instance, there is no metadata service to
479    /// answer, or another one: another cloud's, at the same address, which
480    /// refuses the request as one it doesn't know. Either way, no
481    /// credentials.
482    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    /// The credential, or `None` when the session is no longer valid.
505    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
544/// Sends `request`, and again after each of `delays` while it is answered
545/// with `503`.
546async 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            // A session refused as no longer valid is opened again, once.
593            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
623/// A CI job's OIDC token, exchanged at the security token service for the
624/// access token of the `WORKLOAD` service account a federated credential
625/// trusts the job as. The access token is reused until a minute before it
626/// expires; then a fresh job token is fetched and exchanged again. An
627/// exchange the STS can't check right now (`503`) is sent again twice.
628pub struct Federated {
629    source: JobToken,
630    endpoint: String,
631    audience: String,
632    http: Option<reqwest::Client>,
633    cache: Mutex<Cached>,
634}
635
636/// Where the job's token comes from.
637enum JobToken {
638    File(PathBuf),
639    GitHubActions {
640        request_url: String,
641        request_token: String,
642    },
643}
644
645/// GitHub's answer to a job token request.
646#[derive(Deserialize)]
647struct GitHubToken {
648    value: String,
649}
650
651impl Federated {
652    /// The token in the file at `path`, read again at every exchange, so
653    /// that a rotated token is picked up: any CI provider's, or a
654    /// Kubernetes projected service account token.
655    pub fn from_file(path: impl Into<PathBuf>) -> Self {
656        Self::with_source(JobToken::File(path.into()))
657    }
658
659    /// A GitHub Actions job's token, requested at `request_url` with
660    /// `request_token`: the values of `ACTIONS_ID_TOKEN_REQUEST_URL` and
661    /// `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, which GitHub sets in a job
662    /// granted `permissions: id-token: write`.
663    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    /// Exchanges the token at the STS at `url` instead of [`STS_ENDPOINT`].
684    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    /// Requests GitHub's token for `audience` instead of [`OIDC_AUDIENCE`].
690    /// A token file's audience is whatever its issuer put in it.
691    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
692        self.audience = audience.into();
693        self
694    }
695
696    /// Sends the requests with `http`, instead of the client all
697    /// credentials share. Each request is bounded by 30 seconds either way.
698    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    /// A fresh job token.
712    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    /// The job's token exchanged for an access token.
770    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
851/// Where the CLI stores its session: under `XDG_DATA_HOME`, or
852/// `~/.local/share`, on every platform.
853fn 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/// One access token, replaced once it is about to expire.
867#[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    /// The token, unless it expires within `margin`.
879    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/// A token endpoint's answer.
886#[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
907/// The HTTP client token requests are sent with unless the credentials
908/// name one: one for the process, created by the first request.
909fn 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
960/// A response's status and its body, up to the most of a token response
961/// that is read.
962async 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}