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_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
34/// The security token service, where a CI job's OIDC token is exchanged
35/// for an access token.
36pub const STS_ENDPOINT: &str = "https://sts.origincloudapis.eu";
37
38/// The audience a GitHub Actions job's OIDC token is requested for.
39pub const OIDC_AUDIENCE: &str = "https://sts.origincloudapis.eu";
40
41/// How long before its expiry an exchanged access token is replaced.
42const FEDERATED_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
43
44/// The waits before an exchange the STS answered with `503` (it can't
45/// check the token right now) is sent again.
46const 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
51/// The instance metadata service, which software on an instance reaches
52/// at this link-local address.
53pub const METADATA_ENDPOINT: &str = "http://169.254.169.254";
54
55/// How long a metadata-service session is asked for: the most it grants.
56const 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
60/// How long reaching the metadata service may take, and opening a session
61/// in all: off an instance, the chain's last step fails within them.
62const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
63const METADATA_SESSION_TIMEOUT: Duration = Duration::from_secs(2);
64
65/// How long a credential request may take.
66const METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
67
68/// How long before its expiry the metadata service's credential, which
69/// lives five minutes, is replaced.
70const METADATA_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
71
72/// The waits before a request the metadata service answered with `503`
73/// (it can't sign right now) is sent again.
74const METADATA_RETRY_DELAYS: [Duration; 2] =
75    [Duration::from_millis(100), Duration::from_millis(200)];
76
77/// The OAuth client the CLI logs in as, which its refresh token belongs to.
78const CLI_CLIENT_ID: &str = "origin-cli";
79
80/// How long before its expiry a token is replaced, so that it doesn't
81/// expire on the way.
82const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
83
84/// The most of a token response that is read.
85const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
86
87/// How long a token request may take.
88const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
89
90/// What [`Credentials::token`] returns.
91pub type TokenFuture<'a> = Pin<Box<dyn Future<Output = Result<String, Error>> + Send + 'a>>;
92
93/// Credentials supply the access token each call is sent with.
94pub trait Credentials: Send + Sync + fmt::Debug {
95    /// A valid access token.
96    fn token(&self) -> TokenFuture<'_>;
97}
98
99/// The first credentials found, in this order:
100///
101/// 1. `ORIGINCLOUD_ACCESS_TOKEN`: an access token, sent as it is.
102/// 2. `ORIGINCLOUD_CLIENT_ID` and `ORIGINCLOUD_CLIENT_SECRET`: a service
103///    account's credential, exchanged for access tokens as they expire.
104///    Only one of the two set is an error.
105/// 3. A CI job's OIDC token, exchanged at the security token service
106///    ([`Federated`]): from the file `ORIGINCLOUD_OIDC_TOKEN_FILE` names,
107///    or else from `ORIGINCLOUD_OIDC_TOKEN`, which holds the token itself
108///    (as GitLab CI's `id_tokens` set it), or else from GitHub Actions, in
109///    a job granted `permissions: id-token: write`. Once found, its errors
110///    are returned, not passed over. `ORIGINCLOUD_OIDC_DISABLED=true`
111///    leaves this step out; `ORIGINCLOUD_STS_ENDPOINT` asks another address
112///    than [`STS_ENDPOINT`], and `ORIGINCLOUD_OIDC_AUDIENCE` requests
113///    GitHub's token for another audience than [`OIDC_AUDIENCE`].
114/// 4. The session the `origin` CLI stored at its last login
115///    (`origin id login`).
116/// 5. The instance's metadata service, on an instance that runs as a
117///    service account ([`InstanceMetadata`]). Nothing is requested until a
118///    token is; off an instance, that fails with [`Error::NoCredentials`]
119///    within about two seconds. `ORIGINCLOUD_METADATA_DISABLED=true` leaves
120///    this step out; `ORIGINCLOUD_METADATA_ENDPOINT` asks another address
121///    than [`METADATA_ENDPOINT`].
122///
123/// It fails with [`Error::NoCredentials`] when the metadata service is
124/// left out and there are no others.
125pub fn default_credentials() -> Result<Arc<dyn Credentials>, Error> {
126    from_environment(&|name| std::env::var(name).ok())
127}
128
129/// [`default_credentials`] with the environment read through `env`.
130pub(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
164/// The federated step of the chain, when the environment holds a CI job's
165/// OIDC token: a token file, which can be rotated in place, before a token
166/// given as it is, before GitHub Actions.
167fn 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/// An access token, sent as it is. It is not renewed: a service account's
195/// lives five minutes.
196#[derive(Clone)]
197pub struct AccessToken(String);
198
199impl AccessToken {
200    /// Credentials that always send `token`.
201    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
218/// A service account's client ID and secret, exchanged for access tokens
219/// with the client credentials grant as they expire.
220pub 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    /// The service account `client_id` with the secret `client_secret`.
230    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    /// Requests tokens at `url` instead of [`TOKEN_URL`].
241    pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
242        self.token_url = url.into();
243        self
244    }
245
246    /// Sends the token requests with `http`, instead of the client all
247    /// credentials share. Each request is bounded by 30 seconds either way.
248    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
287/// The session the `origin` CLI stored at its last login, renewed from its
288/// refresh token when its access token has expired. The renewed session
289/// stays in memory: the CLI's file is never written.
290pub 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/// The file the CLI stores its session in.
303#[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    /// The session the CLI stored, or `None` when it stored none: under
317    /// `$XDG_DATA_HOME`, or `~/.local/share`, on every platform, at
318    /// `origincloud/session.json`.
319    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    /// The session stored at `path`, or `None` when there is no file.
327    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    /// Renews the session at `url` instead of [`TOKEN_URL`].
352    pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
353        self.token_url = url.into();
354        self
355    }
356
357    /// Sends the renewals with `http`, instead of the client all
358    /// credentials share. Each renewal is bounded by 30 seconds either way.
359    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
419/// The credential of the service account the instance runs as, from the
420/// instance's metadata service: a session is opened for six hours, and each
421/// access token, which lives five minutes, requested with it a minute
422/// before the last one expires. The requests never go through a proxy,
423/// which the metadata service refuses, and one it can't answer right now
424/// (`503`) is sent again twice.
425pub 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/// The metadata service's credential.
438#[derive(Deserialize)]
439struct Issued {
440    access_token: String,
441    expires_at: DateTime<Utc>,
442}
443
444impl InstanceMetadata {
445    /// The metadata service at [`METADATA_ENDPOINT`].
446    pub fn new() -> Self {
447        Self {
448            endpoint: METADATA_ENDPOINT.to_owned(),
449            http: None,
450            state: Mutex::default(),
451        }
452    }
453
454    /// Asks the metadata service at `url` instead of [`METADATA_ENDPOINT`].
455    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    /// Sends the requests with `http`, as it is configured, instead of a
461    /// client without a proxy that gives up connecting after a second.
462    /// Opening a session is bounded by two seconds either way, and
463    /// requesting a credential by five.
464    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    /// A new session. Off an instance, there is no metadata service to
487    /// answer, or another one: another cloud's, at the same address, which
488    /// refuses the request as one it doesn't know. Either way, no
489    /// credentials.
490    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    /// The credential, or `None` when the session is no longer valid.
513    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
552/// Sends `request`, and again after each of `delays` while it is answered
553/// with `503`.
554async 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            // A session refused as no longer valid is opened again, once.
601            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
631/// A CI job's OIDC token, exchanged at the security token service for the
632/// access token of the `WORKLOAD` service account a federated credential
633/// trusts the job as. The access token is reused until a minute before it
634/// expires; then a fresh job token is fetched and exchanged again, or a
635/// token given as it is ([`Federated::from_token`]) exchanged again. An
636/// exchange the STS can't check right now (`503`) is sent again twice.
637pub struct Federated {
638    source: JobToken,
639    endpoint: String,
640    audience: String,
641    http: Option<reqwest::Client>,
642    cache: Mutex<Cached>,
643}
644
645/// Where the job's token comes from.
646enum JobToken {
647    File(PathBuf),
648    /// A token given as it is, from the environment variable `variable`
649    /// names, if any.
650    Given {
651        token: String,
652        variable: Option<&'static str>,
653    },
654    GitHubActions {
655        request_url: String,
656        request_token: String,
657    },
658}
659
660/// The claims of a job token that are read before it is exchanged.
661#[derive(Deserialize)]
662struct JobTokenClaims {
663    exp: Option<f64>,
664}
665
666/// GitHub's answer to a job token request.
667#[derive(Deserialize)]
668struct GitHubToken {
669    value: String,
670}
671
672impl Federated {
673    /// The token in the file at `path`, read again at every exchange, so
674    /// that a rotated token is picked up: any CI provider's, or a
675    /// Kubernetes projected service account token.
676    pub fn from_file(path: impl Into<PathBuf>) -> Self {
677        Self::with_source(JobToken::File(path.into()))
678    }
679
680    /// The token `token`, exchanged as it is: any CI provider's, such as the
681    /// one GitLab CI sets in the variable its `id_tokens` names. It can't
682    /// be renewed: each renewal of the access token exchanges it again,
683    /// until it expires itself, and from then on fails without asking the
684    /// STS.
685    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    /// A GitHub Actions job's token, requested at `request_url` with
693    /// `request_token`: the values of `ACTIONS_ID_TOKEN_REQUEST_URL` and
694    /// `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, which GitHub sets in a job
695    /// granted `permissions: id-token: write`.
696    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    /// Exchanges the token at the STS at `url` instead of [`STS_ENDPOINT`].
717    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    /// Requests GitHub's token for `audience` instead of [`OIDC_AUDIENCE`].
723    /// A token file's or a given token's audience is whatever its issuer
724    /// put in it.
725    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
726        self.audience = audience.into();
727        self
728    }
729
730    /// Sends the requests with `http`, instead of the client all
731    /// credentials share. Each request is bounded by 30 seconds either way.
732    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    /// A fresh job token.
746    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    /// The job's token exchanged for an access token.
826    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
908/// ` in VARIABLE`, naming where a given job token came from, if anywhere.
909fn in_variable(variable: Option<&str>) -> String {
910    variable.map_or_else(String::new, |name| format!(" in {name}"))
911}
912
913/// When a job token (a JWT) expires, as its unverified `exp` claim says:
914/// `None` when it has none, or isn't a JWT whose claims can be read, which
915/// is left to the STS to judge.
916fn 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
933/// Where the CLI stores its session: under `XDG_DATA_HOME`, or
934/// `~/.local/share`, on every platform.
935fn 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/// One access token, replaced once it is about to expire.
949#[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    /// The token, unless it expires within `margin`.
961    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/// A token endpoint's answer.
968#[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
989/// The HTTP client token requests are sent with unless the credentials
990/// name one: one for the process, created by the first request.
991fn 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
1042/// A response's status and its body, up to the most of a token response
1043/// that is read.
1044async 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    /// An unsigned JWT with the claims `claims`.
1244    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        // Padded base64url, as some issuers write it.
1285        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}