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