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";
24
25/// The OAuth client the CLI logs in as, which its refresh token belongs to.
26const CLI_CLIENT_ID: &str = "origin-cli";
27
28/// How long before its expiry a token is replaced, so that it doesn't
29/// expire on the way.
30const EXPIRY_MARGIN: Duration = Duration::from_secs(30);
31
32/// The most of a token response that is read.
33const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
34
35/// How long a token request may take.
36const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
37
38/// What [`Credentials::token`] returns.
39pub type TokenFuture<'a> = Pin<Box<dyn Future<Output = Result<String, Error>> + Send + 'a>>;
40
41/// Credentials supply the access token each call is sent with.
42pub trait Credentials: Send + Sync + fmt::Debug {
43    /// A valid access token.
44    fn token(&self) -> TokenFuture<'_>;
45}
46
47/// The first credentials found, in this order:
48///
49/// 1. `ORIGINCLOUD_ACCESS_TOKEN`: an access token, sent as it is.
50/// 2. `ORIGINCLOUD_CLIENT_ID` and `ORIGINCLOUD_CLIENT_SECRET`: a service
51///    account's credential, exchanged for access tokens as they expire.
52///    Only one of the two set is an error.
53/// 3. The session the `origin` CLI stored at its last login
54///    (`origin id login`).
55///
56/// It fails with [`Error::NoCredentials`] when there are none.
57pub fn default_credentials() -> Result<Arc<dyn Credentials>, Error> {
58    from_environment(&|name| std::env::var(name).ok())
59}
60
61/// [`default_credentials`] with the environment read through `env`.
62pub(crate) fn from_environment(
63    env: &dyn Fn(&str) -> Option<String>,
64) -> Result<Arc<dyn Credentials>, Error> {
65    let var = |name: &str| env(name).filter(|v| !v.is_empty());
66    if let Some(token) = var(ENV_ACCESS_TOKEN) {
67        return Ok(Arc::new(AccessToken::new(token)));
68    }
69    match (var(ENV_CLIENT_ID), var(ENV_CLIENT_SECRET)) {
70        (Some(id), Some(secret)) => return Ok(Arc::new(ServiceAccount::new(id, secret))),
71        (None, None) => {}
72        _ => {
73            return Err(Error::Config(format!(
74                "{ENV_CLIENT_ID} and {ENV_CLIENT_SECRET} must be set together"
75            )));
76        }
77    }
78    let path = cli_session_path(env).ok_or(Error::NoCredentials)?;
79    match CliSession::load_from(path)? {
80        Some(session) => Ok(Arc::new(session)),
81        None => Err(Error::NoCredentials),
82    }
83}
84
85/// An access token, sent as it is. It is not renewed: a service account's
86/// lives five minutes.
87#[derive(Clone)]
88pub struct AccessToken(String);
89
90impl AccessToken {
91    /// Credentials that always send `token`.
92    pub fn new(token: impl Into<String>) -> Self {
93        Self(token.into())
94    }
95}
96
97impl fmt::Debug for AccessToken {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str("AccessToken(..)")
100    }
101}
102
103impl Credentials for AccessToken {
104    fn token(&self) -> TokenFuture<'_> {
105        Box::pin(async move { Ok(self.0.clone()) })
106    }
107}
108
109/// A service account's client ID and secret, exchanged for access tokens
110/// with the client credentials grant as they expire.
111pub struct ServiceAccount {
112    client_id: String,
113    client_secret: String,
114    token_url: String,
115    http: Option<reqwest::Client>,
116    cache: Mutex<Cached>,
117}
118
119impl ServiceAccount {
120    /// The service account `client_id` with the secret `client_secret`.
121    pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
122        Self {
123            client_id: client_id.into(),
124            client_secret: client_secret.into(),
125            token_url: TOKEN_URL.to_owned(),
126            http: None,
127            cache: Mutex::default(),
128        }
129    }
130
131    /// Requests tokens at `url` instead of [`TOKEN_URL`].
132    pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
133        self.token_url = url.into();
134        self
135    }
136
137    /// Sends the token requests with `http`, instead of the client all
138    /// credentials share. Each request is bounded by 30 seconds either way.
139    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
140        self.http = Some(http);
141        self
142    }
143}
144
145impl fmt::Debug for ServiceAccount {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.debug_struct("ServiceAccount")
148            .field("client_id", &self.client_id)
149            .field("token_url", &self.token_url)
150            .finish_non_exhaustive()
151    }
152}
153
154impl Credentials for ServiceAccount {
155    fn token(&self) -> TokenFuture<'_> {
156        Box::pin(async move {
157            let mut cache = self.cache.lock().await;
158            if let Some(token) = cache.valid() {
159                return Ok(token);
160            }
161            let granted = request_token(
162                self.http.as_ref(),
163                &self.token_url,
164                &[
165                    ("grant_type", "client_credentials"),
166                    ("client_id", &self.client_id),
167                    ("client_secret", &self.client_secret),
168                ],
169            )
170            .await?;
171            cache.token = granted.access_token.clone();
172            cache.expiry = Some(granted.expiry());
173            Ok(granted.access_token)
174        })
175    }
176}
177
178/// The session the `origin` CLI stored at its last login, renewed from its
179/// refresh token when its access token has expired. The renewed session
180/// stays in memory: the CLI's file is never written.
181pub struct CliSession {
182    token_url: String,
183    http: Option<reqwest::Client>,
184    state: Mutex<SessionState>,
185}
186
187struct SessionState {
188    cached: Cached,
189    refresh_token: String,
190    refresh_expiry: Option<SystemTime>,
191}
192
193/// The file the CLI stores its session in.
194#[derive(Deserialize)]
195struct StoredSession {
196    #[serde(default)]
197    access_token: String,
198    #[serde(default)]
199    expires: Option<DateTime<Utc>>,
200    #[serde(default)]
201    refresh_token: String,
202    #[serde(default)]
203    refresh_expires: Option<DateTime<Utc>>,
204}
205
206impl CliSession {
207    /// The session the CLI stored, or `None` when it stored none: under
208    /// `$XDG_DATA_HOME`, or `~/.local/share`, on every platform, at
209    /// `origincloud/session.json`.
210    pub fn load() -> Result<Option<Self>, Error> {
211        match cli_session_path(&|name| std::env::var(name).ok()) {
212            Some(path) => Self::load_from(path),
213            None => Ok(None),
214        }
215    }
216
217    /// The session stored at `path`, or `None` when there is no file.
218    pub fn load_from(path: impl Into<PathBuf>) -> Result<Option<Self>, Error> {
219        let path = path.into();
220        let data = match std::fs::read(&path) {
221            Ok(data) => data,
222            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
223            Err(e) => return Err(Error::Credentials(format!("reading the CLI session: {e}"))),
224        };
225        let stored: StoredSession = serde_json::from_slice(&data).map_err(|e| {
226            Error::Credentials(format!("reading the CLI session {}: {e}", path.display()))
227        })?;
228        Ok(Some(Self {
229            token_url: TOKEN_URL.to_owned(),
230            http: None,
231            state: Mutex::new(SessionState {
232                cached: Cached {
233                    token: stored.access_token,
234                    expiry: stored.expires.map(SystemTime::from),
235                },
236                refresh_token: stored.refresh_token,
237                refresh_expiry: stored.refresh_expires.map(SystemTime::from),
238            }),
239        }))
240    }
241
242    /// Renews the session at `url` instead of [`TOKEN_URL`].
243    pub fn with_token_url(mut self, url: impl Into<String>) -> Self {
244        self.token_url = url.into();
245        self
246    }
247
248    /// Sends the renewals with `http`, instead of the client all
249    /// credentials share. Each renewal is bounded by 30 seconds either way.
250    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
251        self.http = Some(http);
252        self
253    }
254}
255
256impl fmt::Debug for CliSession {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.debug_struct("CliSession")
259            .field("token_url", &self.token_url)
260            .finish_non_exhaustive()
261    }
262}
263
264impl Credentials for CliSession {
265    fn token(&self) -> TokenFuture<'_> {
266        Box::pin(async move {
267            let mut state = self.state.lock().await;
268            if let Some(token) = state.cached.valid() {
269                return Ok(token);
270            }
271            if state
272                .refresh_expiry
273                .is_none_or(|expiry| SystemTime::now() >= expiry)
274            {
275                return Err(Error::Credentials(
276                    "the CLI session has expired: log in again with `origin id login`".to_owned(),
277                ));
278            }
279            let refresh_token = state.refresh_token.clone();
280            let granted = request_token(
281                self.http.as_ref(),
282                &self.token_url,
283                &[
284                    ("grant_type", "refresh_token"),
285                    ("client_id", CLI_CLIENT_ID),
286                    ("refresh_token", &refresh_token),
287                ],
288            )
289            .await
290            .map_err(|e| match e {
291                Error::Credentials(message) => {
292                    Error::Credentials(format!("renewing the CLI session: {message}"))
293                }
294                other => other,
295            })?;
296            if !granted.refresh_token.is_empty() {
297                state.refresh_token = granted.refresh_token.clone();
298                state.refresh_expiry =
299                    Some(SystemTime::now() + seconds(granted.refresh_expires_in));
300            }
301            state.cached = Cached {
302                token: granted.access_token.clone(),
303                expiry: Some(granted.expiry()),
304            };
305            Ok(granted.access_token)
306        })
307    }
308}
309
310/// Where the CLI stores its session: under `XDG_DATA_HOME`, or
311/// `~/.local/share`, on every platform.
312fn cli_session_path(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
313    let base = match env("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
314        Some(base) => PathBuf::from(base),
315        None => home_dir(env)?.join(".local").join("share"),
316    };
317    Some(base.join("origincloud").join("session.json"))
318}
319
320fn home_dir(env: &dyn Fn(&str) -> Option<String>) -> Option<PathBuf> {
321    let name = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
322    env(name).filter(|v| !v.is_empty()).map(PathBuf::from)
323}
324
325/// One access token, replaced once it is about to expire.
326#[derive(Default)]
327struct Cached {
328    token: String,
329    expiry: Option<SystemTime>,
330}
331
332impl Cached {
333    fn valid(&self) -> Option<String> {
334        let expiry = self.expiry?;
335        (!self.token.is_empty() && SystemTime::now() + EXPIRY_MARGIN < expiry)
336            .then(|| self.token.clone())
337    }
338}
339
340/// A token endpoint's answer.
341#[derive(Deserialize, Default)]
342#[serde(default)]
343struct Granted {
344    access_token: String,
345    expires_in: i64,
346    refresh_token: String,
347    refresh_expires_in: i64,
348    error: String,
349    error_description: String,
350}
351
352impl Granted {
353    fn expiry(&self) -> SystemTime {
354        SystemTime::now() + seconds(self.expires_in)
355    }
356}
357
358fn seconds(n: i64) -> Duration {
359    Duration::from_secs(u64::try_from(n).unwrap_or_default())
360}
361
362/// The HTTP client token requests are sent with unless the credentials
363/// name one: one for the process, created by the first request.
364fn default_http_client() -> Result<&'static reqwest::Client, &'static str> {
365    static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
366    CLIENT
367        .get_or_init(|| {
368            reqwest::Client::builder()
369                .build()
370                .map_err(|e| e.to_string())
371        })
372        .as_ref()
373        .map_err(String::as_str)
374}
375
376async fn request_token(
377    http: Option<&reqwest::Client>,
378    url: &str,
379    form: &[(&str, &str)],
380) -> Result<Granted, Error> {
381    let failed = |e: &dyn fmt::Display| Error::Credentials(format!("requesting a token: {e}"));
382    let http = match http {
383        Some(http) => http,
384        None => default_http_client().map_err(|e| failed(&e))?,
385    };
386    let mut response = http
387        .post(url)
388        .timeout(TOKEN_REQUEST_TIMEOUT)
389        .header(reqwest::header::ACCEPT, "application/json")
390        .form(form)
391        .send()
392        .await
393        .map_err(|e| failed(&e))?;
394    let status = response.status();
395    let mut body = Vec::new();
396    while let Some(chunk) = response.chunk().await.map_err(|e| failed(&e))? {
397        body.extend_from_slice(&chunk);
398        if body.len() > MAX_TOKEN_RESPONSE_BYTES {
399            body.truncate(MAX_TOKEN_RESPONSE_BYTES);
400            break;
401        }
402    }
403    let granted: Granted = serde_json::from_slice(&body).map_err(|_| {
404        Error::Credentials(format!(
405            "requesting a token: HTTP {}, not a token response",
406            status.as_u16()
407        ))
408    })?;
409    if status != reqwest::StatusCode::OK || granted.access_token.is_empty() {
410        let reason = if granted.error_description.is_empty() {
411            &granted.error
412        } else {
413            &granted.error_description
414        };
415        return Err(Error::Credentials(format!(
416            "the token request was refused (HTTP {}): {reason}",
417            status.as_u16()
418        )));
419    }
420    Ok(granted)
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use std::collections::HashMap;
427
428    fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
429        let vars: HashMap<String, String> = vars
430            .iter()
431            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
432            .collect();
433        move |name| vars.get(name).cloned()
434    }
435
436    fn now() -> DateTime<Utc> {
437        SystemTime::now().into()
438    }
439
440    fn data_home() -> PathBuf {
441        let dir =
442            std::env::temp_dir().join(format!("origincloud-sdk-test-{}", uuid::Uuid::new_v4()));
443        std::fs::create_dir_all(dir.join("origincloud")).unwrap();
444        dir
445    }
446
447    fn write_session(
448        dir: &std::path::Path,
449        expires: DateTime<Utc>,
450        refresh_expires: DateTime<Utc>,
451    ) -> PathBuf {
452        let path = dir.join("origincloud").join("session.json");
453        let data = serde_json::json!({
454            "version": 1,
455            "access_token": "stored-access",
456            "expires": expires.to_rfc3339(),
457            "refresh_token": "stored-refresh",
458            "refresh_expires": refresh_expires.to_rfc3339(),
459            "token_type": "Bearer",
460        });
461        std::fs::write(&path, data.to_string()).unwrap();
462        path
463    }
464
465    #[tokio::test]
466    async fn an_access_token_in_the_environment_comes_first() {
467        let credentials = from_environment(&env(&[
468            (ENV_ACCESS_TOKEN, "from-env"),
469            (ENV_CLIENT_ID, "id"),
470            (ENV_CLIENT_SECRET, "secret"),
471        ]))
472        .unwrap();
473        assert_eq!(credentials.token().await.unwrap(), "from-env");
474    }
475
476    #[test]
477    fn a_service_account_in_the_environment_comes_before_the_cli_session() {
478        let dir = data_home();
479        let hour = chrono::Duration::hours(1);
480        write_session(&dir, now() + hour, now() + hour);
481        let credentials = from_environment(&env(&[
482            ("XDG_DATA_HOME", dir.to_str().unwrap()),
483            (ENV_CLIENT_ID, "id"),
484            (ENV_CLIENT_SECRET, "secret"),
485        ]))
486        .unwrap();
487        assert!(
488            format!("{credentials:?}").starts_with("ServiceAccount"),
489            "{credentials:?}"
490        );
491    }
492
493    #[test]
494    fn half_a_service_account_is_refused() {
495        let err = from_environment(&env(&[(ENV_CLIENT_ID, "id")])).unwrap_err();
496        assert!(err.to_string().contains("must be set together"), "{err}");
497        let err = from_environment(&env(&[(ENV_CLIENT_SECRET, "secret")])).unwrap_err();
498        assert!(err.to_string().contains("must be set together"), "{err}");
499    }
500
501    #[test]
502    fn no_credentials_at_all() {
503        let dir = data_home();
504        let err = from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap_err();
505        assert!(matches!(err, Error::NoCredentials), "{err}");
506    }
507
508    #[test]
509    fn the_session_is_found_under_the_home_directory_without_xdg_data_home() {
510        let path = cli_session_path(&env(&[
511            ("HOME", "/home/someone"),
512            ("USERPROFILE", "/home/someone"),
513        ]))
514        .unwrap();
515        assert_eq!(
516            path,
517            PathBuf::from("/home/someone/.local/share/origincloud/session.json")
518        );
519    }
520
521    #[tokio::test]
522    async fn the_cli_session_is_used_as_stored() {
523        let dir = data_home();
524        write_session(
525            &dir,
526            now() + chrono::Duration::hours(1),
527            now() + chrono::Duration::hours(2),
528        );
529        let credentials =
530            from_environment(&env(&[("XDG_DATA_HOME", dir.to_str().unwrap())])).unwrap();
531        assert_eq!(credentials.token().await.unwrap(), "stored-access");
532    }
533
534    #[tokio::test]
535    async fn a_cli_session_past_its_refresh_expiry_asks_to_log_in() {
536        let dir = data_home();
537        let path = write_session(
538            &dir,
539            now() - chrono::Duration::hours(1),
540            now() - chrono::Duration::minutes(1),
541        );
542        let session = CliSession::load_from(path).unwrap().unwrap();
543        let err = session.token().await.unwrap_err();
544        assert!(err.to_string().contains("origin id login"), "{err}");
545    }
546}