Skip to main content

qcs_api_client_common/configuration/
mod.rs

1//!
2//! By default, all settings are loaded from files located under your home directory in the
3//! `.qcs` folder. Within that folder:
4//!
5//! * `settings.toml` will be used to load general settings (e.g. which URLs to connect to).
6//! * `secrets.toml` will be used to load tokens for authentication.
7//!
8//! Both files should contain profiles. Your settings should contain a `default_profile_name`
9//! that determines which profile is loaded when no other profile is explicitly provided.
10//!
11//! If you don't have either of these files, see [the QCS credentials guide](https://docs.rigetti.com/qcs/guides/qcs-credentials) for details on how to obtain them.
12//!
13//! You can use environment variables to override values in your configuration:
14//!
15//! * [`SETTINGS_PATH_VAR`]: Set the path of the `settings.toml` file to load.
16//! * [`SECRETS_PATH_VAR`]: Set the path of the `secrets.toml` file to load.
17//! * [`SECRETS_READ_ONLY_VAR`]: Flag indicating whether to treat the `secrets.toml` file as read-only. Disabled by default.
18//!     * Access token updates will _not_ be persisted to the secrets file, regardless of file permissions, for any of the following values (case insensitive): "true", "yes", "1".
19//!     * Access token updates will be persisted to the secrets file if it is writeable for any other value or if unset.
20//! * [`PROFILE_NAME_VAR`]: Override the profile that is loaded by default
21//! * [`QUILC_URL_VAR`]: Override the URL used for requests to the quilc server.
22//! * [`QVM_URL_VAR`]: Override the URL used for requests to the QVM server.
23//! * [`API_URL_VAR`]: Override the URL used for requests to the QCS REST API server.
24//! * [`GRPC_API_URL_VAR`]: Override the URL used for requests to the QCS gRPC API.
25//!
26//! Note that an externally-managed credential cannot be directly
27//! configured by environmental variables - see [`ExternallyManagedCredential`][secrets::ExternallyManagedCredential].
28//!
29//! # Credentials
30//!
31//! A credential in `secrets.toml` works in three ways, by:
32//!
33//! 1. Storing tokens from a PKCE flow directly
34//!
35//! ```toml
36//! [credentials.default.token_payload]
37//! access_token = "..."
38//! refresh_token = "..."
39//! ```
40//!
41//! 2. Delegating completely to an external program which is expected to yield valid tokens
42//!    NOTE: Please read the security notes in [`ExternallyManagedCredential`][secrets::ExternallyManagedCredential].
43//!
44//! ```toml
45//! [credentials.coder.externally_managed]
46//! command = "/usr/bin/coder"
47//! args = ["external-auth", "access-token", "qcs"]
48//! ```
49//!
50//! 3. Using an OAuth client ID + secret to exchange for access tokens, for service-to-service auth
51//!
52//! ```toml
53//! [credentials.service.client_credentials]
54//! client_id = "0oa..."
55//! client_secret = "..."
56//! ```
57//!
58//! The [`ClientConfiguration`] exposes an API for loading and accessing your
59//! configuration.
60
61use crate::configuration::{secrets::SecretAccessToken, tokens::insecure_validate_token_exp};
62#[cfg(feature = "tracing-config")]
63use crate::tracing_configuration::TracingConfiguration;
64use derive_builder::Builder;
65use std::{env, path::PathBuf};
66use tokio_util::sync::CancellationToken;
67
68#[cfg(feature = "stubs")]
69use rigetti_pyo3::pyo3_stub_gen::derive::gen_stub_pyclass;
70
71use self::{
72    secrets::{Credential, Secrets, TokenPayload},
73    settings::Settings,
74};
75
76pub(crate) mod error;
77mod external_command;
78pub mod fs;
79mod oidc;
80mod pkce;
81mod secret_string;
82pub mod secrets;
83pub mod settings;
84pub mod tokens;
85
86pub use error::{LoadError, TokenError};
87#[cfg(feature = "python")]
88pub(crate) mod py;
89
90use settings::AuthServer;
91use tokens::{
92    OAuthGrant, OAuthSession, PkceFlow, RefreshToken, TokenDispatcher, persist_oauth_session,
93};
94
95/// Default profile name.
96pub const DEFAULT_PROFILE_NAME: &str = "default";
97/// Setting this environment variable will change which profile is used from the loaded config files
98pub const PROFILE_NAME_VAR: &str = "QCS_PROFILE_NAME";
99fn env_or_default_profile_name() -> String {
100    env::var(PROFILE_NAME_VAR).unwrap_or_else(|_| DEFAULT_PROFILE_NAME.to_string())
101}
102
103/// Default URL to access the QCS API.
104pub const DEFAULT_API_URL: &str = "https://api.qcs.rigetti.com";
105/// Setting this environment variable will override the URL used to connect to the QCS REST API.
106pub const API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_API_URL";
107fn env_or_default_api_url() -> String {
108    env::var(API_URL_VAR).unwrap_or_else(|_| DEFAULT_API_URL.to_string())
109}
110
111/// Default URL to access the gRPC API.
112pub const DEFAULT_GRPC_API_URL: &str = "https://grpc.qcs.rigetti.com";
113/// Setting this environment variable will override the URL used to connect to the GRPC server.
114pub const GRPC_API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_GRPC_URL";
115fn env_or_default_grpc_url() -> String {
116    env::var(GRPC_API_URL_VAR).unwrap_or_else(|_| DEFAULT_GRPC_API_URL.to_string())
117}
118
119/// Default URL to access QVM.
120pub const DEFAULT_QVM_URL: &str = "http://127.0.0.1:5000";
121/// Setting this environment variable will override the URL used to access the QVM.
122pub const QVM_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QVM_URL";
123fn env_or_default_qvm_url() -> String {
124    env::var(QVM_URL_VAR).unwrap_or_else(|_| DEFAULT_QVM_URL.to_string())
125}
126
127/// Default URL to access `quilc`.
128pub const DEFAULT_QUILC_URL: &str = "tcp://127.0.0.1:5555";
129/// Setting this environment variable will override the URL used to access quilc.
130pub const QUILC_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QUILC_URL";
131fn env_or_default_quilc_url() -> String {
132    env::var(QUILC_URL_VAR).unwrap_or_else(|_| DEFAULT_QUILC_URL.to_string())
133}
134
135/// A configuration suitable for use as a QCS API Client.
136///
137/// This configuration can be constructed in a few ways.
138///
139/// The most common way is to use [`ClientConfiguration::load_default`]. This will load the
140/// configuration associated with your default QCS profile.
141///
142/// When loading your config, any values set by environment variables will override the values in
143/// your configuration files.
144///
145/// You can also build a configuration from scratch using [`ClientConfigurationBuilder`]. Using a
146/// builder bypasses configuration files and environment overrides.
147#[derive(Clone, Debug, Builder)]
148#[cfg_attr(
149    not(feature = "stubs"),
150    builder_struct_attr(optipy::strip_pyo3(only_stubs)),
151    optipy::strip_pyo3(only_stubs)
152)]
153#[cfg_attr(
154    not(feature = "python"),
155    builder_struct_attr(optipy::strip_pyo3),
156    optipy::strip_pyo3
157)]
158#[cfg_attr(
159    feature = "stubs",
160    builder_struct_attr(gen_stub_pyclass),
161    gen_stub_pyclass
162)]
163#[cfg_attr(
164    feature = "python",
165    builder_struct_attr(pyo3::pyclass(
166        module = "qcs_api_client_common._qcs_api_client_common.configuration",
167        from_py_object
168    )),
169    pyo3::pyclass(
170        module = "qcs_api_client_common._qcs_api_client_common.configuration",
171        frozen,
172        from_py_object
173    )
174)]
175pub struct ClientConfiguration {
176    #[builder(private, default = "env_or_default_profile_name()")]
177    #[builder_field_attr(gen_stub(skip))]
178    profile: String,
179
180    /// The key under `[credentials]` in `secrets.toml` that this profile's tokens are stored
181    /// under. This is *not* necessarily the profile name: a profile declares its own
182    /// `credentials_name`, and several profiles may share one credential entry. Tokens must be
183    /// persisted under this name, otherwise a refresh writes to an entry nobody reads and the
184    /// stale credential is retried forever.
185    #[builder(private, default = "env_or_default_profile_name()")]
186    #[builder_field_attr(gen_stub(skip))]
187    credentials_name: String,
188
189    #[doc = "The URL for the QCS REST API."]
190    #[builder(default = "env_or_default_api_url()")]
191    #[builder_field_attr(pyo3(get, set))]
192    #[pyo3(get)]
193    api_url: String,
194
195    #[doc = "The URL for the QCS gRPC API."]
196    #[builder(default = "env_or_default_grpc_url()")]
197    #[builder_field_attr(pyo3(get, set))]
198    #[pyo3(get)]
199    grpc_api_url: String,
200
201    #[doc = "The URL of the quilc server."]
202    #[builder(default = "env_or_default_quilc_url()")]
203    #[builder_field_attr(pyo3(get, set))]
204    #[pyo3(get)]
205    quilc_url: String,
206
207    #[doc = "The URL of the QVM server."]
208    #[builder(default = "env_or_default_qvm_url()")]
209    #[builder_field_attr(pyo3(get, set))]
210    #[pyo3(get)]
211    qvm_url: String,
212
213    /// Provides a single, semi-shared access to user credential tokens.
214    ///
215    /// Note that the tokens are *not* shared when the `ClientConfiguration` is created multiple
216    /// times, e.g. through [`ClientConfiguration::load_default`].
217    #[builder(default, setter(custom))]
218    #[builder_field_attr(pyo3(get))]
219    pub(crate) oauth_session: Option<TokenDispatcher>,
220
221    #[builder(private, default = "ConfigSource::Builder")]
222    #[builder_field_attr(gen_stub(skip))]
223    source: ConfigSource,
224
225    /// Configuration for tracing of network API calls. If `None`, tracing is disabled.
226    #[cfg(feature = "tracing-config")]
227    #[builder(default)]
228    #[builder_field_attr(gen_stub(skip))]
229    tracing_configuration: Option<TracingConfiguration>,
230}
231
232impl ClientConfigurationBuilder {
233    /// The [`OAuthSession`] to use to authenticate with the QCS API.
234    ///
235    /// When set to [`None`], the configuration will not manage an OAuth Session, and access to the
236    /// QCS API will be limited to unauthenticated routes.
237    pub fn oauth_session(&mut self, oauth_session: Option<OAuthSession>) -> &mut Self {
238        self.oauth_session = Some(oauth_session.map(Into::into));
239        self
240    }
241}
242
243/// The common context used to build a [`ClientConfiguration`].
244struct ConfigurationContext {
245    builder: ClientConfigurationBuilder,
246    auth_server: AuthServer,
247    credential: Option<Credential>,
248    /// The [`ConfigSource`] the [`ClientConfigurationBuilder`] was configured with.
249    ///
250    /// Kept alongside the builder (rather than read back off of it) so that callers can persist
251    /// freshly acquired tokens via [`tokens::persist_oauth_session`] before the final
252    /// [`ClientConfiguration`] is built.
253    source: ConfigSource,
254    /// The credentials name the [`ClientConfigurationBuilder`] was configured with, i.e. the key
255    /// the profile's tokens live under in `secrets.toml`. See [`Self::source`].
256    credentials_name: String,
257}
258
259impl ConfigurationContext {
260    fn from_profile(profile_name: Option<String>) -> Result<Self, LoadError> {
261        #[cfg(feature = "tracing-config")]
262        match profile_name.as_ref() {
263            None => tracing::debug!("loading default QCS profile"),
264            Some(profile) => {
265                tracing::debug!("loading QCS profile {profile}")
266            }
267        }
268        let settings = Settings::load()?;
269        let secrets = Secrets::load()?;
270        Self::from_sources(settings, secrets, profile_name)
271    }
272
273    fn from_sources(
274        settings: Settings,
275        mut secrets: Secrets,
276        profile_name: Option<String>,
277    ) -> Result<Self, LoadError> {
278        let Settings {
279            default_profile_name,
280            mut profiles,
281            mut auth_servers,
282            file_path: settings_path,
283        } = settings;
284        let profile_name = profile_name
285            .or_else(|| env::var(PROFILE_NAME_VAR).ok())
286            .unwrap_or(default_profile_name);
287        let profile = profiles
288            .remove(&profile_name)
289            .ok_or(LoadError::ProfileNotFound(profile_name.clone()))?;
290        let auth_server = auth_servers
291            .remove(&profile.auth_server_name)
292            .ok_or_else(|| LoadError::AuthServerNotFound(profile.auth_server_name.clone()))?;
293
294        let secrets_path = secrets.file_path;
295        let credentials_name = profile.credentials_name;
296        let credential = secrets.credentials.remove(&credentials_name);
297
298        let api_url = env::var(API_URL_VAR)
299            .unwrap_or(profile.api_url)
300            .trim_end_matches('/')
301            .to_string();
302        let quilc_url = env::var(QUILC_URL_VAR).unwrap_or(profile.applications.pyquil.quilc_url);
303        let qvm_url = env::var(QVM_URL_VAR).unwrap_or(profile.applications.pyquil.qvm_url);
304        let grpc_api_url = env::var(GRPC_API_URL_VAR)
305            .unwrap_or(profile.grpc_api_url)
306            .trim_end_matches('/')
307            .to_string();
308
309        #[cfg(feature = "tracing-config")]
310        let tracing_configuration =
311            TracingConfiguration::from_env().map_err(LoadError::TracingFilterParseError)?;
312
313        let source = match (settings_path, secrets_path) {
314            (Some(settings_path), Some(secrets_path)) => ConfigSource::File {
315                settings_path,
316                secrets_path,
317            },
318            _ => ConfigSource::Default,
319        };
320
321        let mut builder = ClientConfiguration::builder();
322        builder
323            .profile(profile_name)
324            .credentials_name(credentials_name.clone())
325            .source(source.clone())
326            .api_url(api_url)
327            .quilc_url(quilc_url)
328            .qvm_url(qvm_url)
329            .grpc_api_url(grpc_api_url);
330
331        #[cfg(feature = "tracing-config")]
332        {
333            builder.tracing_configuration(tracing_configuration);
334        }
335
336        Ok(Self {
337            builder,
338            auth_server,
339            credential,
340            source,
341            credentials_name,
342        })
343    }
344}
345
346/// Persists `oauth_session` via [`persist_oauth_session`], logging a warning on failure instead of
347/// returning an error. A session that was just successfully refreshed or logged in is still valid
348/// and usable even if it can't be persisted, so a persistence failure shouldn't prevent returning
349/// it to the caller (mirroring how [`TokenError::Write`] is handled elsewhere).
350async fn persist_or_warn(
351    oauth_session: &OAuthSession,
352    source: &ConfigSource,
353    credentials_name: &str,
354) {
355    if let Err(_error) = persist_oauth_session(oauth_session, source, credentials_name).await {
356        #[cfg(feature = "tracing")]
357        tracing::warn!(
358            "Refreshed QCS credentials but failed to persist them to the secrets file: {_error}"
359        );
360    }
361}
362
363fn credential_to_oauth_session(
364    credential: Option<Credential>,
365    auth_server: AuthServer,
366) -> Option<OAuthSession> {
367    match credential? {
368        Credential::TokenPayload(token_payload) => {
369            let TokenPayload {
370                access_token,
371                refresh_token,
372                ..
373            } = token_payload;
374
375            Some(OAuthSession::new(
376                OAuthGrant::RefreshToken(RefreshToken::new(refresh_token.unwrap_or_default())),
377                auth_server,
378                access_token,
379            ))
380        }
381        Credential::ExternallyManaged(externally_managed) => Some(OAuthSession::new(
382            OAuthGrant::ExternallyManaged(externally_managed.into()),
383            auth_server,
384            None,
385        )),
386        Credential::ClientCredentials(client_credentials) => {
387            let grant = OAuthGrant::ClientCredentials(client_credentials);
388            Some(OAuthSession::new(grant, auth_server, None))
389        }
390    }
391}
392
393impl ClientConfiguration {
394    #[cfg(test)]
395    fn new(
396        settings: Settings,
397        secrets: Secrets,
398        profile_name: Option<String>,
399    ) -> Result<Self, LoadError> {
400        let ConfigurationContext {
401            mut builder,
402            auth_server,
403            credential,
404            ..
405        } = ConfigurationContext::from_sources(settings, secrets, profile_name)?;
406        let oauth_session = credential_to_oauth_session(credential, auth_server);
407        Ok(builder.oauth_session(oauth_session).build()?)
408    }
409
410    /// Attempts to load config files
411    ///
412    /// # Errors
413    ///
414    /// See [`LoadError`]
415    pub fn load_default() -> Result<Self, LoadError> {
416        let base_config = Self::load(None)?;
417        Ok(base_config)
418    }
419
420    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
421    /// specified profile.
422    ///
423    /// # Errors
424    ///
425    /// See [`LoadError`]
426    pub fn load_profile(profile_name: String) -> Result<Self, LoadError> {
427        Self::load(Some(profile_name))
428    }
429
430    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
431    /// specified profile. If no `profile_name` is provided, then a default configuration is
432    /// loaded. When stored OAuth credentials are unavailable, this method falls back to an
433    /// interactive PKCE login flow.
434    ///
435    /// # Errors
436    ///
437    /// See [`LoadError`]
438    pub async fn load_with_login(
439        cancel_token: CancellationToken,
440        profile_name: Option<String>,
441    ) -> Result<Self, LoadError> {
442        let ConfigurationContext {
443            mut builder,
444            auth_server,
445            credential,
446            source,
447            credentials_name,
448        } = ConfigurationContext::from_profile(profile_name)?;
449
450        match credential {
451            // There's no possible logging in here, we expect the credentials to be valid.
452            credential @ Some(
453                Credential::ExternallyManaged(_) | Credential::ClientCredentials(_),
454            ) => {
455                let oauth_session = credential_to_oauth_session(credential, auth_server);
456                return Ok(builder.oauth_session(oauth_session).build()?);
457            }
458            Some(Credential::TokenPayload(TokenPayload {
459                access_token,
460                refresh_token,
461                ..
462            })) => {
463                if let Some(access_token) = access_token
464                    && insecure_validate_token_exp(&access_token).is_ok()
465                {
466                    let refresh_token = refresh_token.clone().unwrap_or_default();
467                    let oauth_session = OAuthSession::new(
468                        OAuthGrant::RefreshToken(RefreshToken::new(refresh_token)),
469                        auth_server,
470                        Some(access_token),
471                    );
472                    return Ok(builder.oauth_session(Some(oauth_session)).build()?);
473                }
474
475                // The access token is invalid, try to refresh it
476                if let Some(refresh_token) = refresh_token
477                    && !refresh_token.is_empty()
478                {
479                    let mut refresh_token = RefreshToken::new(refresh_token);
480
481                    // If the refresh token is valid, use it
482                    if let Ok(access_token) = refresh_token.request_access_token(&auth_server).await
483                    {
484                        let oauth_session = OAuthSession::new(
485                            OAuthGrant::RefreshToken(refresh_token),
486                            auth_server,
487                            Some(access_token),
488                        );
489
490                        // Requesting a new access token may have rotated the refresh token.
491                        persist_or_warn(&oauth_session, &source, &credentials_name).await;
492
493                        return Ok(builder.oauth_session(Some(oauth_session)).build()?);
494                    }
495                }
496
497                #[cfg(feature = "tracing")]
498                tracing::debug!("Stored credentials are invalid, falling back to login flow");
499            }
500            None => {
501                #[cfg(feature = "tracing")]
502                tracing::debug!("No stored credentials found, falling back to login flow",);
503            }
504        }
505
506        // At this point the stored credentials are known to be invalid, so a login is required
507        let pkce_flow = PkceFlow::new_login_flow(cancel_token, &auth_server).await?;
508        let access_token = pkce_flow.access_token.clone();
509        let oauth_session =
510            OAuthSession::from_pkce_flow(pkce_flow, auth_server, Some(access_token));
511
512        // Persist eagerly: without this, the freshly logged-in tokens are only saved once
513        // something later triggers a dispatcher-managed refresh (e.g. the access token expiring
514        // during a later call). If this process exits before that happens, the login is lost and
515        // the next process is forced through the login flow again.
516        persist_or_warn(&oauth_session, &source, &credentials_name).await;
517
518        Ok(builder.oauth_session(Some(oauth_session)).build()?)
519    }
520
521    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
522    /// specified profile. If no `profile_name` is provided, then a default configuration is
523    /// loaded.
524    ///
525    /// # Errors
526    ///
527    /// See [`LoadError`]
528    fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
529        let ConfigurationContext {
530            mut builder,
531            auth_server,
532            credential,
533            ..
534        } = ConfigurationContext::from_profile(profile_name)?;
535        let oauth_session = credential_to_oauth_session(credential, auth_server);
536        Ok(builder.oauth_session(oauth_session).build()?)
537    }
538
539    /// Get a [`ClientConfigurationBuilder`]
540    #[must_use]
541    pub fn builder() -> ClientConfigurationBuilder {
542        ClientConfigurationBuilder::default()
543    }
544
545    /// Get the name of the profile that was loaded, if any.
546    #[must_use]
547    pub fn profile(&self) -> &str {
548        &self.profile
549    }
550
551    /// Get the name of the credential the loaded profile uses, i.e. the key its tokens are stored
552    /// under in `secrets.toml`. This may differ from [`Self::profile`].
553    #[must_use]
554    pub fn credentials_name(&self) -> &str {
555        &self.credentials_name
556    }
557
558    /// Get the URL of the QCS REST API.
559    #[must_use]
560    pub fn api_url(&self) -> &str {
561        &self.api_url
562    }
563
564    /// Get the URL of the QCS gRPC API.
565    #[must_use]
566    pub fn grpc_api_url(&self) -> &str {
567        &self.grpc_api_url
568    }
569
570    /// Get the URL of the quilc server.
571    #[must_use]
572    pub fn quilc_url(&self) -> &str {
573        &self.quilc_url
574    }
575
576    /// Get the URL of the QVM server.
577    #[must_use]
578    pub fn qvm_url(&self) -> &str {
579        &self.qvm_url
580    }
581
582    /// Get the [`TracingConfiguration`].
583    #[cfg(feature = "tracing-config")]
584    #[must_use]
585    pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
586        self.tracing_configuration.as_ref()
587    }
588
589    /// Get the source of the configuration.
590    #[must_use]
591    pub const fn source(&self) -> &ConfigSource {
592        &self.source
593    }
594
595    /// Get a copy of the current [`OAuthSession`].
596    ///
597    /// Note: This is a _copy_, the contained tokens will become stale once they expire.
598    ///
599    /// # Errors
600    ///
601    /// See [`TokenError`]
602    pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
603        Ok(self
604            .oauth_session
605            .as_ref()
606            .ok_or(TokenError::NoRefreshToken)?
607            .tokens()
608            .await)
609    }
610
611    /// Gets the `Bearer` access token, refreshing it if it is expired.
612    ///
613    /// # Errors
614    ///
615    /// See [`TokenError`].
616    pub async fn get_bearer_access_token(&self) -> Result<SecretAccessToken, TokenError> {
617        let dispatcher = self
618            .oauth_session
619            .as_ref()
620            .ok_or_else(|| TokenError::NoCredentials)?;
621        match dispatcher.validate().await {
622            Ok(tokens) => Ok(tokens),
623            #[allow(unused_variables)]
624            Err(e) => {
625                #[cfg(feature = "tracing-config")]
626                tracing::debug!("Refreshing access token because current one is invalid: {e}");
627                dispatcher
628                    .refresh(self.source(), self.credentials_name())
629                    .await
630                    .map(|e| e.access_token().cloned())?
631            }
632        }
633    }
634
635    /// Refreshes the [`Tokens`] in use and returns the new bearer access token.
636    ///
637    /// # Errors
638    ///
639    /// See [`TokenError`]
640    pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
641        self.oauth_session
642            .as_ref()
643            .ok_or(TokenError::NoRefreshToken)?
644            .refresh(self.source(), self.credentials_name())
645            .await
646    }
647}
648
649/// Describes how a [`ClientConfiguration`] was initialized.
650#[derive(Clone, Debug)]
651pub enum ConfigSource {
652    /// A [`ClientConfiguration`] derived from a [`ClientConfigurationBuilder`]
653    Builder,
654    /// A [`ClientConfiguration`] derived from at least one file.
655    File {
656        /// The path to the QCS `settings.toml` file used to initialize the [`ClientConfiguration`].
657        settings_path: PathBuf,
658        /// The path to a QCS `secrets.toml` file used to initialize the [`ClientConfiguration`].
659        secrets_path: PathBuf,
660    },
661    /// A [`ClientConfiguration`] derived from default values.
662    Default,
663}
664
665fn expand_path_from_env_or_default(
666    env_var_name: &str,
667    default: &str,
668) -> Result<PathBuf, LoadError> {
669    match env::var(env_var_name) {
670        Ok(path) => {
671            let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
672            let path_buf: PathBuf = expanded_path.as_ref().into();
673            if !path_buf.exists() {
674                return Err(LoadError::Path {
675                    path: path_buf,
676                    message: format!("The given path does not exist: {path}"),
677                });
678            }
679            Ok(path_buf)
680        }
681        Err(env::VarError::NotPresent) => {
682            let expanded_path = shellexpand::tilde_with_context(default, || {
683                env::home_dir().map(|path| path.display().to_string())
684            });
685            let path_buf: PathBuf = expanded_path.as_ref().into();
686            if !path_buf.exists() {
687                return Err(LoadError::Path {
688                    path: path_buf,
689                    message: format!(
690                        "Could not find a QCS configuration at the default path: {default}"
691                    ),
692                });
693            }
694            Ok(path_buf)
695        }
696        Err(other_error) => Err(LoadError::EnvVar {
697            variable_name: env_var_name.to_string(),
698            message: other_error.to_string(),
699        }),
700    }
701}
702
703#[cfg(test)]
704mod test {
705    #![allow(clippy::result_large_err, reason = "happens in figment tests")]
706
707    use httpmock::prelude::*;
708    use jsonwebtoken::{EncodingKey, Header, encode};
709    use serde::Serialize;
710    use time::{Duration, OffsetDateTime};
711    use tokio_util::sync::CancellationToken;
712
713    use crate::configuration::{
714        API_URL_VAR, AuthServer, ClientConfiguration, DEFAULT_QUILC_URL, GRPC_API_URL_VAR,
715        OAuthGrant, OAuthSession, QUILC_URL_VAR, QVM_URL_VAR, RefreshToken,
716        expand_path_from_env_or_default, oidc,
717        pkce::tests::PkceTestServerHarness,
718        secrets::{
719            Credential, SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR, SecretAccessToken,
720            SecretRefreshToken, Secrets, TokenPayload,
721        },
722        settings::{SETTINGS_PATH_VAR, Settings},
723        tokens::{ClientCredentialsResponse, RefreshTokenResponse, TokenRefresher},
724    };
725
726    use super::{settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials};
727
728    /// Unwrap a [`Credential`] into its [`TokenPayload`], panicking for any other credential kind.
729    fn expect_token_payload(credential: Credential) -> TokenPayload {
730        match credential {
731            Credential::TokenPayload(payload) => payload,
732            Credential::ExternallyManaged(_) | Credential::ClientCredentials(_) => {
733                panic!("expected a token payload credential")
734            }
735        }
736    }
737
738    #[test]
739    fn expands_env_var() {
740        figment::Jail::expect_with(|jail| {
741            let dir = jail.create_dir("~/blah/blah/")?;
742            jail.create_file(dir.join("file.toml"), "")?;
743            jail.set_env("SOME_PATH", "blah/blah");
744            jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
745            let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
746            assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
747
748            Ok(())
749        });
750    }
751
752    #[test]
753    fn uses_env_var_overrides() {
754        figment::Jail::expect_with(|jail| {
755            let quilc_url = "tcp://quilc:5555";
756            let qvm_url = "http://qvm:5000";
757            let grpc_url = "http://grpc:80";
758            let api_url = "http://api:80";
759
760            jail.set_env(QUILC_URL_VAR, quilc_url);
761            jail.set_env(QVM_URL_VAR, qvm_url);
762            jail.set_env(API_URL_VAR, api_url);
763            jail.set_env(GRPC_API_URL_VAR, grpc_url);
764
765            let config = ClientConfiguration::new(
766                Settings::default(),
767                Secrets::default(),
768                Some("default".to_string()),
769            )
770            .expect("Should be able to build default config.");
771
772            assert_eq!(config.quilc_url, quilc_url);
773            assert_eq!(config.qvm_url, qvm_url);
774            assert_eq!(config.grpc_api_url, grpc_url);
775
776            Ok(())
777        });
778    }
779
780    #[tokio::test]
781    async fn test_default_uses_env_var_overrides() {
782        figment::Jail::expect_with(|jail| {
783            let quilc_url = "quilc_url";
784            let qvm_url = "qvm_url";
785            let grpc_url = "grpc_url";
786            let api_url = "api_url";
787
788            jail.set_env(QUILC_URL_VAR, quilc_url);
789            jail.set_env(QVM_URL_VAR, qvm_url);
790            jail.set_env(GRPC_API_URL_VAR, grpc_url);
791            jail.set_env(API_URL_VAR, api_url);
792
793            let config = ClientConfiguration::load_default().unwrap();
794            assert_eq!(config.quilc_url, quilc_url);
795            assert_eq!(config.qvm_url, qvm_url);
796            assert_eq!(config.grpc_api_url, grpc_url);
797            assert_eq!(config.api_url, api_url);
798
799            Ok(())
800        });
801    }
802
803    #[test]
804    fn test_default_loads_settings_with_partial_profile_applications() {
805        figment::Jail::expect_with(|jail| {
806            let directory = jail.directory();
807            let settings_file_name = "settings.toml";
808            let settings_file_path = directory.join(settings_file_name);
809
810            let quilc_url_env_var = "env-var://quilc.url/after";
811
812            let settings_file_contents = r#"
813default_profile_name = "default"
814
815[profiles]
816[profiles.default]
817api_url = ""
818auth_server_name = "default"
819credentials_name = "default"
820applications = {}
821
822[auth_servers]
823[auth_servers.default]
824client_id = ""
825issuer = ""
826"#;
827            jail.create_file(settings_file_name, settings_file_contents)
828                .expect("should create test settings.toml");
829
830            jail.set_env(
831                "QCS_SETTINGS_FILE_PATH",
832                settings_file_path
833                    .to_str()
834                    .expect("settings file path should be a string"),
835            );
836
837            // before setting env var
838            let config = ClientConfiguration::load_default().unwrap();
839            assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
840
841            jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
842
843            // after setting env var
844            let config = ClientConfiguration::load_default().unwrap();
845            assert_eq!(config.quilc_url, quilc_url_env_var);
846
847            Ok(())
848        });
849    }
850
851    #[test]
852    fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
853        figment::Jail::expect_with(|jail| {
854            let directory = jail.directory();
855            let settings_file_name = "settings.toml";
856            let settings_file_path = directory.join(settings_file_name);
857
858            let quilc_url_settings_toml = "settings-toml://quilc.url";
859            let quilc_url_env_var = "env-var://quilc.url/after";
860
861            let settings_file_contents = format!(
862                r#"
863default_profile_name = "default"
864
865[profiles]
866[profiles.default]
867api_url = ""
868auth_server_name = "default"
869credentials_name = "default"
870applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
871
872[auth_servers]
873[auth_servers.default]
874client_id = ""
875issuer = ""
876"#
877            );
878
879            jail.create_file(settings_file_name, &settings_file_contents)
880                .expect("should create test settings.toml");
881
882            jail.set_env(
883                "QCS_SETTINGS_FILE_PATH",
884                settings_file_path
885                    .to_str()
886                    .expect("settings file path should be a string"),
887            );
888
889            // before setting env var
890            let config = ClientConfiguration::load_default().unwrap();
891            assert_eq!(config.quilc_url, quilc_url_settings_toml);
892
893            jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
894
895            // after setting env var
896            let config = ClientConfiguration::load_default().unwrap();
897            assert_eq!(config.quilc_url, quilc_url_env_var);
898
899            Ok(())
900        });
901    }
902
903    #[tokio::test]
904    async fn test_hydrate_access_token_on_load() {
905        let mut config = ClientConfiguration::builder().build().unwrap();
906        let access_token = "test_access_token";
907        figment::Jail::expect_with(|jail| {
908            let directory = jail.directory();
909            let settings_file_name = "settings.toml";
910            let settings_file_path = directory.join(settings_file_name);
911            let secrets_file_name = "secrets.toml";
912            let secrets_file_path = directory.join(secrets_file_name);
913
914            let settings_file_contents = r#"
915default_profile_name = "default"
916
917[profiles]
918[profiles.default]
919api_url = ""
920auth_server_name = "default"
921credentials_name = "default"
922
923[auth_servers]
924[auth_servers.default]
925client_id = ""
926issuer = ""
927"#;
928
929            let secrets_file_contents = format!(
930                r#"
931[credentials]
932[credentials.default]
933[credentials.default.token_payload]
934access_token = "{access_token}"
935expires_in = 3600
936id_token = "id_token"
937refresh_token = "refresh_token"
938scope = "offline_access openid profile email"
939token_type = "Bearer"
940"#
941            );
942
943            jail.create_file(settings_file_name, settings_file_contents)
944                .expect("should create test settings.toml");
945            jail.create_file(secrets_file_name, &secrets_file_contents)
946                .expect("should create test settings.toml");
947
948            jail.set_env(
949                "QCS_SETTINGS_FILE_PATH",
950                settings_file_path
951                    .to_str()
952                    .expect("settings file path should be a string"),
953            );
954            jail.set_env(
955                "QCS_SECRETS_FILE_PATH",
956                secrets_file_path
957                    .to_str()
958                    .expect("secrets file path should be a string"),
959            );
960
961            config = ClientConfiguration::load_default().unwrap();
962            Ok(())
963        });
964        assert_eq!(
965            config.get_access_token().await.unwrap().unwrap(),
966            SecretAccessToken::from(access_token)
967        );
968    }
969
970    #[derive(Clone, Debug, Serialize)]
971    struct Claims {
972        exp: i64,
973        iss: String,
974        sub: String,
975    }
976
977    impl Default for Claims {
978        fn default() -> Self {
979            Self {
980                exp: 0,
981                iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
982                sub: "qcs@rigetti.com".to_string(),
983            }
984        }
985    }
986
987    impl Claims {
988        fn new_valid() -> Self {
989            Self {
990                exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
991                ..Self::default()
992            }
993        }
994
995        fn new_expired() -> Self {
996            Self {
997                exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
998                ..Self::default()
999            }
1000        }
1001
1002        fn to_encoded(&self) -> String {
1003            encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
1004        }
1005
1006        fn to_access_token(&self) -> SecretAccessToken {
1007            SecretAccessToken::from(self.to_encoded())
1008        }
1009    }
1010
1011    #[test]
1012    fn test_valid_token() {
1013        let valid_token = Claims::new_valid().to_access_token();
1014        let tokens = OAuthSession::from_refresh_token(
1015            RefreshToken::new(SecretRefreshToken::from("unused")),
1016            AuthServer::default(),
1017            Some(valid_token.clone()),
1018        );
1019        assert_eq!(
1020            tokens
1021                .validate()
1022                .expect("Token should not fail validation."),
1023            valid_token
1024        );
1025    }
1026
1027    #[test]
1028    fn test_expired_token() {
1029        let invalid_token = Claims::new_expired().to_access_token();
1030        let tokens = OAuthSession::from_refresh_token(
1031            RefreshToken::new(SecretRefreshToken::from("unused")),
1032            AuthServer::default(),
1033            Some(invalid_token),
1034        );
1035        assert!(tokens.validate().is_err());
1036    }
1037
1038    #[test]
1039    fn test_client_credentials_without_access_token() {
1040        let tokens = OAuthSession::from_client_credentials(
1041            ClientCredentials::new("client_id", "client_secret"),
1042            AuthServer::default(),
1043            None,
1044        );
1045        assert!(tokens.validate().is_err());
1046    }
1047
1048    #[tokio::test]
1049    async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
1050        let access_token = Claims::new_valid().to_encoded();
1051        let mut config = ClientConfiguration::builder().build().unwrap();
1052        figment::Jail::expect_with(|jail| {
1053            let directory = jail.directory();
1054            let settings_file_name = "settings.toml";
1055            let settings_file_path = directory.join(settings_file_name);
1056            let secrets_file_name = "secrets.toml";
1057            let secrets_file_path = directory.join(secrets_file_name);
1058
1059            let settings_file_contents = r#"
1060default_profile_name = "default"
1061
1062[profiles]
1063[profiles.default]
1064api_url = ""
1065auth_server_name = "default"
1066credentials_name = "default"
1067
1068[auth_servers]
1069[auth_servers.default]
1070client_id = ""
1071issuer = ""
1072"#;
1073
1074            // note this has no `refresh_token` property
1075            let secrets_file_contents = format!(
1076                r#"
1077[credentials]
1078[credentials.default]
1079[credentials.default.token_payload]
1080access_token = "{access_token}"
1081expires_in = 3600
1082id_token = "id_token"
1083scope = "offline_access openid profile email"
1084token_type = "Bearer"
1085"#
1086            );
1087
1088            jail.create_file(settings_file_name, settings_file_contents)
1089                .expect("should create test settings.toml");
1090            jail.create_file(secrets_file_name, &secrets_file_contents)
1091                .expect("should create test secrets.toml");
1092
1093            jail.set_env(
1094                "QCS_SETTINGS_FILE_PATH",
1095                settings_file_path
1096                    .to_str()
1097                    .expect("settings file path should be a string"),
1098            );
1099            jail.set_env(
1100                "QCS_SECRETS_FILE_PATH",
1101                secrets_file_path
1102                    .to_str()
1103                    .expect("secrets file path should be a string"),
1104            );
1105
1106            config = ClientConfiguration::load_default().unwrap();
1107            Ok(())
1108        });
1109
1110        assert_eq!(
1111            config.get_bearer_access_token().await.unwrap(),
1112            SecretAccessToken::from(access_token)
1113        );
1114    }
1115
1116    /// Exercises the PKCE login flow end-to-end, ensuring that the token is persisted to the secrets file.
1117    #[test]
1118    #[serial_test::serial(oauth2_test_server)]
1119    fn test_pkce_flow_persists_token() {
1120        // Because we need to block on the runtime inside the jail function,
1121        // we have to create one manually here instead of relying on #[tokio::test].
1122        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1123
1124        let PkceTestServerHarness {
1125            server,
1126            client,
1127            discovery: _,
1128            redirect_port: _,
1129        } = runtime.block_on(PkceTestServerHarness::new());
1130
1131        let client_id = client.client_id;
1132        let issuer = server.issuer().to_string();
1133
1134        figment::Jail::expect_with(|jail| {
1135            // In CI, the secrets file is mounted as read-only,
1136            // but these tmp testing files should be writable.
1137            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1138
1139            let directory = jail.directory();
1140            let settings_file_name = "settings.toml";
1141            let settings_file_path = directory.join(settings_file_name);
1142
1143            let secrets_file_name = "secrets.toml";
1144            let secrets_file_path = directory.join(secrets_file_name);
1145
1146            let settings_file_contents = format!(
1147                r#"
1148default_profile_name = "default"
1149
1150[profiles]
1151[profiles.default]
1152api_url = ""
1153auth_server_name = "default"
1154credentials_name = "default"
1155
1156[auth_servers]
1157[auth_servers.default]
1158client_id = "{client_id}"
1159issuer = "{issuer}"
1160"#
1161            );
1162
1163            let secrets_file_contents = r#"
1164[credentials]
1165[credentials.default]
1166[credentials.default.token_payload]
1167access_token = ""
1168"#;
1169
1170            jail.create_file(settings_file_name, &settings_file_contents)
1171                .expect("should create test settings.toml");
1172
1173            jail.set_env(
1174                SETTINGS_PATH_VAR,
1175                settings_file_path
1176                    .to_str()
1177                    .expect("settings file path should be a string"),
1178            );
1179
1180            jail.create_file(secrets_file_name, secrets_file_contents)
1181                .expect("should create test secrets.toml");
1182
1183            jail.set_env(
1184                SECRETS_PATH_VAR,
1185                secrets_file_path
1186                    .to_str()
1187                    .expect("secrets file path should be a string"),
1188            );
1189
1190            // should perform a login flow, which persists the token to the secrets file.
1191            runtime.block_on(async {
1192                let cancel_token = CancellationToken::new();
1193                // should load the configuration and perform a login flow
1194                let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1195                    .await
1196                    .expect("should load configuration");
1197                let oauth_session = configuration.refresh().await.expect("should refresh");
1198                let token = oauth_session.validate().expect("token should be valid");
1199
1200                // now, the configuration should load without needing to perform a login flow
1201                let configuration =
1202                    ClientConfiguration::load_default().expect("should load configuration");
1203
1204                let oauth_session = configuration
1205                    .oauth_session()
1206                    .await
1207                    .expect("should get oauth session");
1208
1209                let token_payload = expect_token_payload(
1210                    Secrets::load_from_path(&secrets_file_path)
1211                        .expect("should load secrets")
1212                        .credentials
1213                        .remove("default")
1214                        .expect("should get default credentials"),
1215                );
1216
1217                assert_eq!(
1218                    token,
1219                    oauth_session.validate().expect("should contain token"),
1220                    "session: {oauth_session:?}, token_payload: {token_payload:?}",
1221                );
1222                assert_eq!(
1223                    token_payload.access_token,
1224                    Some(token),
1225                    "session: {oauth_session:?}, token_payload: {token_payload:?}"
1226                );
1227                assert_ne!(
1228                    token_payload.refresh_token, None,
1229                    "session: {oauth_session:?}, token_payload: {token_payload:?}"
1230                );
1231            });
1232
1233            Ok(())
1234        });
1235
1236        drop(server);
1237    }
1238
1239    /// Exercises the "no valid credential, perform an interactive login" branch of
1240    /// [`ClientConfiguration::load_with_login`], ensuring the resulting tokens are persisted to the
1241    /// secrets file immediately — unlike [`test_pkce_flow_persists_token`], this does NOT call
1242    /// `.refresh()` afterward. If `load_with_login` doesn't persist the login on its own, a process
1243    /// that exits before anything else triggers a dispatcher-managed refresh would lose the login
1244    /// entirely, forcing the next process back through an interactive login too.
1245    #[test]
1246    #[serial_test::serial(oauth2_test_server)]
1247    fn test_load_with_login_persists_login_flow_token_without_explicit_refresh() {
1248        // Because we need to block on the runtime inside the jail function,
1249        // we have to create one manually here instead of relying on #[tokio::test].
1250        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1251
1252        let PkceTestServerHarness {
1253            server,
1254            client,
1255            discovery: _,
1256            redirect_port: _,
1257        } = runtime.block_on(PkceTestServerHarness::new());
1258
1259        let client_id = client.client_id;
1260        let issuer = server.issuer().to_string();
1261
1262        figment::Jail::expect_with(|jail| {
1263            // In CI, the secrets file is mounted as read-only,
1264            // but these tmp testing files should be writable.
1265            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1266
1267            let directory = jail.directory();
1268            let settings_file_name = "settings.toml";
1269            let settings_file_path = directory.join(settings_file_name);
1270
1271            let secrets_file_name = "secrets.toml";
1272            let secrets_file_path = directory.join(secrets_file_name);
1273
1274            let settings_file_contents = format!(
1275                r#"
1276default_profile_name = "default"
1277
1278[profiles]
1279[profiles.default]
1280api_url = ""
1281auth_server_name = "default"
1282credentials_name = "default"
1283
1284[auth_servers]
1285[auth_servers.default]
1286client_id = "{client_id}"
1287issuer = "{issuer}"
1288"#
1289            );
1290
1291            let secrets_file_contents = r#"
1292[credentials]
1293[credentials.default]
1294[credentials.default.token_payload]
1295access_token = ""
1296"#;
1297
1298            jail.create_file(settings_file_name, &settings_file_contents)
1299                .expect("should create test settings.toml");
1300
1301            jail.set_env(
1302                SETTINGS_PATH_VAR,
1303                settings_file_path
1304                    .to_str()
1305                    .expect("settings file path should be a string"),
1306            );
1307
1308            jail.create_file(secrets_file_name, secrets_file_contents)
1309                .expect("should create test secrets.toml");
1310
1311            jail.set_env(
1312                SECRETS_PATH_VAR,
1313                secrets_file_path
1314                    .to_str()
1315                    .expect("secrets file path should be a string"),
1316            );
1317
1318            runtime.block_on(async {
1319                let cancel_token = CancellationToken::new();
1320
1321                // Deliberately do NOT call `.refresh()` afterward: `load_with_login` itself
1322                // should persist the freshly logged-in tokens.
1323                let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1324                    .await
1325                    .expect("should perform a login flow");
1326
1327                let oauth_session = configuration
1328                    .oauth_session()
1329                    .await
1330                    .expect("should get oauth session");
1331                let token = oauth_session.validate().expect("token should be valid");
1332
1333                let token_payload = expect_token_payload(
1334                    Secrets::load_from_path(&secrets_file_path)
1335                        .expect("should load secrets")
1336                        .credentials
1337                        .remove("default")
1338                        .expect("should get default credentials"),
1339                );
1340
1341                assert_eq!(
1342                    token_payload.access_token,
1343                    Some(token),
1344                    "the access token from the login flow should be persisted without an \
1345                     explicit follow-up refresh"
1346                );
1347                assert!(
1348                    token_payload.refresh_token.is_some(),
1349                    "the refresh token from the login flow should be persisted without an \
1350                     explicit follow-up refresh"
1351                );
1352            });
1353
1354            Ok(())
1355        });
1356
1357        drop(server);
1358    }
1359
1360    /// Exercises the "refresh the stored refresh token" branch of [`ClientConfiguration::load_with_login`],
1361    /// which is taken when the stored access token has expired but a refresh token is still on file.
1362    ///
1363    /// This ensures that a rotated refresh token returned by the auth server is (a) reflected in the
1364    /// in-memory [`OAuthSession`] and (b) persisted back to the secrets file. If the rotated refresh
1365    /// token is not persisted, the next process to load this profile will retry the stale, already-consumed
1366    /// refresh token, fail, and be forced into an interactive login flow every time.
1367    #[test]
1368    #[serial_test::serial(oauth2_test_server)]
1369    fn test_load_with_login_persists_rotated_refresh_token_on_refresh() {
1370        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1371
1372        let mock_server = runtime.block_on(MockServer::start_async());
1373
1374        let new_access_token = Claims::new_valid().to_encoded();
1375        let rotated_refresh_token = "rotated_refresh_token".to_string();
1376
1377        let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1378            when.method(GET).path("/.well-known/openid-configuration");
1379            then.status(200)
1380                .json_body_obj(&oidc::Discovery::new_for_test(
1381                    mock_server.base_url().parse().unwrap(),
1382                ));
1383        }));
1384
1385        let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1386            when.method(POST).path("/v1/token");
1387            then.status(200).json_body_obj(&RefreshTokenResponse {
1388                access_token: SecretAccessToken::from(new_access_token.clone()),
1389                refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1390            });
1391        }));
1392
1393        let client_id = "client_id";
1394        let issuer = mock_server.base_url();
1395        let initial_refresh_token = "initial_refresh_token";
1396        let expired_access_token = Claims::new_expired().to_encoded();
1397
1398        figment::Jail::expect_with(|jail| {
1399            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1400
1401            let directory = jail.directory();
1402            let settings_file_name = "settings.toml";
1403            let settings_file_path = directory.join(settings_file_name);
1404
1405            let secrets_file_name = "secrets.toml";
1406            let secrets_file_path = directory.join(secrets_file_name);
1407
1408            let settings_file_contents = format!(
1409                r#"
1410default_profile_name = "default"
1411
1412[profiles]
1413[profiles.default]
1414api_url = ""
1415auth_server_name = "default"
1416credentials_name = "default"
1417
1418[auth_servers]
1419[auth_servers.default]
1420client_id = "{client_id}"
1421issuer = "{issuer}"
1422"#
1423            );
1424
1425            let secrets_file_contents = format!(
1426                r#"
1427[credentials]
1428[credentials.default]
1429[credentials.default.token_payload]
1430access_token = "{expired_access_token}"
1431refresh_token = "{initial_refresh_token}"
1432"#
1433            );
1434
1435            jail.create_file(settings_file_name, &settings_file_contents)
1436                .expect("should create test settings.toml");
1437            jail.set_env(
1438                SETTINGS_PATH_VAR,
1439                settings_file_path
1440                    .to_str()
1441                    .expect("settings file path should be a string"),
1442            );
1443
1444            jail.create_file(secrets_file_name, &secrets_file_contents)
1445                .expect("should create test secrets.toml");
1446            jail.set_env(
1447                SECRETS_PATH_VAR,
1448                secrets_file_path
1449                    .to_str()
1450                    .expect("secrets file path should be a string"),
1451            );
1452
1453            runtime.block_on(async {
1454                let cancel_token = CancellationToken::new();
1455
1456                // The expired access token should be refreshed using the stored refresh token,
1457                // without falling back to an interactive login flow.
1458                let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1459                    .await
1460                    .expect("should refresh using the stored refresh token");
1461
1462                oidc_mock.assert_async().await;
1463                issuer_mock.assert_async().await;
1464
1465                let oauth_session = configuration
1466                    .oauth_session()
1467                    .await
1468                    .expect("should get oauth session");
1469
1470                assert_eq!(
1471                    oauth_session.access_token().cloned().ok(),
1472                    Some(SecretAccessToken::from(new_access_token.clone())),
1473                    "in-memory access token should be the freshly refreshed one"
1474                );
1475
1476                match oauth_session.payload() {
1477                    OAuthGrant::RefreshToken(payload) => {
1478                        assert_eq!(
1479                            payload.refresh_token,
1480                            SecretRefreshToken::from(rotated_refresh_token.clone()),
1481                            "in-memory refresh token should be updated to the rotated value"
1482                        );
1483                    }
1484                    other => panic!("expected a RefreshToken grant, got {other:?}"),
1485                }
1486
1487                let token_payload = expect_token_payload(
1488                    Secrets::load_from_path(&secrets_file_path)
1489                        .expect("should load secrets")
1490                        .credentials
1491                        .remove("default")
1492                        .expect("should get default credentials"),
1493                );
1494
1495                assert_eq!(
1496                    token_payload.access_token,
1497                    Some(SecretAccessToken::from(new_access_token.clone())),
1498                    "new access token should be persisted to the secrets file"
1499                );
1500                assert_eq!(
1501                    token_payload.refresh_token,
1502                    Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1503                    "rotated refresh token should be persisted to the secrets file, otherwise \
1504                     the next process to load this profile will retry the stale, \
1505                     already-consumed refresh token and be forced back into a login flow"
1506                );
1507            });
1508
1509            Ok(())
1510        });
1511    }
1512
1513    /// A profile's `credentials_name` may differ from the profile's own name, and several profiles
1514    /// may point at the same credential. Tokens are *read* from `credentials.<credentials_name>`,
1515    /// so they must also be *written* there.
1516    ///
1517    /// Persisting under the profile name instead means every refresh lands in an entry nobody
1518    /// reads, leaving the credential actually in use permanently stale: the profile works until
1519    /// the access token expires (~1 hour), then retries the same consumed refresh token forever.
1520    ///
1521    /// This exercises the runtime path taken by ordinary API calls
1522    /// ([`ClientConfiguration::get_bearer_access_token`] -> [`TokenDispatcher::refresh`]), not just
1523    /// the login path.
1524    #[test]
1525    #[serial_test::serial(oauth2_test_server)]
1526    fn test_refresh_persists_to_credentials_name_not_profile_name() {
1527        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1528
1529        let mock_server = runtime.block_on(MockServer::start_async());
1530
1531        let new_access_token = Claims::new_valid().to_encoded();
1532        let rotated_refresh_token = "rotated_refresh_token".to_string();
1533
1534        let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1535            when.method(GET).path("/.well-known/openid-configuration");
1536            then.status(200)
1537                .json_body_obj(&oidc::Discovery::new_for_test(
1538                    mock_server.base_url().parse().unwrap(),
1539                ));
1540        }));
1541
1542        let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1543            when.method(POST).path("/v1/token");
1544            then.status(200).json_body_obj(&RefreshTokenResponse {
1545                access_token: SecretAccessToken::from(new_access_token.clone()),
1546                refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1547            });
1548        }));
1549
1550        let client_id = "client_id";
1551        let issuer = mock_server.base_url();
1552        let initial_refresh_token = "initial_refresh_token";
1553        let expired_access_token = Claims::new_expired().to_encoded();
1554
1555        // The profile and the credential it uses are deliberately named differently.
1556        let profile_name = "funnel";
1557        let credentials_name = "shared";
1558
1559        figment::Jail::expect_with(|jail| {
1560            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1561
1562            let directory = jail.directory();
1563            let settings_file_name = "settings.toml";
1564            let settings_file_path = directory.join(settings_file_name);
1565
1566            let secrets_file_name = "secrets.toml";
1567            let secrets_file_path = directory.join(secrets_file_name);
1568
1569            let settings_file_contents = format!(
1570                r#"
1571default_profile_name = "{profile_name}"
1572
1573[profiles]
1574[profiles.{profile_name}]
1575api_url = ""
1576auth_server_name = "default"
1577credentials_name = "{credentials_name}"
1578
1579[auth_servers]
1580[auth_servers.default]
1581client_id = "{client_id}"
1582issuer = "{issuer}"
1583"#
1584            );
1585
1586            // A decoy credential named after the profile. Persisting by profile name would write
1587            // here, silently succeed, and leave `{credentials_name}` (the one actually loaded)
1588            // stale, which is exactly the failure this test guards against.
1589            let secrets_file_contents = format!(
1590                r#"
1591[credentials]
1592[credentials.{credentials_name}]
1593[credentials.{credentials_name}.token_payload]
1594access_token = "{expired_access_token}"
1595refresh_token = "{initial_refresh_token}"
1596
1597[credentials.{profile_name}]
1598[credentials.{profile_name}.token_payload]
1599access_token = "decoy_access_token"
1600refresh_token = "decoy_refresh_token"
1601"#
1602            );
1603
1604            jail.create_file(settings_file_name, &settings_file_contents)
1605                .expect("should create test settings.toml");
1606            jail.set_env(
1607                SETTINGS_PATH_VAR,
1608                settings_file_path
1609                    .to_str()
1610                    .expect("settings file path should be a string"),
1611            );
1612
1613            jail.create_file(secrets_file_name, &secrets_file_contents)
1614                .expect("should create test secrets.toml");
1615            jail.set_env(
1616                SECRETS_PATH_VAR,
1617                secrets_file_path
1618                    .to_str()
1619                    .expect("secrets file path should be a string"),
1620            );
1621
1622            runtime.block_on(async {
1623                let configuration = ClientConfiguration::load_profile(profile_name.to_string())
1624                    .expect("should load the profile");
1625
1626                assert_eq!(configuration.profile(), profile_name);
1627                assert_eq!(configuration.credentials_name(), credentials_name);
1628
1629                // The stored access token is expired, so this refreshes and persists.
1630                let access_token = configuration
1631                    .get_bearer_access_token()
1632                    .await
1633                    .expect("should refresh the expired access token");
1634
1635                oidc_mock.assert_async().await;
1636                issuer_mock.assert_async().await;
1637
1638                assert_eq!(
1639                    access_token,
1640                    SecretAccessToken::from(new_access_token.clone())
1641                );
1642
1643                let mut credentials = Secrets::load_from_path(&secrets_file_path)
1644                    .expect("should load secrets")
1645                    .credentials;
1646
1647                let token_payload = expect_token_payload(
1648                    credentials
1649                        .remove(credentials_name)
1650                        .expect("should get the credential the profile points at"),
1651                );
1652
1653                assert_eq!(
1654                    token_payload.access_token,
1655                    Some(SecretAccessToken::from(new_access_token.clone())),
1656                    "the refreshed access token should be persisted under `credentials_name`, \
1657                     which is where the next load reads it from"
1658                );
1659                assert_eq!(
1660                    token_payload.refresh_token,
1661                    Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1662                    "the rotated refresh token should be persisted under `credentials_name`"
1663                );
1664
1665                let decoy_payload = expect_token_payload(
1666                    credentials
1667                        .remove(profile_name)
1668                        .expect("decoy credential should still exist"),
1669                );
1670
1671                assert_eq!(
1672                    decoy_payload.access_token,
1673                    Some(SecretAccessToken::from("decoy_access_token".to_string())),
1674                    "the credential named after the profile is not the one in use and \
1675                     should be left untouched"
1676                );
1677            });
1678
1679            Ok(())
1680        });
1681    }
1682
1683    /// The `secrets.toml` for a profile whose credential is externally managed. `access_token`
1684    /// is printed by the program, so it must be a token the client can actually validate.
1685    fn externally_managed_config_files(access_token: &str) -> (&'static str, String) {
1686        let settings = r#"
1687default_profile_name = "default"
1688
1689[profiles]
1690[profiles.default]
1691api_url = ""
1692auth_server_name = "default"
1693credentials_name = "default"
1694
1695[auth_servers]
1696[auth_servers.default]
1697client_id = ""
1698issuer = ""
1699"#;
1700
1701        let (program, flag) = super::external_command::shell();
1702        let args = format!(r#"["{flag}", "echo {access_token}"]"#);
1703
1704        let secrets = format!(
1705            r"
1706[credentials]
1707[credentials.default]
1708[credentials.default.externally_managed]
1709command = {program:?}
1710args = {args}
1711"
1712        );
1713
1714        (settings, secrets)
1715    }
1716
1717    /// Write `settings.toml` and `secrets.toml` into the jail and point the environment at them.
1718    fn jail_config_files(
1719        jail: &mut figment::Jail,
1720        settings: &str,
1721        secrets: &str,
1722    ) -> std::path::PathBuf {
1723        let directory = jail.directory().to_path_buf();
1724        jail.create_file("settings.toml", settings)
1725            .expect("should create test settings.toml");
1726        jail.create_file("secrets.toml", secrets)
1727            .expect("should create test secrets.toml");
1728
1729        jail.set_env(
1730            SETTINGS_PATH_VAR,
1731            directory
1732                .join("settings.toml")
1733                .to_str()
1734                .expect("settings path should be a string"),
1735        );
1736        let secrets_path = directory.join("secrets.toml");
1737        jail.set_env(
1738            SECRETS_PATH_VAR,
1739            secrets_path
1740                .to_str()
1741                .expect("secrets path should be a string"),
1742        );
1743
1744        secrets_path
1745    }
1746
1747    /// An externally managed credential should get its access token by running the configured
1748    /// program, without any authorization server being involved.
1749    #[test]
1750    fn test_externally_managed_credential_runs_the_configured_program() {
1751        let access_token = Claims::new_valid().to_encoded();
1752        let (settings, secrets) = externally_managed_config_files(&access_token);
1753
1754        figment::Jail::expect_with(|jail| {
1755            jail_config_files(jail, settings, &secrets);
1756
1757            let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1758            runtime.block_on(async {
1759                let config = ClientConfiguration::load_default().expect("should load config");
1760
1761                assert_eq!(
1762                    config
1763                        .get_bearer_access_token()
1764                        .await
1765                        .expect("should get an access token from the external program"),
1766                    SecretAccessToken::from(access_token.clone())
1767                );
1768            });
1769
1770            Ok(())
1771        });
1772    }
1773
1774    /// Tokens from an external program belong to whatever produced them, and must not be copied
1775    /// into `secrets.toml`.
1776    #[test]
1777    fn test_externally_managed_credential_is_never_persisted() {
1778        let access_token = Claims::new_valid().to_encoded();
1779        let (settings, secrets) = externally_managed_config_files(&access_token);
1780
1781        figment::Jail::expect_with(|jail| {
1782            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1783            let secrets_path = jail_config_files(jail, settings, &secrets);
1784            let before =
1785                std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
1786
1787            let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1788            runtime.block_on(async {
1789                let config = ClientConfiguration::load_default().expect("should load config");
1790                config
1791                    .refresh()
1792                    .await
1793                    .expect("should refresh via the external program");
1794            });
1795
1796            let after =
1797                std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
1798            assert_eq!(
1799                before, after,
1800                "an externally managed credential's tokens should never be written to disk"
1801            );
1802
1803            Ok(())
1804        });
1805    }
1806
1807    /// `load_with_login` must not start an interactive login for a credential that can always
1808    /// produce a token on demand. The auth server here points nowhere, so a PKCE flow would
1809    /// fail rather than hang.
1810    #[test]
1811    fn test_externally_managed_credential_skips_the_login_flow() {
1812        let access_token = Claims::new_valid().to_encoded();
1813        let (settings, secrets) = externally_managed_config_files(&access_token);
1814
1815        figment::Jail::expect_with(|jail| {
1816            jail_config_files(jail, settings, &secrets);
1817
1818            let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1819            runtime.block_on(async {
1820                let config = ClientConfiguration::load_with_login(CancellationToken::new(), None)
1821                    .await
1822                    .expect("should load without an interactive login");
1823
1824                assert_eq!(
1825                    config
1826                        .get_bearer_access_token()
1827                        .await
1828                        .expect("should get an access token from the external program"),
1829                    SecretAccessToken::from(access_token.clone())
1830                );
1831            });
1832
1833            Ok(())
1834        });
1835    }
1836
1837    /// A valid token is reused rather than re-fetched, so the program runs once instead of once
1838    /// per request. Without this, every API call would spawn a process.
1839    #[cfg(unix)]
1840    #[test]
1841    fn test_externally_managed_credential_reuses_a_valid_token() {
1842        let access_token = Claims::new_valid().to_encoded();
1843        let (settings, _) = externally_managed_config_files(&access_token);
1844
1845        figment::Jail::expect_with(|jail| {
1846            let counter = jail.directory().join("invocations");
1847            // Appends a line per run, so the file's length is the number of invocations.
1848            let secrets = format!(
1849                r#"
1850[credentials]
1851[credentials.default]
1852[credentials.default.externally_managed]
1853command = "/bin/sh"
1854args = ["-c", "echo run >> {counter} && printf '%s' '{access_token}'"]
1855"#,
1856                counter = counter.display(),
1857            );
1858            jail_config_files(jail, settings, &secrets);
1859
1860            let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1861            runtime.block_on(async {
1862                let config = ClientConfiguration::load_default().expect("should load config");
1863                for _ in 0..5_u8 {
1864                    config
1865                        .get_bearer_access_token()
1866                        .await
1867                        .expect("should get an access token");
1868                }
1869            });
1870
1871            let invocations = std::fs::read_to_string(&counter)
1872                .expect("the program should have run at least once")
1873                .lines()
1874                .count();
1875            assert_eq!(
1876                invocations, 1,
1877                "a valid token should be reused across requests, not re-fetched"
1878            );
1879
1880            Ok(())
1881        });
1882    }
1883
1884    /// A program that never produces a token the client can validate must not be re-run in a
1885    /// loop. Each token request runs it exactly once and hands back whatever it printed, leaving
1886    /// it to the API to reject; the alternative is an unbounded stream of subprocesses whenever a
1887    /// credential helper is misconfigured or returns an opaque (non-JWT) token, which the client
1888    /// has no way to validate locally.
1889    #[cfg(unix)]
1890    #[test]
1891    fn test_externally_managed_credential_does_not_loop_on_invalid_tokens() {
1892        let (settings, _) = externally_managed_config_files("unused");
1893        let expired = Claims::new_expired().to_encoded();
1894
1895        // Both shapes of token that fail local validation: one expired, one not a JWT at all.
1896        for (label, token) in [("expired", expired.as_str()), ("opaque", "not-a-jwt")] {
1897            figment::Jail::expect_with(|jail| {
1898                let counter = jail.directory().join("invocations");
1899                // Appends a line per run, so the file's length is the number of invocations.
1900                let secrets = format!(
1901                    r#"
1902[credentials]
1903[credentials.default]
1904[credentials.default.externally_managed]
1905command = "/bin/sh"
1906args = ["-c", "echo run >> {counter} && printf '%s' '{token}'"]
1907"#,
1908                    counter = counter.display(),
1909                );
1910                jail_config_files(jail, settings, &secrets);
1911
1912                let requests = 3_usize;
1913                let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1914                runtime.block_on(async {
1915                    let config = ClientConfiguration::load_default().expect("should load config");
1916
1917                    // A timeout so an actual loop fails the test instead of hanging it.
1918                    tokio::time::timeout(std::time::Duration::from_secs(30), async {
1919                        for _ in 0..requests {
1920                            assert_eq!(
1921                                config
1922                                    .get_bearer_access_token()
1923                                    .await
1924                                    .expect(
1925                                        "an unvalidatable token is still returned to the caller"
1926                                    )
1927                                    .secret(),
1928                                token,
1929                                "the {label} token from the program should be returned as-is"
1930                            );
1931                        }
1932                    })
1933                    .await
1934                    .expect("requesting a token should not loop on the external program");
1935                });
1936
1937                let invocations = std::fs::read_to_string(&counter)
1938                    .expect("the program should have run at least once")
1939                    .lines()
1940                    .count();
1941                assert_eq!(
1942                    invocations, requests,
1943                    "a program returning {label} tokens should run once per request, not \
1944                     repeatedly until it produces a valid one"
1945                );
1946
1947                Ok(())
1948            });
1949        }
1950    }
1951
1952    /// A credential carrying both kinds of configuration should use the external program, since
1953    /// stored tokens can only be a stale copy of what it produces.
1954    #[test]
1955    fn test_externally_managed_credential_takes_precedence_over_stored_tokens() {
1956        let access_token = Claims::new_valid().to_encoded();
1957        let (settings, secrets) = externally_managed_config_files(&access_token);
1958        let secrets = format!(
1959            "{secrets}\n[credentials.default.token_payload]\naccess_token = \"{}\"\n",
1960            Claims::new_valid().to_encoded()
1961        );
1962
1963        figment::Jail::expect_with(|jail| {
1964            jail_config_files(jail, settings, &secrets);
1965
1966            let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1967            runtime.block_on(async {
1968                let config = ClientConfiguration::load_default().expect("should load config");
1969
1970                assert_eq!(
1971                    config
1972                        .get_bearer_access_token()
1973                        .await
1974                        .expect("should get an access token from the external program"),
1975                    SecretAccessToken::from(access_token.clone()),
1976                    "the external program should win over the stored access token"
1977                );
1978            });
1979
1980            Ok(())
1981        });
1982    }
1983
1984    /// The client secret used by the client-credentials tests.
1985    const TEST_CLIENT_ID: &str = "a-client-id";
1986    const TEST_CLIENT_SECRET: &str = "a-client-secret";
1987
1988    fn expected_basic_auth(client_id: &str, client_secret: &str) -> String {
1989        use base64::Engine as _;
1990
1991        let encoded = base64::engine::general_purpose::STANDARD
1992            .encode(format!("{client_id}:{client_secret}"));
1993        format!("Basic {encoded}")
1994    }
1995
1996    /// The `settings.toml`/`secrets.toml` pair for a profile where the credential is a client secret.
1997    fn client_credentials_fixture_files(issuer: &str) -> (String, String) {
1998        let settings = format!(
1999            r#"
2000default_profile_name = "default"
2001
2002[profiles]
2003[profiles.default]
2004api_url = ""
2005auth_server_name = "default"
2006credentials_name = "default"
2007
2008[auth_servers]
2009[auth_servers.default]
2010client_id = "ignored"
2011issuer = "{issuer}"
2012"#
2013        );
2014
2015        let secrets = format!(
2016            r#"
2017[credentials]
2018[credentials.default.client_credentials]
2019client_id = "{TEST_CLIENT_ID}"
2020client_secret = "{TEST_CLIENT_SECRET}"
2021"#
2022        );
2023
2024        (settings, secrets)
2025    }
2026
2027    fn mock_client_credentials_issuer<'server>(
2028        runtime: &tokio::runtime::Runtime,
2029        mock_server: &'server MockServer,
2030        access_token: &str,
2031    ) -> (httpmock::Mock<'server>, httpmock::Mock<'server>) {
2032        let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
2033            when.method(GET).path("/.well-known/openid-configuration");
2034            then.status(200)
2035                .json_body_obj(&oidc::Discovery::new_for_test(
2036                    mock_server.base_url().parse().unwrap(),
2037                ));
2038        }));
2039
2040        let authorization = expected_basic_auth(TEST_CLIENT_ID, TEST_CLIENT_SECRET);
2041        let access_token = access_token.to_string();
2042        let token_mock = runtime.block_on(mock_server.mock_async(|when, then| {
2043            when.method(POST)
2044                .path("/v1/token")
2045                .header("authorization", authorization)
2046                .body_includes("grant_type=client_credentials");
2047            then.status(200).json_body_obj(&ClientCredentialsResponse {
2048                access_token: SecretAccessToken::from(access_token),
2049            });
2050        }));
2051
2052        (oidc_mock, token_mock)
2053    }
2054
2055    /// Make sure the `auth_server`'s client_id is not used
2056    #[test]
2057    fn test_client_credentials_credential_authenticates_as_its_own_client_id() {
2058        let access_token = Claims::new_valid().to_encoded();
2059
2060        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2061        let mock_server = runtime.block_on(MockServer::start_async());
2062        let (oidc_mock, token_mock) =
2063            mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2064
2065        let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2066
2067        figment::Jail::expect_with(|jail| {
2068            jail_config_files(jail, &settings, &secrets);
2069
2070            runtime.block_on(async {
2071                let config = ClientConfiguration::load_default().expect("should load config");
2072
2073                assert_eq!(
2074                    config
2075                        .get_bearer_access_token()
2076                        .await
2077                        .expect("should exchange the client secret for an access token"),
2078                    SecretAccessToken::from(access_token.clone()),
2079                );
2080            });
2081
2082            oidc_mock.assert();
2083            token_mock.assert();
2084
2085            Ok(())
2086        });
2087    }
2088
2089    #[test]
2090    fn test_client_credentials_credential_is_never_persisted() {
2091        let access_token = Claims::new_valid().to_encoded();
2092
2093        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2094        let mock_server = runtime.block_on(MockServer::start_async());
2095        let (_oidc_mock, _token_mock) =
2096            mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2097
2098        let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2099
2100        figment::Jail::expect_with(|jail| {
2101            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
2102            let secrets_path = jail_config_files(jail, &settings, &secrets);
2103            let before =
2104                std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
2105
2106            runtime.block_on(async {
2107                let config = ClientConfiguration::load_default().expect("should load config");
2108                config
2109                    .refresh()
2110                    .await
2111                    .expect("should exchange the client secret for an access token");
2112            });
2113
2114            let after =
2115                std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
2116            assert_eq!(
2117                before, after,
2118                "a client credentials grant has nothing to persist: no refresh token, and an \
2119                 access token the secret can mint again at any time"
2120            );
2121
2122            Ok(())
2123        });
2124    }
2125
2126    #[test]
2127    fn test_client_credentials_credential_skips_the_login_flow() {
2128        let access_token = Claims::new_valid().to_encoded();
2129
2130        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2131        let mock_server = runtime.block_on(MockServer::start_async());
2132        let (_oidc_mock, token_mock) =
2133            mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2134
2135        let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2136
2137        figment::Jail::expect_with(|jail| {
2138            jail_config_files(jail, &settings, &secrets);
2139
2140            runtime.block_on(async {
2141                let config = ClientConfiguration::load_with_login(CancellationToken::new(), None)
2142                    .await
2143                    .expect("should load without an interactive login");
2144
2145                assert_eq!(
2146                    config
2147                        .get_bearer_access_token()
2148                        .await
2149                        .expect("should exchange the client secret for an access token"),
2150                    SecretAccessToken::from(access_token.clone()),
2151                );
2152            });
2153
2154            token_mock.assert();
2155
2156            Ok(())
2157        });
2158    }
2159}