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