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 another one: another cloud's, at the same address, which
422    /// refuses the request as one it doesn't know. Either way, no
423    /// credentials.
424    async fn open_session(&self) -> Result<String, Error> {
425        let request = self
426            .http()?
427            .put(format!("{}/latest/api/token", self.endpoint))
428            .timeout(METADATA_SESSION_TIMEOUT)
429            .header(
430                METADATA_SESSION_TTL_HEADER,
431                METADATA_SESSION_TTL.as_secs().to_string(),
432            );
433        let (status, body) = send_retrying(request)
434            .await
435            .map_err(|_| Error::NoCredentials)?;
436        match status.as_u16() {
437            200 => Ok(String::from_utf8_lossy(&body).trim().to_owned()),
438            403 => Err(refused_through_a_proxy()),
439            503 => Err(Error::Credentials(
440                "the metadata service refused a session (HTTP 503)".to_owned(),
441            )),
442            _ => Err(Error::NoCredentials),
443        }
444    }
445
446    /// The credential, or `None` when the session is no longer valid.
447    async fn request_credential(&self, session: &str) -> Result<Option<Issued>, Error> {
448        let failed =
449            |e: reqwest::Error| Error::Credentials(format!("reaching the metadata service: {e}"));
450        let request = self
451            .http()?
452            .get(format!(
453                "{}/latest/meta-data/service-account/token",
454                self.endpoint
455            ))
456            .timeout(METADATA_REQUEST_TIMEOUT)
457            .header(METADATA_SESSION_HEADER, session)
458            .header(reqwest::header::ACCEPT, "application/json");
459        let (status, body) = send_retrying(request).await.map_err(failed)?;
460        match status.as_u16() {
461            200 => {}
462            401 => return Ok(None),
463            404 => {
464                return Err(Error::Credentials(
465                    "the instance runs as no service account".to_owned(),
466                ));
467            }
468            403 => return Err(refused_through_a_proxy()),
469            status => {
470                return Err(Error::Credentials(format!(
471                    "the metadata service answered HTTP {status}"
472                )));
473            }
474        }
475        match serde_json::from_slice::<Issued>(&body) {
476            Ok(issued) if !issued.access_token.is_empty() => Ok(Some(issued)),
477            _ => Err(Error::Credentials(
478                "the metadata service's answer is not a credential".to_owned(),
479            )),
480        }
481    }
482}
483
484/// Sends `request`, and again after each of [`METADATA_RETRY_DELAYS`] while
485/// the metadata service answers `503`.
486async fn send_retrying(
487    request: reqwest::RequestBuilder,
488) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
489    let mut delays = METADATA_RETRY_DELAYS.iter();
490    loop {
491        let attempt = request
492            .try_clone()
493            .expect("a metadata-service request has no streamed body");
494        let answer = read_bounded(attempt.send().await?).await?;
495        match delays.next() {
496            Some(delay) if answer.0 == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
497                tokio::time::sleep(*delay).await;
498            }
499            _ => return Ok(answer),
500        }
501    }
502}
503
504fn refused_through_a_proxy() -> Error {
505    Error::Credentials(
506        "the metadata service refused a request that came through a proxy".to_owned(),
507    )
508}
509
510impl Default for InstanceMetadata {
511    fn default() -> Self {
512        Self::new()
513    }
514}
515
516impl fmt::Debug for InstanceMetadata {
517    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
518        f.debug_struct("InstanceMetadata")
519            .field("endpoint", &self.endpoint)
520            .finish_non_exhaustive()
521    }
522}
523
524impl Credentials for InstanceMetadata {
525    fn token(&self) -> TokenFuture<'_> {
526        Box::pin(async move {
527            let mut state = self.state.lock().await;
528            if let Some(token) = state.cached.valid_for(METADATA_EXPIRY_MARGIN) {
529                return Ok(token);
530            }
531            // A session refused as no longer valid is opened again, once.
532            for _ in 0..2 {
533                let session = match state.session.valid() {
534                    Some(session) => session,
535                    None => {
536                        let session = self.open_session().await?;
537                        state.session = Cached {
538                            token: session.clone(),
539                            expiry: Some(SystemTime::now() + METADATA_SESSION_TTL),
540                        };
541                        session
542                    }
543                };
544                match self.request_credential(&session).await? {
545                    Some(issued) => {
546                        state.cached = Cached {
547                            token: issued.access_token.clone(),
548                            expiry: Some(issued.expires_at.into()),
549                        };
550                        return Ok(issued.access_token);
551                    }
552                    None => state.session = Cached::default(),
553                }
554            }
555            Err(Error::Credentials(
556                "the metadata service answered HTTP 401".to_owned(),
557            ))
558        })
559    }
560}
561
562/// Where the CLI stores its session: under `XDG_DATA_HOME`, or
563/// `~/.local/share`, on every platform.
564fn cli_session_path(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
565    let base = match env("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
566        Some(base) => PathBuf::from(base),
567        None => home_dir(env)?.join(".local").join("share"),
568    };
569    Some(base.join("origincloud").join("session.json"))
570}
571
572fn home_dir(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
573    let name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
574    env(name).filter(|v| !v.is_empty()).map(PathBuf::from)
575}
576
577/// One access token, replaced once it is about to expire.
578#[derive(Default)]
579struct Cached {
580    token: String,
581    expiry: Option<SystemTime>,
582}
583
584impl Cached {
585    fn valid(&self) -> Option<String> {
586        self.valid_for(EXPIRY_MARGIN)
587    }
588
589    /// The token, unless it expires within `margin`.
590    fn valid_for(&self, margin: Duration) -> Option<String> {
591        let expiry = self.expiry?;
592        (!self.token.is_empty() && SystemTime::now() + margin < expiry).then(|| self.token.clone())
593    }
594}
595
596/// A token endpoint's answer.
597#[derive(Deserialize, Default)]
598#[serde(default)]
599struct Granted {
600    access_token: String,
601    expires_in: i64,
602    refresh_token: String,
603    refresh_expires_in: i64,
604    error: String,
605    error_description: String,
606}
607
608impl Granted {
609    fn expiry(&self) -> SystemTime {
610        SystemTime::now() + seconds(self.expires_in)
611    }
612}
613
614fn seconds(n: i64) -> Duration {
615    Duration::from_secs(u64::try_from(n).unwrap_or_default())
616}
617
618/// The HTTP client token requests are sent with unless the credentials
619/// name one: one for the process, created by the first request.
620fn default_http_client() -> Result<&'static reqwest::Client, &'static str> {
621    static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
622    CLIENT
623        .get_or_init(|| {
624            reqwest::Client::builder()
625                .build()
626                .map_err(|e| e.to_string())
627        })
628        .as_ref()
629        .map_err(String::as_str)
630}
631
632async fn request_token(
633    http: Option<&reqwest::Client>,
634    url: &str,
635    form: &[(&str, &str)],
636) -> Result<Granted, Error> {
637    let failed = |e: &dyn fmt::Display| Error::Credentials(format!("requesting a token: {e}"));
638    let http = match http {
639        Some(http) => http,
640        None => default_http_client().map_err(|e| failed(&e))?,
641    };
642    let response = http
643        .post(url)
644        .timeout(TOKEN_REQUEST_TIMEOUT)
645        .header(reqwest::header::ACCEPT, "application/json")
646        .form(form)
647        .send()
648        .await
649        .map_err(|e| failed(&e))?;
650    let (status, body) = read_bounded(response).await.map_err(|e| failed(&e))?;
651    let granted: Granted = serde_json::from_slice(&body).map_err(|_| {
652        Error::Credentials(format!(
653            "requesting a token: HTTP {}, not a token response",
654            status.as_u16()
655        ))
656    })?;
657    if status != reqwest::StatusCode::OK || granted.access_token.is_empty() {
658        let reason = if granted.error_description.is_empty() {
659            &granted.error
660        } else {
661            &granted.error_description
662        };
663        return Err(Error::Credentials(format!(
664            "the token request was refused (HTTP {}): {reason}",
665            status.as_u16()
666        )));
667    }
668    Ok(granted)
669}
670
671/// A response's status and its body, up to the most of a token response
672/// that is read.
673async fn read_bounded(
674    mut response: reqwest::Response,
675) -> Result<(reqwest::StatusCode, Vec<u8>), reqwest::Error> {
676    let status = response.status();
677    let mut body = Vec::new();
678    while let Some(chunk) = response.chunk().await? {
679        body.extend_from_slice(&chunk);
680        if body.len() > MAX_TOKEN_RESPONSE_BYTES {
681            body.truncate(MAX_TOKEN_RESPONSE_BYTES);
682            break;
683        }
684    }
685    Ok((status, body))
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use std::collections::HashMap;
692
693    fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
694        let vars: HashMap<String, String> = vars
695            .iter()
696            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
697            .collect();
698        move |name| vars.get(name).cloned()
699    }
700
701    fn now() -> DateTime<Utc> {
702        SystemTime::now().into()
703    }
704
705    fn data_home() -> PathBuf {
706        let dir =
707            std::env::temp_dir().join(format!("origincloud-sdk-test-{}", uuid::Uuid::new_v4()));
708        std::fs::create_dir_all(dir.join("origincloud")).unwrap();
709        dir
710    }
711
712    fn write_session(
713        dir: &std::path::Path,
714        expires: DateTime<Utc>,
715        refresh_expires: DateTime<Utc>,
716    ) -> PathBuf {
717        let path = dir.join("origincloud").join("session.json");
718        let data = serde_json::json!({
719            "version": 1,
720            "access_token": "stored-access",
721            "expires": expires.to_rfc3339(),
722            "refresh_token": "stored-refresh",
723            "refresh_expires": refresh_expires.to_rfc3339(),
724            "token_type": "Bearer",
725        });
726        std::fs::write(&path, data.to_string()).unwrap();
727        path
728    }
729
730    #[tokio::test]
731    async fn an_access_token_in_the_environment_comes_first() {
732        let credentials = from_environment(&env(&[
733            (ENV_ACCESS_TOKEN, "from-env"),
734            (ENV_CLIENT_ID, "id"),
735            (ENV_CLIENT_SECRET, "secret"),
736        ]))
737        .unwrap();
738        assert_eq!(credentials.token().await.unwrap(), "from-env");
739    }
740
741    #[test]
742    fn a_service_account_in_the_environment_comes_before_the_cli_session() {
743        let dir = data_home();
744        let hour = chrono::Duration::hours(1);
745        write_session(&dir, now() + hour, now() + hour);
746        let credentials = from_environment(&env(&[
747            ("XDG_DATA_HOME", dir.to_str().unwrap()),
748            (ENV_CLIENT_ID, "id"),
749            (ENV_CLIENT_SECRET, "secret"),
750        ]))
751        .unwrap();
752        assert!(
753            format!("{credentials:?}").starts_with("ServiceAccount"),
754            "{credentials:?}"
755        );
756    }
757
758    #[test]
759    fn half_a_service_account_is_refused() {
760        let err = from_environment(&env(&[(ENV_CLIENT_ID, "id")])).unwrap_err();
761        assert!(err.to_string().contains("must be set together"), "{err}");
762        let err = from_environment(&env(&[(ENV_CLIENT_SECRET, "secret")])).unwrap_err();
763        assert!(err.to_string().contains("must be set together"), "{err}");
764    }
765
766    #[test]
767    fn without_others_the_metadata_service_comes_last() {
768        let dir = data_home();
769        let credentials =
770            from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
771        assert!(
772            format!("{credentials:?}").starts_with("InstanceMetadata"),
773            "{credentials:?}"
774        );
775    }
776
777    #[test]
778    fn the_metadata_service_s_address_can_be_named() {
779        let dir = data_home();
780        let credentials = from_environment(&env(&[
781            ("XDG_DATA_HOME", dir.to_str().unwrap()),
782            (ENV_METADATA_ENDPOINT, "http://127.0.0.1:8080/"),
783        ]))
784        .unwrap();
785        assert!(
786            format!("{credentials:?}").contains(r#"endpoint: "http://127.0.0.1:8080""#),
787            "{credentials:?}"
788        );
789    }
790
791    #[test]
792    fn no_credentials_at_all_without_the_metadata_service() {
793        let dir = data_home();
794        let err = from_environment(&env(&[
795            ("XDG_DATA_HOME", dir.to_str().unwrap()),
796            (ENV_METADATA_DISABLED, "TRUE"),
797        ]))
798        .unwrap_err();
799        assert!(matches!(err, Error::NoCredentials), "{err}");
800    }
801
802    #[test]
803    fn the_session_is_found_under_the_home_directory_without_xdg_data_home() {
804        let path = cli_session_path(&env(&[
805            ("HOME", "/home/someone"),
806            ("USERPROFILE", "/home/someone"),
807        ]))
808        .unwrap();
809        assert_eq!(
810            path,
811            PathBuf::from("/home/someone/.local/share/origincloud/session.json")
812        );
813    }
814
815    #[tokio::test]
816    async fn the_cli_session_is_used_as_stored() {
817        let dir = data_home();
818        write_session(
819            &dir,
820            now() + chrono::Duration::hours(1),
821            now() + chrono::Duration::hours(2),
822        );
823        let credentials =
824            from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
825        assert_eq!(credentials.token().await.unwrap(), "stored-access");
826    }
827
828    #[tokio::test]
829    async fn a_cli_session_past_its_refresh_expiry_asks_to_log_in() {
830        let dir = data_home();
831        let path = write_session(
832            &dir,
833            now() - chrono::Duration::hours(1),
834            now() - chrono::Duration::minutes(1),
835        );
836        let session = CliSession::load_from(path).unwrap().unwrap();
837        let err = session.token().await.unwrap_err();
838        assert!(err.to_string().contains("origin id login"), "{err}");
839    }
840}