Skip to main content

origincloud_core/
credentials.rs

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