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