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