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";
26
27/// The instance metadata service, which software on an instance reaches
28/// at this link-local address.
29pub const METADATA_ENDPOINT: &str = "http://169.254.169.254";
30
31/// How long a metadata-service session is asked for: the most it grants.
32const METADATA_SESSION_TTL: Duration = Duration::from_secs(6 * 60 * 60);
33const METADATA_SESSION_TTL_HEADER: &str = "x-origincloud-metadata-token-ttl-seconds";
34const METADATA_SESSION_HEADER: &str = "x-origincloud-metadata-token";
35
36/// How long reaching the metadata service may take, and opening a session
37/// in all: off an instance, the chain's last step fails within them.
38const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
39const METADATA_SESSION_TIMEOUT: Duration = Duration::from_secs(2);
40
41/// How long a credential request may take.
42const METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
43
44/// How long before its expiry the metadata service's credential, which
45/// lives five minutes, is replaced.
46const METADATA_EXPIRY_MARGIN: Duration = Duration::from_secs(60);
47
48/// The waits before a request the metadata service answered with `503`
49/// (it can't sign right now) is sent again.
50const METADATA_RETRY_DELAYS: [Duration; 2] =
51    [Duration::from_millis(100), Duration::from_millis(200)];
52
53/// The OAuth client the CLI logs in as, which its refresh token belongs to.
54const CLI_CLIENT_ID: &str = "origin-cli";
55
56/// How long before its expiry a token is replaced, so that it doesn't
57/// expire on the way.
58const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
59
60/// The most of a token response that is read.
61const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
62
63/// How long a token request may take.
64const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
65
66/// What [`Credentials::token`] returns.
67pub type TokenFuture<'a> = Pin<Box<dyn Future<Output = Result<String, Error>> + Send + 'a>>;
68
69/// Credentials supply the access token each call is sent with.
70pub trait Credentials: Send + Sync + fmt::Debug {
71    /// A valid access token.
72    fn token(&self) -> TokenFuture<'_>;
73}
74
75/// The first credentials found, in this order:
76///
77/// 1. `ORIGINCLOUD_ACCESS_TOKEN`: an access token, sent as it is.
78/// 2. `ORIGINCLOUD_CLIENT_ID` and `ORIGINCLOUD_CLIENT_SECRET`: a service
79///    account's credential, exchanged for access tokens as they expire.
80///    Only one of the two set is an error.
81/// 3. The session the `origin` CLI stored at its last login
82///    (`origin id login`).
83/// 4. The instance's metadata service, on an instance that runs as a
84///    service account ([`InstanceMetadata`]). Nothing is requested until a
85///    token is; off an instance, that fails with [`Error::NoCredentials`]
86///    within about two seconds. `ORIGINCLOUD_METADATA_DISABLED=true` leaves
87///    this step out; `ORIGINCLOUD_METADATA_ENDPOINT` asks another address
88///    than [`METADATA_ENDPOINT`].
89///
90/// It fails with [`Error::NoCredentials`] when the metadata service is
91/// left out and there are no others.
92pub fn default_credentials() -> Result<Arc<dyn Credentials>, Error> {
93    from_environment(&|name| std::env::var(name).ok())
94}
95
96/// [`default_credentials`] with the environment read through `env`.
97pub(crate) fn from_environment(
98    env: &dyn Fn(&str) -> Option<String>,
99) -> Result<Arc<dyn Credentials>, Error> {
100    let var = |name: &str| env(name).filter(|v| !v.is_empty());
101    if let Some(token) = var(ENV_ACCESS_TOKEN) {
102        return Ok(Arc::new(AccessToken::new(token)));
103    }
104    match (var(ENV_CLIENT_ID), var(ENV_CLIENT_SECRET)) {
105        (Some(id), Some(secret)) => return Ok(Arc::new(ServiceAccount::new(id, secret))),
106        (None, None) => {}
107        _ => {
108            return Err(Error::Config(format!(
109                "{ENV_CLIENT_ID} and {ENV_CLIENT_SECRET} must be set together"
110            )));
111        }
112    }
113    if let Some(path) = cli_session_path(env)
114        && let Some(session) = CliSession::load_from(path)?
115    {
116        return Ok(Arc::new(session));
117    }
118    if var(ENV_METADATA_DISABLED).is_some_and(|v| v.eq_ignore_ascii_case("true")) {
119        return Err(Error::NoCredentials);
120    }
121    let metadata = InstanceMetadata::new();
122    Ok(Arc::new(match var(ENV_METADATA_ENDPOINT) {
123        Some(endpoint) => metadata.with_endpoint(endpoint),
124        None => metadata,
125    }))
126}
127
128/// An access token, sent as it is. It is not renewed: a service account's
129/// lives five minutes.
130#[derive(Clone)]
131pub struct AccessToken(String);
132
133impl AccessToken {
134    /// Credentials that always send `token`.
135    pub fn new(token: impl Into<String>) -> Self {
136        Self(token.into())
137    }
138}
139
140impl fmt::Debug for AccessToken {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.write_str("AccessToken(..)")
143    }
144}
145
146impl Credentials for AccessToken {
147    fn token(&self) -> TokenFuture<'_> {
148        Box::pin(async move { Ok(self.0.clone()) })
149    }
150}
151
152/// A service account's client ID and secret, exchanged for access tokens
153/// with the client credentials grant as they expire.
154pub struct ServiceAccount {
155    client_id: String,
156    client_secret: String,
157    token_url: String,
158    http: Option<reqwest::Client>,
159    cache: Mutex<Cached>,
160}
161
162impl ServiceAccount {
163    /// The service account `client_id` with the secret `client_secret`.
164    pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
165        Self {
166            client_id: client_id.into(),
167            client_secret: client_secret.into(),
168            token_url: TOKEN_URL.to_owned(),
169            http: None,
170            cache: Mutex::default(),
171        }
172    }
173
174    /// Requests tokens at `url` instead of [`TOKEN_URL`].
175    pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
176        self.token_url = url.into();
177        self
178    }
179
180    /// Sends the token requests with `http`, instead of the client all
181    /// credentials share. Each request is bounded by 30 seconds either way.
182    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
183        self.http = Some(http);
184        self
185    }
186}
187
188impl fmt::Debug for ServiceAccount {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        f.debug_struct("ServiceAccount")
191            .field("client_id", &self.client_id)
192            .field("token_url", &self.token_url)
193            .finish_non_exhaustive()
194    }
195}
196
197impl Credentials for ServiceAccount {
198    fn token(&self) -> TokenFuture<'_> {
199        Box::pin(async move {
200            let mut cache = self.cache.lock().await;
201            if let Some(token) = cache.valid() {
202                return Ok(token);
203            }
204            let granted = request_token(
205                self.http.as_ref(),
206                &self.token_url,
207                &[
208                    ("grant_type", "client_credentials"),
209                    ("client_id", &self.client_id),
210                    ("client_secret", &self.client_secret),
211                ],
212            )
213            .await?;
214            cache.token = granted.access_token.clone();
215            cache.expiry = Some(granted.expiry());
216            Ok(granted.access_token)
217        })
218    }
219}
220
221/// The session the `origin` CLI stored at its last login, renewed from its
222/// refresh token when its access token has expired. The renewed session
223/// stays in memory: the CLI's file is never written.
224pub struct CliSession {
225    token_url: String,
226    http: Option<reqwest::Client>,
227    state: Mutex<SessionState>,
228}
229
230struct SessionState {
231    cached: Cached,
232    refresh_token: String,
233    refresh_expiry: Option<SystemTime>,
234}
235
236/// The file the CLI stores its session in.
237#[derive(Deserialize)]
238struct StoredSession {
239    #[serde(default)]
240    access_token: String,
241    #[serde(default)]
242    expires: Option<DateTime<Utc>>,
243    #[serde(default)]
244    refresh_token: String,
245    #[serde(default)]
246    refresh_expires: Option<DateTime<Utc>>,
247}
248
249impl CliSession {
250    /// The session the CLI stored, or `None` when it stored none: under
251    /// `$XDG_DATA_HOME`, or `~/.local/share`, on every platform, at
252    /// `origincloud/session.json`.
253    pub fn load() -> Result<Option<Self>, Error> {
254        match cli_session_path(&|name| std::env::var(name).ok()) {
255            Some(path) => Self::load_from(path),
256            None => Ok(None),
257        }
258    }
259
260    /// The session stored at `path`, or `None` when there is no file.
261    pub fn load_from(path: impl Into<PathBuf>) -> Result<Option<Self>, Error> {
262        let path = path.into();
263        let data = match std::fs::read(&path) {
264            Ok(data) => data,
265            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
266            Err(e) => return Err(Error::Credentials(format!("reading the CLI session: {e}"))),
267        };
268        let stored: StoredSession = serde_json::from_slice(&data).map_err(|e| {
269            Error::Credentials(format!("reading the CLI session {}: {e}", path.display()))
270        })?;
271        Ok(Some(Self {
272            token_url: TOKEN_URL.to_owned(),
273            http: None,
274            state: Mutex::new(SessionState {
275                cached: Cached {
276                    token: stored.access_token,
277                    expiry: stored.expires.map(SystemTime::from),
278                },
279                refresh_token: stored.refresh_token,
280                refresh_expiry: stored.refresh_expires.map(SystemTime::from),
281            }),
282        }))
283    }
284
285    /// Renews the session at `url` instead of [`TOKEN_URL`].
286    pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
287        self.token_url = url.into();
288        self
289    }
290
291    /// Sends the renewals with `http`, instead of the client all
292    /// credentials share. Each renewal is bounded by 30 seconds either way.
293    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
294        self.http = Some(http);
295        self
296    }
297}
298
299impl fmt::Debug for CliSession {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        f.debug_struct("CliSession")
302            .field("token_url", &self.token_url)
303            .finish_non_exhaustive()
304    }
305}
306
307impl Credentials for CliSession {
308    fn token(&self) -> TokenFuture<'_> {
309        Box::pin(async move {
310            let mut state = self.state.lock().await;
311            if let Some(token) = state.cached.valid() {
312                return Ok(token);
313            }
314            if state
315                .refresh_expiry
316                .is_none_or(|expiry| SystemTime::now() >= expiry)
317            {
318                return Err(Error::Credentials(
319                    "the CLI session has expired: log in again with `origin id login`".to_owned(),
320                ));
321            }
322            let refresh_token = state.refresh_token.clone();
323            let granted = request_token(
324                self.http.as_ref(),
325                &self.token_url,
326                &[
327                    ("grant_type", "refresh_token"),
328                    ("client_id", CLI_CLIENT_ID),
329                    ("refresh_token", &refresh_token),
330                ],
331            )
332            .await
333            .map_err(|e| match e {
334                Error::Credentials(message) => {
335                    Error::Credentials(format!("renewing the CLI session: {message}"))
336                }
337                other => other,
338            })?;
339            if !granted.refresh_token.is_empty() {
340                state.refresh_token = granted.refresh_token.clone();
341                state.refresh_expiry =
342                    Some(SystemTime::now() + seconds(granted.refresh_expires_in));
343            }
344            state.cached = Cached {
345                token: granted.access_token.clone(),
346                expiry: Some(granted.expiry()),
347            };
348            Ok(granted.access_token)
349        })
350    }
351}
352
353/// The credential of the service account the instance runs as, from the
354/// instance's metadata service: a session is opened for six hours, and each
355/// access token, which lives five minutes, requested with it a minute
356/// before the last one expires. The requests never go through a proxy,
357/// which the metadata service refuses, and one it can't answer right now
358/// (`503`) is sent again twice.
359pub struct InstanceMetadata {
360    endpoint: String,
361    http: Option<reqwest::Client>,
362    state: Mutex<MetadataState>,
363}
364
365#[derive(Default)]
366struct MetadataState {
367    session: Cached,
368    cached: Cached,
369}
370
371/// The metadata service's credential.
372#[derive(Deserialize)]
373struct Issued {
374    access_token: String,
375    expires_at: DateTime<Utc>,
376}
377
378impl InstanceMetadata {
379    /// The metadata service at [`METADATA_ENDPOINT`].
380    pub fn new() -> Self {
381        Self {
382            endpoint: METADATA_ENDPOINT.to_owned(),
383            http: None,
384            state: Mutex::default(),
385        }
386    }
387
388    /// Asks the metadata service at `url` instead of [`METADATA_ENDPOINT`].
389    pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
390        self.endpoint = url.into().trim_end_matches('/').to_owned();
391        self
392    }
393
394    /// Sends the requests with `http`, as it is configured, instead of a
395    /// client without a proxy that gives up connecting after a second.
396    /// Opening a session is bounded by two seconds either way, and
397    /// requesting a credential by five.
398    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
399        self.http = Some(http);
400        self
401    }
402
403    fn http(&self) -> Result<&reqwest::Client, Error> {
404        static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
405        if let Some(http) = &self.http {
406            return Ok(http);
407        }
408        CLIENT
409            .get_or_init(|| {
410                reqwest::Client::builder()
411                    .no_proxy()
412                    .connect_timeout(METADATA_CONNECT_TIMEOUT)
413                    .build()
414                    .map_err(|e| e.to_string())
415            })
416            .as_ref()
417            .map_err(|e| Error::Credentials(format!("reaching the metadata service: {e}")))
418    }
419
420    /// A new session. Off an instance, there is no metadata service to
421    /// answer, or it knows no instance at the address: no credentials.
422    async fn open_session(&self) -> Result<String, Error> {
423        let request = self
424            .http()?
425            .put(format!("{}/latest/api/token", self.endpoint))
426            .timeout(METADATA_SESSION_TIMEOUT)
427            .header(
428                METADATA_SESSION_TTL_HEADER,
429                METADATA_SESSION_TTL.as_secs().to_string(),
430            );
431        let (status, body) = send_retrying(request)
432            .await
433            .map_err(|_| Error::NoCredentials)?;
434        match status.as_u16() {
435            200 => Ok(String::from_utf8_lossy(&body).trim().to_owned()),
436            404 => Err(Error::NoCredentials),
437            403 => Err(refused_through_a_proxy()),
438            status => Err(Error::Credentials(format!(
439                "the metadata service refused a session (HTTP {status})"
440            ))),
441        }
442    }
443
444    /// The credential, or `None` when the session is no longer valid.
445    async fn request_credential(&self, session: &str) -> Result<Option<Issued>, Error> {
446        let failed =
447            |e: reqwest::Error| Error::Credentials(format!("reaching the metadata service: {e}"));
448        let request = self
449            .http()?
450            .get(format!(
451                "{}/latest/meta-data/service-account/token",
452                self.endpoint
453            ))
454            .timeout(METADATA_REQUEST_TIMEOUT)
455            .header(METADATA_SESSION_HEADER, session)
456            .header(reqwest::header::ACCEPT, "application/json");
457        let (status, body) = send_retrying(request).await.map_err(failed)?;
458        match status.as_u16() {
459            200 => {}
460            401 => return Ok(None),
461            404 => {
462                return Err(Error::Credentials(
463                    "the instance runs as no service account".to_owned(),
464                ));
465            }
466            403 => return Err(refused_through_a_proxy()),
467            status => {
468                return Err(Error::Credentials(format!(
469                    "the metadata service answered HTTP {status}"
470                )));
471            }
472        }
473        match serde_json::from_slice::<Issued>(&body) {
474            Ok(issued) if !issued.access_token.is_empty() => Ok(Some(issued)),
475            _ => Err(Error::Credentials(
476                "the metadata service's answer is not a credential".to_owned(),
477            )),
478        }
479    }
480}
481
482/// Sends `request`, and again after each of [`METADATA_RETRY_DELAYS`] while
483/// the metadata service answers `503`.
484async fn send_retrying(
485    request: reqwest::RequestBuilder,
486) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
487    let mut delays = METADATA_RETRY_DELAYS.iter();
488    loop {
489        let attempt = request
490            .try_clone()
491            .expect("a metadata-service request has no streamed body");
492        let answer = read_bounded(attempt.send().await?).await?;
493        match delays.next() {
494            Some(delay) if answer.0 == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
495                tokio::time::sleep(*delay).await;
496            }
497            _ => return Ok(answer),
498        }
499    }
500}
501
502fn refused_through_a_proxy() -> Error {
503    Error::Credentials(
504        "the metadata service refused a request that came through a proxy".to_owned(),
505    )
506}
507
508impl Default for InstanceMetadata {
509    fn default() -> Self {
510        Self::new()
511    }
512}
513
514impl fmt::Debug for InstanceMetadata {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        f.debug_struct("InstanceMetadata")
517            .field("endpoint", &self.endpoint)
518            .finish_non_exhaustive()
519    }
520}
521
522impl Credentials for InstanceMetadata {
523    fn token(&self) -> TokenFuture<'_> {
524        Box::pin(async move {
525            let mut state = self.state.lock().await;
526            if let Some(token) = state.cached.valid_for(METADATA_EXPIRY_MARGIN) {
527                return Ok(token);
528            }
529            // A session refused as no longer valid is opened again, once.
530            for _ in 0..2 {
531                let session = match state.session.valid() {
532                    Some(session) => session,
533                    None => {
534                        let session = self.open_session().await?;
535                        state.session = Cached {
536                            token: session.clone(),
537                            expiry: Some(SystemTime::now() + METADATA_SESSION_TTL),
538                        };
539                        session
540                    }
541                };
542                match self.request_credential(&session).await? {
543                    Some(issued) => {
544                        state.cached = Cached {
545                            token: issued.access_token.clone(),
546                            expiry: Some(issued.expires_at.into()),
547                        };
548                        return Ok(issued.access_token);
549                    }
550                    None => state.session = Cached::default(),
551                }
552            }
553            Err(Error::Credentials(
554                "the metadata service answered HTTP 401".to_owned(),
555            ))
556        })
557    }
558}
559
560/// Where the CLI stores its session: under `XDG_DATA_HOME`, or
561/// `~/.local/share`, on every platform.
562fn cli_session_path(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
563    let base = match env("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
564        Some(base) => PathBuf::from(base),
565        None => home_dir(env)?.join(".local").join("share"),
566    };
567    Some(base.join("origincloud").join("session.json"))
568}
569
570fn home_dir(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
571    let name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
572    env(name).filter(|v| !v.is_empty()).map(PathBuf::from)
573}
574
575/// One access token, replaced once it is about to expire.
576#[derive(Default)]
577struct Cached {
578    token: String,
579    expiry: Option<SystemTime>,
580}
581
582impl Cached {
583    fn valid(&self) -> Option<String> {
584        self.valid_for(EXPIRY_MARGIN)
585    }
586
587    /// The token, unless it expires within `margin`.
588    fn valid_for(&self, margin: Duration) -> Option<String> {
589        let expiry = self.expiry?;
590        (!self.token.is_empty() && SystemTime::now() + margin < expiry).then(|| self.token.clone())
591    }
592}
593
594/// A token endpoint's answer.
595#[derive(Deserialize, Default)]
596#[serde(default)]
597struct Granted {
598    access_token: String,
599    expires_in: i64,
600    refresh_token: String,
601    refresh_expires_in: i64,
602    error: String,
603    error_description: String,
604}
605
606impl Granted {
607    fn expiry(&self) -> SystemTime {
608        SystemTime::now() + seconds(self.expires_in)
609    }
610}
611
612fn seconds(n: i64) -> Duration {
613    Duration::from_secs(u64::try_from(n).unwrap_or_default())
614}
615
616/// The HTTP client token requests are sent with unless the credentials
617/// name one: one for the process, created by the first request.
618fn default_http_client() -> Result<&'static reqwest::Client, &'static str> {
619    static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
620    CLIENT
621        .get_or_init(|| {
622            reqwest::Client::builder()
623                .build()
624                .map_err(|e| e.to_string())
625        })
626        .as_ref()
627        .map_err(String::as_str)
628}
629
630async fn request_token(
631    http: Option<&reqwest::Client>,
632    url: &str,
633    form: &[(&str, &str)],
634) -> Result<Granted, Error> {
635    let failed = |e: &dyn fmt::Display| Error::Credentials(format!("requesting a token: {e}"));
636    let http = match http {
637        Some(http) => http,
638        None => default_http_client().map_err(|e| failed(&e))?,
639    };
640    let response = http
641        .post(url)
642        .timeout(TOKEN_REQUEST_TIMEOUT)
643        .header(reqwest::header::ACCEPT, "application/json")
644        .form(form)
645        .send()
646        .await
647        .map_err(|e| failed(&e))?;
648    let (status, body) = read_bounded(response).await.map_err(|e| failed(&e))?;
649    let granted: Granted = serde_json::from_slice(&body).map_err(|_| {
650        Error::Credentials(format!(
651            "requesting a token: HTTP {}, not a token response",
652            status.as_u16()
653        ))
654    })?;
655    if status != reqwest::StatusCode::OK || granted.access_token.is_empty() {
656        let reason = if granted.error_description.is_empty() {
657            &granted.error
658        } else {
659            &granted.error_description
660        };
661        return Err(Error::Credentials(format!(
662            "the token request was refused (HTTP {}): {reason}",
663            status.as_u16()
664        )));
665    }
666    Ok(granted)
667}
668
669/// A response's status and its body, up to the most of a token response
670/// that is read.
671async fn read_bounded(
672    mut response: reqwest::Response,
673) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
674    let status = response.status();
675    let mut body = Vec::new();
676    while let Some(chunk) = response.chunk().await? {
677        body.extend_from_slice(&chunk);
678        if body.len() > MAX_TOKEN_RESPONSE_BYTES {
679            body.truncate(MAX_TOKEN_RESPONSE_BYTES);
680            break;
681        }
682    }
683    Ok((status, body))
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use std::collections::HashMap;
690
691    fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
692        let vars: HashMap<String, String> = vars
693            .iter()
694            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
695            .collect();
696        move |name| vars.get(name).cloned()
697    }
698
699    fn now() -> DateTime<Utc> {
700        SystemTime::now().into()
701    }
702
703    fn data_home() -> PathBuf {
704        let dir =
705            std::env::temp_dir().join(format!("origincloud-sdk-test-{}", uuid::Uuid::new_v4()));
706        std::fs::create_dir_all(dir.join("origincloud")).unwrap();
707        dir
708    }
709
710    fn write_session(
711        dir: &std::path::Path,
712        expires: DateTime<Utc>,
713        refresh_expires: DateTime<Utc>,
714    ) -> PathBuf {
715        let path = dir.join("origincloud").join("session.json");
716        let data = serde_json::json!({
717            "version": 1,
718            "access_token": "stored-access",
719            "expires": expires.to_rfc3339(),
720            "refresh_token": "stored-refresh",
721            "refresh_expires": refresh_expires.to_rfc3339(),
722            "token_type": "Bearer",
723        });
724        std::fs::write(&path, data.to_string()).unwrap();
725        path
726    }
727
728    #[tokio::test]
729    async fn an_access_token_in_the_environment_comes_first() {
730        let credentials = from_environment(&env(&[
731            (ENV_ACCESS_TOKEN, "from-env"),
732            (ENV_CLIENT_ID, "id"),
733            (ENV_CLIENT_SECRET, "secret"),
734        ]))
735        .unwrap();
736        assert_eq!(credentials.token().await.unwrap(), "from-env");
737    }
738
739    #[test]
740    fn a_service_account_in_the_environment_comes_before_the_cli_session() {
741        let dir = data_home();
742        let hour = chrono::Duration::hours(1);
743        write_session(&dir, now() + hour, now() + hour);
744        let credentials = from_environment(&env(&[
745            ("XDG_DATA_HOME", dir.to_str().unwrap()),
746            (ENV_CLIENT_ID, "id"),
747            (ENV_CLIENT_SECRET, "secret"),
748        ]))
749        .unwrap();
750        assert!(
751            format!("{credentials:?}").starts_with("ServiceAccount"),
752            "{credentials:?}"
753        );
754    }
755
756    #[test]
757    fn half_a_service_account_is_refused() {
758        let err = from_environment(&env(&[(ENV_CLIENT_ID, "id")])).unwrap_err();
759        assert!(err.to_string().contains("must be set together"), "{err}");
760        let err = from_environment(&env(&[(ENV_CLIENT_SECRET, "secret")])).unwrap_err();
761        assert!(err.to_string().contains("must be set together"), "{err}");
762    }
763
764    #[test]
765    fn without_others_the_metadata_service_comes_last() {
766        let dir = data_home();
767        let credentials =
768            from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
769        assert!(
770            format!("{credentials:?}").starts_with("InstanceMetadata"),
771            "{credentials:?}"
772        );
773    }
774
775    #[test]
776    fn the_metadata_service_s_address_can_be_named() {
777        let dir = data_home();
778        let credentials = from_environment(&env(&[
779            ("XDG_DATA_HOME", dir.to_str().unwrap()),
780            (ENV_METADATA_ENDPOINT, "http://127.0.0.1:8080/"),
781        ]))
782        .unwrap();
783        assert!(
784            format!("{credentials:?}").contains(r#"endpoint: "http://127.0.0.1:8080""#),
785            "{credentials:?}"
786        );
787    }
788
789    #[test]
790    fn no_credentials_at_all_without_the_metadata_service() {
791        let dir = data_home();
792        let err = from_environment(&env(&[
793            ("XDG_DATA_HOME", dir.to_str().unwrap()),
794            (ENV_METADATA_DISABLED, "TRUE"),
795        ]))
796        .unwrap_err();
797        assert!(matches!(err, Error::NoCredentials), "{err}");
798    }
799
800    #[test]
801    fn the_session_is_found_under_the_home_directory_without_xdg_data_home() {
802        let path = cli_session_path(&env(&[
803            ("HOME", "/home/someone"),
804            ("USERPROFILE", "/home/someone"),
805        ]))
806        .unwrap();
807        assert_eq!(
808            path,
809            PathBuf::from("/home/someone/.local/share/origincloud/session.json")
810        );
811    }
812
813    #[tokio::test]
814    async fn the_cli_session_is_used_as_stored() {
815        let dir = data_home();
816        write_session(
817            &dir,
818            now() + chrono::Duration::hours(1),
819            now() + chrono::Duration::hours(2),
820        );
821        let credentials =
822            from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
823        assert_eq!(credentials.token().await.unwrap(), "stored-access");
824    }
825
826    #[tokio::test]
827    async fn a_cli_session_past_its_refresh_expiry_asks_to_log_in() {
828        let dir = data_home();
829        let path = write_session(
830            &dir,
831            now() - chrono::Duration::hours(1),
832            now() - chrono::Duration::minutes(1),
833        );
834        let session = CliSession::load_from(path).unwrap().unwrap();
835        let err = session.token().await.unwrap_err();
836        assert!(err.to_string().contains("origin id login"), "{err}");
837    }
838}