Skip to main content

qcs_api_client_common/configuration/
mod.rs

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