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//! The [`ClientConfiguration`] exposes an API for loading and accessing your
27//! configuration.
28
29use crate::configuration::{secrets::SecretAccessToken, tokens::insecure_validate_token_exp};
30#[cfg(feature = "tracing-config")]
31use crate::tracing_configuration::TracingConfiguration;
32use derive_builder::Builder;
33use std::{env, path::PathBuf};
34use tokio_util::sync::CancellationToken;
35
36#[cfg(feature = "stubs")]
37use rigetti_pyo3::pyo3_stub_gen::derive::gen_stub_pyclass;
38
39use self::{
40    secrets::{Credential, Secrets, TokenPayload},
41    settings::Settings,
42};
43
44pub(crate) mod error;
45pub mod fs;
46mod oidc;
47mod pkce;
48mod secret_string;
49pub mod secrets;
50pub mod settings;
51pub mod tokens;
52
53pub use error::{LoadError, TokenError};
54#[cfg(feature = "python")]
55pub(crate) mod py;
56
57use settings::AuthServer;
58use tokens::{
59    OAuthGrant, OAuthSession, PkceFlow, RefreshToken, TokenDispatcher, persist_oauth_session,
60};
61
62/// Default profile name.
63pub const DEFAULT_PROFILE_NAME: &str = "default";
64/// Setting this environment variable will change which profile is used from the loaded config files
65pub const PROFILE_NAME_VAR: &str = "QCS_PROFILE_NAME";
66fn env_or_default_profile_name() -> String {
67    env::var(PROFILE_NAME_VAR).unwrap_or_else(|_| DEFAULT_PROFILE_NAME.to_string())
68}
69
70/// Default URL to access the QCS API.
71pub const DEFAULT_API_URL: &str = "https://api.qcs.rigetti.com";
72/// Setting this environment variable will override the URL used to connect to the QCS REST API.
73pub const API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_API_URL";
74fn env_or_default_api_url() -> String {
75    env::var(API_URL_VAR).unwrap_or_else(|_| DEFAULT_API_URL.to_string())
76}
77
78/// Default URL to access the gRPC API.
79pub const DEFAULT_GRPC_API_URL: &str = "https://grpc.qcs.rigetti.com";
80/// Setting this environment variable will override the URL used to connect to the GRPC server.
81pub const GRPC_API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_GRPC_URL";
82fn env_or_default_grpc_url() -> String {
83    env::var(GRPC_API_URL_VAR).unwrap_or_else(|_| DEFAULT_GRPC_API_URL.to_string())
84}
85
86/// Default URL to access QVM.
87pub const DEFAULT_QVM_URL: &str = "http://127.0.0.1:5000";
88/// Setting this environment variable will override the URL used to access the QVM.
89pub const QVM_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QVM_URL";
90fn env_or_default_qvm_url() -> String {
91    env::var(QVM_URL_VAR).unwrap_or_else(|_| DEFAULT_QVM_URL.to_string())
92}
93
94/// Default URL to access `quilc`.
95pub const DEFAULT_QUILC_URL: &str = "tcp://127.0.0.1:5555";
96/// Setting this environment variable will override the URL used to access quilc.
97pub const QUILC_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QUILC_URL";
98fn env_or_default_quilc_url() -> String {
99    env::var(QUILC_URL_VAR).unwrap_or_else(|_| DEFAULT_QUILC_URL.to_string())
100}
101
102/// A configuration suitable for use as a QCS API Client.
103///
104/// This configuration can be constructed in a few ways.
105///
106/// The most common way is to use [`ClientConfiguration::load_default`]. This will load the
107/// configuration associated with your default QCS profile.
108///
109/// When loading your config, any values set by environment variables will override the values in
110/// your configuration files.
111///
112/// You can also build a configuration from scratch using [`ClientConfigurationBuilder`]. Using a
113/// builder bypasses configuration files and environment overrides.
114#[derive(Clone, Debug, Builder)]
115#[cfg_attr(
116    not(feature = "stubs"),
117    builder_struct_attr(optipy::strip_pyo3(only_stubs)),
118    optipy::strip_pyo3(only_stubs)
119)]
120#[cfg_attr(
121    not(feature = "python"),
122    builder_struct_attr(optipy::strip_pyo3),
123    optipy::strip_pyo3
124)]
125#[cfg_attr(
126    feature = "stubs",
127    builder_struct_attr(gen_stub_pyclass),
128    gen_stub_pyclass
129)]
130#[cfg_attr(
131    feature = "python",
132    builder_struct_attr(pyo3::pyclass(module = "qcs_api_client_common.configuration")),
133    pyo3::pyclass(module = "qcs_api_client_common.configuration", frozen)
134)]
135pub struct ClientConfiguration {
136    #[builder(private, default = "env_or_default_profile_name()")]
137    #[builder_field_attr(gen_stub(skip))]
138    profile: String,
139
140    /// The key under `[credentials]` in `secrets.toml` that this profile's tokens are stored
141    /// under. This is *not* necessarily the profile name: a profile declares its own
142    /// `credentials_name`, and several profiles may share one credential entry. Tokens must be
143    /// persisted under this name, otherwise a refresh writes to an entry nobody reads and the
144    /// stale credential is retried forever.
145    #[builder(private, default = "env_or_default_profile_name()")]
146    #[builder_field_attr(gen_stub(skip))]
147    credentials_name: String,
148
149    #[doc = "The URL for the QCS REST API."]
150    #[builder(default = "env_or_default_api_url()")]
151    #[builder_field_attr(pyo3(get, set))]
152    #[pyo3(get)]
153    api_url: String,
154
155    #[doc = "The URL for the QCS gRPC API."]
156    #[builder(default = "env_or_default_grpc_url()")]
157    #[builder_field_attr(pyo3(get, set))]
158    #[pyo3(get)]
159    grpc_api_url: String,
160
161    #[doc = "The URL of the quilc server."]
162    #[builder(default = "env_or_default_quilc_url()")]
163    #[builder_field_attr(pyo3(get, set))]
164    #[pyo3(get)]
165    quilc_url: String,
166
167    #[doc = "The URL of the QVM server."]
168    #[builder(default = "env_or_default_qvm_url()")]
169    #[builder_field_attr(pyo3(get, set))]
170    #[pyo3(get)]
171    qvm_url: String,
172
173    /// Provides a single, semi-shared access to user credential tokens.
174    ///
175    /// Note that the tokens are *not* shared when the `ClientConfiguration` is created multiple
176    /// times, e.g. through [`ClientConfiguration::load_default`].
177    #[builder(default, setter(custom))]
178    #[builder_field_attr(pyo3(get))]
179    pub(crate) oauth_session: Option<TokenDispatcher>,
180
181    #[builder(private, default = "ConfigSource::Builder")]
182    #[builder_field_attr(gen_stub(skip))]
183    source: ConfigSource,
184
185    /// Configuration for tracing of network API calls. If `None`, tracing is disabled.
186    #[cfg(feature = "tracing-config")]
187    #[builder(default)]
188    #[builder_field_attr(gen_stub(skip))]
189    tracing_configuration: Option<TracingConfiguration>,
190}
191
192impl ClientConfigurationBuilder {
193    /// The [`OAuthSession`] to use to authenticate with the QCS API.
194    ///
195    /// When set to [`None`], the configuration will not manage an OAuth Session, and access to the
196    /// QCS API will be limited to unauthenticated routes.
197    pub fn oauth_session(&mut self, oauth_session: Option<OAuthSession>) -> &mut Self {
198        self.oauth_session = Some(oauth_session.map(Into::into));
199        self
200    }
201}
202
203/// The common context used to build a [`ClientConfiguration`].
204struct ConfigurationContext {
205    builder: ClientConfigurationBuilder,
206    auth_server: AuthServer,
207    credential: Option<Credential>,
208    /// The [`ConfigSource`] the [`ClientConfigurationBuilder`] was configured with.
209    ///
210    /// Kept alongside the builder (rather than read back off of it) so that callers can persist
211    /// freshly acquired tokens via [`tokens::persist_oauth_session`] before the final
212    /// [`ClientConfiguration`] is built.
213    source: ConfigSource,
214    /// The credentials name the [`ClientConfigurationBuilder`] was configured with, i.e. the key
215    /// the profile's tokens live under in `secrets.toml`. See [`Self::source`].
216    credentials_name: String,
217}
218
219impl ConfigurationContext {
220    fn from_profile(profile_name: Option<String>) -> Result<Self, LoadError> {
221        #[cfg(feature = "tracing-config")]
222        match profile_name.as_ref() {
223            None => tracing::debug!("loading default QCS profile"),
224            Some(profile) => {
225                tracing::debug!("loading QCS profile {profile}")
226            }
227        }
228        let settings = Settings::load()?;
229        let secrets = Secrets::load()?;
230        Self::from_sources(settings, secrets, profile_name)
231    }
232
233    fn from_sources(
234        settings: Settings,
235        mut secrets: Secrets,
236        profile_name: Option<String>,
237    ) -> Result<Self, LoadError> {
238        let Settings {
239            default_profile_name,
240            mut profiles,
241            mut auth_servers,
242            file_path: settings_path,
243        } = settings;
244        let profile_name = profile_name
245            .or_else(|| env::var(PROFILE_NAME_VAR).ok())
246            .unwrap_or(default_profile_name);
247        let profile = profiles
248            .remove(&profile_name)
249            .ok_or(LoadError::ProfileNotFound(profile_name.clone()))?;
250        let auth_server = auth_servers
251            .remove(&profile.auth_server_name)
252            .ok_or_else(|| LoadError::AuthServerNotFound(profile.auth_server_name.clone()))?;
253
254        let secrets_path = secrets.file_path;
255        let credentials_name = profile.credentials_name;
256        let credential = secrets.credentials.remove(&credentials_name);
257
258        let api_url = env::var(API_URL_VAR)
259            .unwrap_or(profile.api_url)
260            .trim_end_matches('/')
261            .to_string();
262        let quilc_url = env::var(QUILC_URL_VAR).unwrap_or(profile.applications.pyquil.quilc_url);
263        let qvm_url = env::var(QVM_URL_VAR).unwrap_or(profile.applications.pyquil.qvm_url);
264        let grpc_api_url = env::var(GRPC_API_URL_VAR)
265            .unwrap_or(profile.grpc_api_url)
266            .trim_end_matches('/')
267            .to_string();
268
269        #[cfg(feature = "tracing-config")]
270        let tracing_configuration =
271            TracingConfiguration::from_env().map_err(LoadError::TracingFilterParseError)?;
272
273        let source = match (settings_path, secrets_path) {
274            (Some(settings_path), Some(secrets_path)) => ConfigSource::File {
275                settings_path,
276                secrets_path,
277            },
278            _ => ConfigSource::Default,
279        };
280
281        let mut builder = ClientConfiguration::builder();
282        builder
283            .profile(profile_name)
284            .credentials_name(credentials_name.clone())
285            .source(source.clone())
286            .api_url(api_url)
287            .quilc_url(quilc_url)
288            .qvm_url(qvm_url)
289            .grpc_api_url(grpc_api_url);
290
291        #[cfg(feature = "tracing-config")]
292        {
293            builder.tracing_configuration(tracing_configuration);
294        }
295
296        Ok(Self {
297            builder,
298            auth_server,
299            credential,
300            source,
301            credentials_name,
302        })
303    }
304}
305
306/// Persists `oauth_session` via [`persist_oauth_session`], logging a warning on failure instead of
307/// returning an error. A session that was just successfully refreshed or logged in is still valid
308/// and usable even if it can't be persisted, so a persistence failure shouldn't prevent returning
309/// it to the caller (mirroring how [`TokenError::Write`] is handled elsewhere).
310async fn persist_or_warn(
311    oauth_session: &OAuthSession,
312    source: &ConfigSource,
313    credentials_name: &str,
314) {
315    if let Err(_error) = persist_oauth_session(oauth_session, source, credentials_name).await {
316        #[cfg(feature = "tracing")]
317        tracing::warn!(
318            "Refreshed QCS credentials but failed to persist them to the secrets file: {_error}"
319        );
320    }
321}
322
323fn credential_to_oauth_session(
324    credential: Option<Credential>,
325    auth_server: AuthServer,
326) -> Option<OAuthSession> {
327    match credential {
328        Some(Credential {
329            token_payload:
330                Some(TokenPayload {
331                    access_token,
332                    refresh_token,
333                    ..
334                }),
335        }) => Some(OAuthSession::new(
336            OAuthGrant::RefreshToken(RefreshToken::new(refresh_token.unwrap_or_default())),
337            auth_server,
338            access_token,
339        )),
340        _ => None,
341    }
342}
343
344impl ClientConfiguration {
345    #[cfg(test)]
346    fn new(
347        settings: Settings,
348        secrets: Secrets,
349        profile_name: Option<String>,
350    ) -> Result<Self, LoadError> {
351        let ConfigurationContext {
352            mut builder,
353            auth_server,
354            credential,
355            ..
356        } = ConfigurationContext::from_sources(settings, secrets, profile_name)?;
357        let oauth_session = credential_to_oauth_session(credential, auth_server);
358        Ok(builder.oauth_session(oauth_session).build()?)
359    }
360
361    /// Attempts to load config files
362    ///
363    /// # Errors
364    ///
365    /// See [`LoadError`]
366    pub fn load_default() -> Result<Self, LoadError> {
367        let base_config = Self::load(None)?;
368        Ok(base_config)
369    }
370
371    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
372    /// specified profile.
373    ///
374    /// # Errors
375    ///
376    /// See [`LoadError`]
377    pub fn load_profile(profile_name: String) -> Result<Self, LoadError> {
378        Self::load(Some(profile_name))
379    }
380
381    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
382    /// specified profile. If no `profile_name` is provided, then a default configuration is
383    /// loaded. When stored OAuth credentials are unavailable, this method falls back to an
384    /// interactive PKCE login flow.
385    ///
386    /// # Errors
387    ///
388    /// See [`LoadError`]
389    pub async fn load_with_login(
390        cancel_token: CancellationToken,
391        profile_name: Option<String>,
392    ) -> Result<Self, LoadError> {
393        let ConfigurationContext {
394            mut builder,
395            auth_server,
396            credential,
397            source,
398            credentials_name,
399        } = ConfigurationContext::from_profile(profile_name)?;
400
401        // If the stored access or refresh tokens are valid, skip the login flow
402        if let Some(Credential {
403            token_payload:
404                Some(TokenPayload {
405                    access_token,
406                    refresh_token,
407                    ..
408                }),
409        }) = credential
410        {
411            // The current access token is valid, use it
412            if let Some(access_token) = access_token {
413                if insecure_validate_token_exp(&access_token).is_ok() {
414                    let refresh_token = refresh_token.unwrap_or_default();
415
416                    let oauth_session = OAuthSession::new(
417                        OAuthGrant::RefreshToken(RefreshToken::new(refresh_token)),
418                        auth_server,
419                        Some(access_token),
420                    );
421                    return Ok(builder.oauth_session(Some(oauth_session)).build()?);
422                }
423            }
424
425            // The access token is invalid, try to refresh it
426            if let Some(refresh_token) = refresh_token
427                && !refresh_token.is_empty()
428            {
429                let mut refresh_token = RefreshToken::new(refresh_token);
430
431                // If the refresh token is valid, use it
432                if let Ok(access_token) = refresh_token.request_access_token(&auth_server).await {
433                    let oauth_session = OAuthSession::new(
434                        OAuthGrant::RefreshToken(refresh_token),
435                        auth_server,
436                        Some(access_token),
437                    );
438
439                    // Requesting a new access token may have rotated the refresh token.
440                    persist_or_warn(&oauth_session, &source, &credentials_name).await;
441
442                    return Ok(builder.oauth_session(Some(oauth_session)).build()?);
443                }
444            }
445        }
446
447        // At this point the stored credentials are known to be invalid, so a login is required
448        let pkce_flow = PkceFlow::new_login_flow(cancel_token, &auth_server).await?;
449        let access_token = pkce_flow.access_token.clone();
450        let oauth_session =
451            OAuthSession::from_pkce_flow(pkce_flow, auth_server, Some(access_token));
452
453        // Persist eagerly: without this, the freshly logged-in tokens are only saved once
454        // something later triggers a dispatcher-managed refresh (e.g. the access token expiring
455        // during a later call). If this process exits before that happens, the login is lost and
456        // the next process is forced through the login flow again.
457        persist_or_warn(&oauth_session, &source, &credentials_name).await;
458
459        Ok(builder.oauth_session(Some(oauth_session)).build()?)
460    }
461
462    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
463    /// specified profile. If no `profile_name` is provided, then a default configuration is
464    /// loaded.
465    ///
466    /// # Errors
467    ///
468    /// See [`LoadError`]
469    fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
470        let ConfigurationContext {
471            mut builder,
472            auth_server,
473            credential,
474            source: _,
475            credentials_name: _,
476        } = ConfigurationContext::from_profile(profile_name)?;
477        let oauth_session = credential_to_oauth_session(credential, auth_server);
478        Ok(builder.oauth_session(oauth_session).build()?)
479    }
480
481    /// Get a [`ClientConfigurationBuilder`]
482    #[must_use]
483    pub fn builder() -> ClientConfigurationBuilder {
484        ClientConfigurationBuilder::default()
485    }
486
487    /// Get the name of the profile that was loaded, if any.
488    #[must_use]
489    pub fn profile(&self) -> &str {
490        &self.profile
491    }
492
493    /// Get the name of the credential the loaded profile uses, i.e. the key its tokens are stored
494    /// under in `secrets.toml`. This may differ from [`Self::profile`].
495    #[must_use]
496    pub fn credentials_name(&self) -> &str {
497        &self.credentials_name
498    }
499
500    /// Get the URL of the QCS REST API.
501    #[must_use]
502    pub fn api_url(&self) -> &str {
503        &self.api_url
504    }
505
506    /// Get the URL of the QCS gRPC API.
507    #[must_use]
508    pub fn grpc_api_url(&self) -> &str {
509        &self.grpc_api_url
510    }
511
512    /// Get the URL of the quilc server.
513    #[must_use]
514    pub fn quilc_url(&self) -> &str {
515        &self.quilc_url
516    }
517
518    /// Get the URL of the QVM server.
519    #[must_use]
520    pub fn qvm_url(&self) -> &str {
521        &self.qvm_url
522    }
523
524    /// Get the [`TracingConfiguration`].
525    #[cfg(feature = "tracing-config")]
526    #[must_use]
527    pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
528        self.tracing_configuration.as_ref()
529    }
530
531    /// Get the source of the configuration.
532    #[must_use]
533    pub const fn source(&self) -> &ConfigSource {
534        &self.source
535    }
536
537    /// Get a copy of the current [`OAuthSession`].
538    ///
539    /// Note: This is a _copy_, the contained tokens will become stale once they expire.
540    ///
541    /// # Errors
542    ///
543    /// See [`TokenError`]
544    pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
545        Ok(self
546            .oauth_session
547            .as_ref()
548            .ok_or(TokenError::NoRefreshToken)?
549            .tokens()
550            .await)
551    }
552
553    /// Gets the `Bearer` access token, refreshing it if it is expired.
554    ///
555    /// # Errors
556    ///
557    /// See [`TokenError`].
558    pub async fn get_bearer_access_token(&self) -> Result<SecretAccessToken, TokenError> {
559        let dispatcher = self
560            .oauth_session
561            .as_ref()
562            .ok_or_else(|| TokenError::NoCredentials)?;
563        match dispatcher.validate().await {
564            Ok(tokens) => Ok(tokens),
565            #[allow(unused_variables)]
566            Err(e) => {
567                #[cfg(feature = "tracing-config")]
568                tracing::debug!("Refreshing access token because current one is invalid: {e}");
569                dispatcher
570                    .refresh(self.source(), self.credentials_name())
571                    .await
572                    .map(|e| e.access_token().cloned())?
573            }
574        }
575    }
576
577    /// Refreshes the [`Tokens`] in use and returns the new bearer access token.
578    ///
579    /// # Errors
580    ///
581    /// See [`TokenError`]
582    pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
583        self.oauth_session
584            .as_ref()
585            .ok_or(TokenError::NoRefreshToken)?
586            .refresh(self.source(), self.credentials_name())
587            .await
588    }
589}
590
591/// Describes how a [`ClientConfiguration`] was initialized.
592#[derive(Clone, Debug)]
593pub enum ConfigSource {
594    /// A [`ClientConfiguration`] derived from a [`ClientConfigurationBuilder`]
595    Builder,
596    /// A [`ClientConfiguration`] derived from at least one file.
597    File {
598        /// The path to the QCS `settings.toml` file used to initialize the [`ClientConfiguration`].
599        settings_path: PathBuf,
600        /// The path to a QCS `secrets.toml` file used to initialize the [`ClientConfiguration`].
601        secrets_path: PathBuf,
602    },
603    /// A [`ClientConfiguration`] derived from default values.
604    Default,
605}
606
607fn expand_path_from_env_or_default(
608    env_var_name: &str,
609    default: &str,
610) -> Result<PathBuf, LoadError> {
611    match env::var(env_var_name) {
612        Ok(path) => {
613            let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
614            let path_buf: PathBuf = expanded_path.as_ref().into();
615            if !path_buf.exists() {
616                return Err(LoadError::Path {
617                    path: path_buf,
618                    message: format!("The given path does not exist: {path}"),
619                });
620            }
621            Ok(path_buf)
622        }
623        Err(env::VarError::NotPresent) => {
624            let expanded_path = shellexpand::tilde_with_context(default, || {
625                env::home_dir().map(|path| path.display().to_string())
626            });
627            let path_buf: PathBuf = expanded_path.as_ref().into();
628            if !path_buf.exists() {
629                return Err(LoadError::Path {
630                    path: path_buf,
631                    message: format!(
632                        "Could not find a QCS configuration at the default path: {default}"
633                    ),
634                });
635            }
636            Ok(path_buf)
637        }
638        Err(other_error) => Err(LoadError::EnvVar {
639            variable_name: env_var_name.to_string(),
640            message: other_error.to_string(),
641        }),
642    }
643}
644
645#[cfg(test)]
646mod test {
647    #![allow(clippy::result_large_err, reason = "happens in figment tests")]
648
649    use httpmock::prelude::*;
650    use jsonwebtoken::{EncodingKey, Header, encode};
651    use serde::Serialize;
652    use time::{Duration, OffsetDateTime};
653    use tokio_util::sync::CancellationToken;
654
655    use crate::configuration::{
656        API_URL_VAR, AuthServer, ClientConfiguration, DEFAULT_QUILC_URL, GRPC_API_URL_VAR,
657        OAuthGrant, OAuthSession, QUILC_URL_VAR, QVM_URL_VAR, RefreshToken,
658        expand_path_from_env_or_default, oidc,
659        pkce::tests::PkceTestServerHarness,
660        secrets::{
661            SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR, SecretAccessToken, SecretRefreshToken, Secrets,
662        },
663        settings::{SETTINGS_PATH_VAR, Settings},
664        tokens::{RefreshTokenResponse, TokenRefresher},
665    };
666
667    use super::{settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials};
668
669    #[test]
670    fn expands_env_var() {
671        figment::Jail::expect_with(|jail| {
672            let dir = jail.create_dir("~/blah/blah/")?;
673            jail.create_file(dir.join("file.toml"), "")?;
674            jail.set_env("SOME_PATH", "blah/blah");
675            jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
676            let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
677            assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
678
679            Ok(())
680        });
681    }
682
683    #[test]
684    fn uses_env_var_overrides() {
685        figment::Jail::expect_with(|jail| {
686            let quilc_url = "tcp://quilc:5555";
687            let qvm_url = "http://qvm:5000";
688            let grpc_url = "http://grpc:80";
689            let api_url = "http://api:80";
690
691            jail.set_env(QUILC_URL_VAR, quilc_url);
692            jail.set_env(QVM_URL_VAR, qvm_url);
693            jail.set_env(API_URL_VAR, api_url);
694            jail.set_env(GRPC_API_URL_VAR, grpc_url);
695
696            let config = ClientConfiguration::new(
697                Settings::default(),
698                Secrets::default(),
699                Some("default".to_string()),
700            )
701            .expect("Should be able to build default config.");
702
703            assert_eq!(config.quilc_url, quilc_url);
704            assert_eq!(config.qvm_url, qvm_url);
705            assert_eq!(config.grpc_api_url, grpc_url);
706
707            Ok(())
708        });
709    }
710
711    #[tokio::test]
712    async fn test_default_uses_env_var_overrides() {
713        figment::Jail::expect_with(|jail| {
714            let quilc_url = "quilc_url";
715            let qvm_url = "qvm_url";
716            let grpc_url = "grpc_url";
717            let api_url = "api_url";
718
719            jail.set_env(QUILC_URL_VAR, quilc_url);
720            jail.set_env(QVM_URL_VAR, qvm_url);
721            jail.set_env(GRPC_API_URL_VAR, grpc_url);
722            jail.set_env(API_URL_VAR, api_url);
723
724            let config = ClientConfiguration::load_default().unwrap();
725            assert_eq!(config.quilc_url, quilc_url);
726            assert_eq!(config.qvm_url, qvm_url);
727            assert_eq!(config.grpc_api_url, grpc_url);
728            assert_eq!(config.api_url, api_url);
729
730            Ok(())
731        });
732    }
733
734    #[test]
735    fn test_default_loads_settings_with_partial_profile_applications() {
736        figment::Jail::expect_with(|jail| {
737            let directory = jail.directory();
738            let settings_file_name = "settings.toml";
739            let settings_file_path = directory.join(settings_file_name);
740
741            let quilc_url_env_var = "env-var://quilc.url/after";
742
743            let settings_file_contents = r#"
744default_profile_name = "default"
745
746[profiles]
747[profiles.default]
748api_url = ""
749auth_server_name = "default"
750credentials_name = "default"
751applications = {}
752
753[auth_servers]
754[auth_servers.default]
755client_id = ""
756issuer = ""
757"#;
758            jail.create_file(settings_file_name, settings_file_contents)
759                .expect("should create test settings.toml");
760
761            jail.set_env(
762                "QCS_SETTINGS_FILE_PATH",
763                settings_file_path
764                    .to_str()
765                    .expect("settings file path should be a string"),
766            );
767
768            // before setting env var
769            let config = ClientConfiguration::load_default().unwrap();
770            assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
771
772            jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
773
774            // after setting env var
775            let config = ClientConfiguration::load_default().unwrap();
776            assert_eq!(config.quilc_url, quilc_url_env_var);
777
778            Ok(())
779        });
780    }
781
782    #[test]
783    fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
784        figment::Jail::expect_with(|jail| {
785            let directory = jail.directory();
786            let settings_file_name = "settings.toml";
787            let settings_file_path = directory.join(settings_file_name);
788
789            let quilc_url_settings_toml = "settings-toml://quilc.url";
790            let quilc_url_env_var = "env-var://quilc.url/after";
791
792            let settings_file_contents = format!(
793                r#"
794default_profile_name = "default"
795
796[profiles]
797[profiles.default]
798api_url = ""
799auth_server_name = "default"
800credentials_name = "default"
801applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
802
803[auth_servers]
804[auth_servers.default]
805client_id = ""
806issuer = ""
807"#
808            );
809
810            jail.create_file(settings_file_name, &settings_file_contents)
811                .expect("should create test settings.toml");
812
813            jail.set_env(
814                "QCS_SETTINGS_FILE_PATH",
815                settings_file_path
816                    .to_str()
817                    .expect("settings file path should be a string"),
818            );
819
820            // before setting env var
821            let config = ClientConfiguration::load_default().unwrap();
822            assert_eq!(config.quilc_url, quilc_url_settings_toml);
823
824            jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
825
826            // after setting env var
827            let config = ClientConfiguration::load_default().unwrap();
828            assert_eq!(config.quilc_url, quilc_url_env_var);
829
830            Ok(())
831        });
832    }
833
834    #[tokio::test]
835    async fn test_hydrate_access_token_on_load() {
836        let mut config = ClientConfiguration::builder().build().unwrap();
837        let access_token = "test_access_token";
838        figment::Jail::expect_with(|jail| {
839            let directory = jail.directory();
840            let settings_file_name = "settings.toml";
841            let settings_file_path = directory.join(settings_file_name);
842            let secrets_file_name = "secrets.toml";
843            let secrets_file_path = directory.join(secrets_file_name);
844
845            let settings_file_contents = r#"
846default_profile_name = "default"
847
848[profiles]
849[profiles.default]
850api_url = ""
851auth_server_name = "default"
852credentials_name = "default"
853
854[auth_servers]
855[auth_servers.default]
856client_id = ""
857issuer = ""
858"#;
859
860            let secrets_file_contents = format!(
861                r#"
862[credentials]
863[credentials.default]
864[credentials.default.token_payload]
865access_token = "{access_token}"
866expires_in = 3600
867id_token = "id_token"
868refresh_token = "refresh_token"
869scope = "offline_access openid profile email"
870token_type = "Bearer"
871"#
872            );
873
874            jail.create_file(settings_file_name, settings_file_contents)
875                .expect("should create test settings.toml");
876            jail.create_file(secrets_file_name, &secrets_file_contents)
877                .expect("should create test settings.toml");
878
879            jail.set_env(
880                "QCS_SETTINGS_FILE_PATH",
881                settings_file_path
882                    .to_str()
883                    .expect("settings file path should be a string"),
884            );
885            jail.set_env(
886                "QCS_SECRETS_FILE_PATH",
887                secrets_file_path
888                    .to_str()
889                    .expect("secrets file path should be a string"),
890            );
891
892            config = ClientConfiguration::load_default().unwrap();
893            Ok(())
894        });
895        assert_eq!(
896            config.get_access_token().await.unwrap().unwrap(),
897            SecretAccessToken::from(access_token)
898        );
899    }
900
901    #[derive(Clone, Debug, Serialize)]
902    struct Claims {
903        exp: i64,
904        iss: String,
905        sub: String,
906    }
907
908    impl Default for Claims {
909        fn default() -> Self {
910            Self {
911                exp: 0,
912                iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
913                sub: "qcs@rigetti.com".to_string(),
914            }
915        }
916    }
917
918    impl Claims {
919        fn new_valid() -> Self {
920            Self {
921                exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
922                ..Self::default()
923            }
924        }
925
926        fn new_expired() -> Self {
927            Self {
928                exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
929                ..Self::default()
930            }
931        }
932
933        fn to_encoded(&self) -> String {
934            encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
935        }
936
937        fn to_access_token(&self) -> SecretAccessToken {
938            SecretAccessToken::from(self.to_encoded())
939        }
940    }
941
942    #[test]
943    fn test_valid_token() {
944        let valid_token = Claims::new_valid().to_access_token();
945        let tokens = OAuthSession::from_refresh_token(
946            RefreshToken::new(SecretRefreshToken::from("unused")),
947            AuthServer::default(),
948            Some(valid_token.clone()),
949        );
950        assert_eq!(
951            tokens
952                .validate()
953                .expect("Token should not fail validation."),
954            valid_token
955        );
956    }
957
958    #[test]
959    fn test_expired_token() {
960        let invalid_token = Claims::new_expired().to_access_token();
961        let tokens = OAuthSession::from_refresh_token(
962            RefreshToken::new(SecretRefreshToken::from("unused")),
963            AuthServer::default(),
964            Some(invalid_token),
965        );
966        assert!(tokens.validate().is_err());
967    }
968
969    #[test]
970    fn test_client_credentials_without_access_token() {
971        let tokens = OAuthSession::from_client_credentials(
972            ClientCredentials::new("client_id", "client_secret"),
973            AuthServer::default(),
974            None,
975        );
976        assert!(tokens.validate().is_err());
977    }
978
979    #[tokio::test]
980    async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
981        let access_token = Claims::new_valid().to_encoded();
982        let mut config = ClientConfiguration::builder().build().unwrap();
983        figment::Jail::expect_with(|jail| {
984            let directory = jail.directory();
985            let settings_file_name = "settings.toml";
986            let settings_file_path = directory.join(settings_file_name);
987            let secrets_file_name = "secrets.toml";
988            let secrets_file_path = directory.join(secrets_file_name);
989
990            let settings_file_contents = r#"
991default_profile_name = "default"
992
993[profiles]
994[profiles.default]
995api_url = ""
996auth_server_name = "default"
997credentials_name = "default"
998
999[auth_servers]
1000[auth_servers.default]
1001client_id = ""
1002issuer = ""
1003"#;
1004
1005            // note this has no `refresh_token` property
1006            let secrets_file_contents = format!(
1007                r#"
1008[credentials]
1009[credentials.default]
1010[credentials.default.token_payload]
1011access_token = "{access_token}"
1012expires_in = 3600
1013id_token = "id_token"
1014scope = "offline_access openid profile email"
1015token_type = "Bearer"
1016"#
1017            );
1018
1019            jail.create_file(settings_file_name, settings_file_contents)
1020                .expect("should create test settings.toml");
1021            jail.create_file(secrets_file_name, &secrets_file_contents)
1022                .expect("should create test secrets.toml");
1023
1024            jail.set_env(
1025                "QCS_SETTINGS_FILE_PATH",
1026                settings_file_path
1027                    .to_str()
1028                    .expect("settings file path should be a string"),
1029            );
1030            jail.set_env(
1031                "QCS_SECRETS_FILE_PATH",
1032                secrets_file_path
1033                    .to_str()
1034                    .expect("secrets file path should be a string"),
1035            );
1036
1037            config = ClientConfiguration::load_default().unwrap();
1038            Ok(())
1039        });
1040
1041        assert_eq!(
1042            config.get_bearer_access_token().await.unwrap(),
1043            SecretAccessToken::from(access_token)
1044        );
1045    }
1046
1047    /// Exercises the PKCE login flow end-to-end, ensuring that the token is persisted to the secrets file.
1048    #[test]
1049    #[serial_test::serial(oauth2_test_server)]
1050    fn test_pkce_flow_persists_token() {
1051        // Because we need to block on the runtime inside the jail function,
1052        // we have to create one manually here instead of relying on #[tokio::test].
1053        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1054
1055        let PkceTestServerHarness {
1056            server,
1057            client,
1058            discovery: _,
1059            redirect_port: _,
1060        } = runtime.block_on(PkceTestServerHarness::new());
1061
1062        let client_id = client.client_id;
1063        let issuer = server.issuer().to_string();
1064
1065        figment::Jail::expect_with(|jail| {
1066            // In CI, the secrets file is mounted as read-only,
1067            // but these tmp testing files should be writable.
1068            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1069
1070            let directory = jail.directory();
1071            let settings_file_name = "settings.toml";
1072            let settings_file_path = directory.join(settings_file_name);
1073
1074            let secrets_file_name = "secrets.toml";
1075            let secrets_file_path = directory.join(secrets_file_name);
1076
1077            let settings_file_contents = format!(
1078                r#"
1079default_profile_name = "default"
1080
1081[profiles]
1082[profiles.default]
1083api_url = ""
1084auth_server_name = "default"
1085credentials_name = "default"
1086
1087[auth_servers]
1088[auth_servers.default]
1089client_id = "{client_id}"
1090issuer = "{issuer}"
1091"#
1092            );
1093
1094            let secrets_file_contents = r#"
1095[credentials]
1096[credentials.default]
1097[credentials.default.token_payload]
1098access_token = ""
1099"#;
1100
1101            jail.create_file(settings_file_name, &settings_file_contents)
1102                .expect("should create test settings.toml");
1103
1104            jail.set_env(
1105                SETTINGS_PATH_VAR,
1106                settings_file_path
1107                    .to_str()
1108                    .expect("settings file path should be a string"),
1109            );
1110
1111            jail.create_file(secrets_file_name, secrets_file_contents)
1112                .expect("should create test secrets.toml");
1113
1114            jail.set_env(
1115                SECRETS_PATH_VAR,
1116                secrets_file_path
1117                    .to_str()
1118                    .expect("secrets file path should be a string"),
1119            );
1120
1121            // should perform a login flow, which persists the token to the secrets file.
1122            runtime.block_on(async {
1123                let cancel_token = CancellationToken::new();
1124                // should load the configuration and perform a login flow
1125                let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1126                    .await
1127                    .expect("should load configuration");
1128                let oauth_session = configuration.refresh().await.expect("should refresh");
1129                let token = oauth_session.validate().expect("token should be valid");
1130
1131                // now, the configuration should load without needing to perform a login flow
1132                let configuration =
1133                    ClientConfiguration::load_default().expect("should load configuration");
1134
1135                let oauth_session = configuration
1136                    .oauth_session()
1137                    .await
1138                    .expect("should get oauth session");
1139
1140                let token_payload = Secrets::load_from_path(&secrets_file_path)
1141                    .expect("should load secrets")
1142                    .credentials
1143                    .remove("default")
1144                    .expect("should get default credentials")
1145                    .token_payload
1146                    .expect("should get token payload");
1147
1148                assert_eq!(
1149                    token,
1150                    oauth_session.validate().expect("should contain token"),
1151                    "session: {oauth_session:?}, token_payload: {token_payload:?}",
1152                );
1153                assert_eq!(
1154                    token_payload.access_token,
1155                    Some(token),
1156                    "session: {oauth_session:?}, token_payload: {token_payload:?}"
1157                );
1158                assert_ne!(
1159                    token_payload.refresh_token, None,
1160                    "session: {oauth_session:?}, token_payload: {token_payload:?}"
1161                );
1162            });
1163
1164            Ok(())
1165        });
1166
1167        drop(server);
1168    }
1169
1170    /// Exercises the "no valid credential, perform an interactive login" branch of
1171    /// [`ClientConfiguration::load_with_login`], ensuring the resulting tokens are persisted to the
1172    /// secrets file immediately — unlike [`test_pkce_flow_persists_token`], this does NOT call
1173    /// `.refresh()` afterward. If `load_with_login` doesn't persist the login on its own, a process
1174    /// that exits before anything else triggers a dispatcher-managed refresh would lose the login
1175    /// entirely, forcing the next process back through an interactive login too.
1176    #[test]
1177    #[serial_test::serial(oauth2_test_server)]
1178    fn test_load_with_login_persists_login_flow_token_without_explicit_refresh() {
1179        // Because we need to block on the runtime inside the jail function,
1180        // we have to create one manually here instead of relying on #[tokio::test].
1181        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1182
1183        let PkceTestServerHarness {
1184            server,
1185            client,
1186            discovery: _,
1187            redirect_port: _,
1188        } = runtime.block_on(PkceTestServerHarness::new());
1189
1190        let client_id = client.client_id;
1191        let issuer = server.issuer().to_string();
1192
1193        figment::Jail::expect_with(|jail| {
1194            // In CI, the secrets file is mounted as read-only,
1195            // but these tmp testing files should be writable.
1196            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1197
1198            let directory = jail.directory();
1199            let settings_file_name = "settings.toml";
1200            let settings_file_path = directory.join(settings_file_name);
1201
1202            let secrets_file_name = "secrets.toml";
1203            let secrets_file_path = directory.join(secrets_file_name);
1204
1205            let settings_file_contents = format!(
1206                r#"
1207default_profile_name = "default"
1208
1209[profiles]
1210[profiles.default]
1211api_url = ""
1212auth_server_name = "default"
1213credentials_name = "default"
1214
1215[auth_servers]
1216[auth_servers.default]
1217client_id = "{client_id}"
1218issuer = "{issuer}"
1219"#
1220            );
1221
1222            let secrets_file_contents = r#"
1223[credentials]
1224[credentials.default]
1225[credentials.default.token_payload]
1226access_token = ""
1227"#;
1228
1229            jail.create_file(settings_file_name, &settings_file_contents)
1230                .expect("should create test settings.toml");
1231
1232            jail.set_env(
1233                SETTINGS_PATH_VAR,
1234                settings_file_path
1235                    .to_str()
1236                    .expect("settings file path should be a string"),
1237            );
1238
1239            jail.create_file(secrets_file_name, secrets_file_contents)
1240                .expect("should create test secrets.toml");
1241
1242            jail.set_env(
1243                SECRETS_PATH_VAR,
1244                secrets_file_path
1245                    .to_str()
1246                    .expect("secrets file path should be a string"),
1247            );
1248
1249            runtime.block_on(async {
1250                let cancel_token = CancellationToken::new();
1251
1252                // Deliberately do NOT call `.refresh()` afterward: `load_with_login` itself
1253                // should persist the freshly logged-in tokens.
1254                let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1255                    .await
1256                    .expect("should perform a login flow");
1257
1258                let oauth_session = configuration
1259                    .oauth_session()
1260                    .await
1261                    .expect("should get oauth session");
1262                let token = oauth_session.validate().expect("token should be valid");
1263
1264                let token_payload = Secrets::load_from_path(&secrets_file_path)
1265                    .expect("should load secrets")
1266                    .credentials
1267                    .remove("default")
1268                    .expect("should get default credentials")
1269                    .token_payload
1270                    .expect("should get token payload");
1271
1272                assert_eq!(
1273                    token_payload.access_token,
1274                    Some(token),
1275                    "the access token from the login flow should be persisted without an \
1276                     explicit follow-up refresh"
1277                );
1278                assert!(
1279                    token_payload.refresh_token.is_some(),
1280                    "the refresh token from the login flow should be persisted without an \
1281                     explicit follow-up refresh"
1282                );
1283            });
1284
1285            Ok(())
1286        });
1287
1288        drop(server);
1289    }
1290
1291    /// Exercises the "refresh the stored refresh token" branch of [`ClientConfiguration::load_with_login`],
1292    /// which is taken when the stored access token has expired but a refresh token is still on file.
1293    ///
1294    /// This ensures that a rotated refresh token returned by the auth server is (a) reflected in the
1295    /// in-memory [`OAuthSession`] and (b) persisted back to the secrets file. If the rotated refresh
1296    /// token is not persisted, the next process to load this profile will retry the stale, already-consumed
1297    /// refresh token, fail, and be forced into an interactive login flow every time.
1298    #[test]
1299    #[serial_test::serial(oauth2_test_server)]
1300    fn test_load_with_login_persists_rotated_refresh_token_on_refresh() {
1301        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1302
1303        let mock_server = runtime.block_on(MockServer::start_async());
1304
1305        let new_access_token = Claims::new_valid().to_encoded();
1306        let rotated_refresh_token = "rotated_refresh_token".to_string();
1307
1308        let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1309            when.method(GET).path("/.well-known/openid-configuration");
1310            then.status(200)
1311                .json_body_obj(&oidc::Discovery::new_for_test(
1312                    mock_server.base_url().parse().unwrap(),
1313                ));
1314        }));
1315
1316        let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1317            when.method(POST).path("/v1/token");
1318            then.status(200).json_body_obj(&RefreshTokenResponse {
1319                access_token: SecretAccessToken::from(new_access_token.clone()),
1320                refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1321            });
1322        }));
1323
1324        let client_id = "client_id";
1325        let issuer = mock_server.base_url();
1326        let initial_refresh_token = "initial_refresh_token";
1327        let expired_access_token = Claims::new_expired().to_encoded();
1328
1329        figment::Jail::expect_with(|jail| {
1330            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1331
1332            let directory = jail.directory();
1333            let settings_file_name = "settings.toml";
1334            let settings_file_path = directory.join(settings_file_name);
1335
1336            let secrets_file_name = "secrets.toml";
1337            let secrets_file_path = directory.join(secrets_file_name);
1338
1339            let settings_file_contents = format!(
1340                r#"
1341default_profile_name = "default"
1342
1343[profiles]
1344[profiles.default]
1345api_url = ""
1346auth_server_name = "default"
1347credentials_name = "default"
1348
1349[auth_servers]
1350[auth_servers.default]
1351client_id = "{client_id}"
1352issuer = "{issuer}"
1353"#
1354            );
1355
1356            let secrets_file_contents = format!(
1357                r#"
1358[credentials]
1359[credentials.default]
1360[credentials.default.token_payload]
1361access_token = "{expired_access_token}"
1362refresh_token = "{initial_refresh_token}"
1363"#
1364            );
1365
1366            jail.create_file(settings_file_name, &settings_file_contents)
1367                .expect("should create test settings.toml");
1368            jail.set_env(
1369                SETTINGS_PATH_VAR,
1370                settings_file_path
1371                    .to_str()
1372                    .expect("settings file path should be a string"),
1373            );
1374
1375            jail.create_file(secrets_file_name, &secrets_file_contents)
1376                .expect("should create test secrets.toml");
1377            jail.set_env(
1378                SECRETS_PATH_VAR,
1379                secrets_file_path
1380                    .to_str()
1381                    .expect("secrets file path should be a string"),
1382            );
1383
1384            runtime.block_on(async {
1385                let cancel_token = CancellationToken::new();
1386
1387                // The expired access token should be refreshed using the stored refresh token,
1388                // without falling back to an interactive login flow.
1389                let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1390                    .await
1391                    .expect("should refresh using the stored refresh token");
1392
1393                oidc_mock.assert_async().await;
1394                issuer_mock.assert_async().await;
1395
1396                let oauth_session = configuration
1397                    .oauth_session()
1398                    .await
1399                    .expect("should get oauth session");
1400
1401                assert_eq!(
1402                    oauth_session.access_token().cloned().ok(),
1403                    Some(SecretAccessToken::from(new_access_token.clone())),
1404                    "in-memory access token should be the freshly refreshed one"
1405                );
1406
1407                match oauth_session.payload() {
1408                    OAuthGrant::RefreshToken(payload) => {
1409                        assert_eq!(
1410                            payload.refresh_token,
1411                            SecretRefreshToken::from(rotated_refresh_token.clone()),
1412                            "in-memory refresh token should be updated to the rotated value"
1413                        );
1414                    }
1415                    other => panic!("expected a RefreshToken grant, got {other:?}"),
1416                }
1417
1418                let token_payload = Secrets::load_from_path(&secrets_file_path)
1419                    .expect("should load secrets")
1420                    .credentials
1421                    .remove("default")
1422                    .expect("should get default credentials")
1423                    .token_payload
1424                    .expect("should get token payload");
1425
1426                assert_eq!(
1427                    token_payload.access_token,
1428                    Some(SecretAccessToken::from(new_access_token.clone())),
1429                    "new access token should be persisted to the secrets file"
1430                );
1431                assert_eq!(
1432                    token_payload.refresh_token,
1433                    Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1434                    "rotated refresh token should be persisted to the secrets file, otherwise \
1435                     the next process to load this profile will retry the stale, \
1436                     already-consumed refresh token and be forced back into a login flow"
1437                );
1438            });
1439
1440            Ok(())
1441        });
1442    }
1443
1444    /// A profile's `credentials_name` may differ from the profile's own name, and several profiles
1445    /// may point at the same credential. Tokens are *read* from `credentials.<credentials_name>`,
1446    /// so they must also be *written* there.
1447    ///
1448    /// Persisting under the profile name instead means every refresh lands in an entry nobody
1449    /// reads, leaving the credential actually in use permanently stale: the profile works until
1450    /// the access token expires (~1 hour), then retries the same consumed refresh token forever.
1451    ///
1452    /// This exercises the runtime path taken by ordinary API calls
1453    /// ([`ClientConfiguration::get_bearer_access_token`] -> [`TokenDispatcher::refresh`]), not just
1454    /// the login path.
1455    #[test]
1456    #[serial_test::serial(oauth2_test_server)]
1457    fn test_refresh_persists_to_credentials_name_not_profile_name() {
1458        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1459
1460        let mock_server = runtime.block_on(MockServer::start_async());
1461
1462        let new_access_token = Claims::new_valid().to_encoded();
1463        let rotated_refresh_token = "rotated_refresh_token".to_string();
1464
1465        let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1466            when.method(GET).path("/.well-known/openid-configuration");
1467            then.status(200)
1468                .json_body_obj(&oidc::Discovery::new_for_test(
1469                    mock_server.base_url().parse().unwrap(),
1470                ));
1471        }));
1472
1473        let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1474            when.method(POST).path("/v1/token");
1475            then.status(200).json_body_obj(&RefreshTokenResponse {
1476                access_token: SecretAccessToken::from(new_access_token.clone()),
1477                refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1478            });
1479        }));
1480
1481        let client_id = "client_id";
1482        let issuer = mock_server.base_url();
1483        let initial_refresh_token = "initial_refresh_token";
1484        let expired_access_token = Claims::new_expired().to_encoded();
1485
1486        // The profile and the credential it uses are deliberately named differently.
1487        let profile_name = "funnel";
1488        let credentials_name = "shared";
1489
1490        figment::Jail::expect_with(|jail| {
1491            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1492
1493            let directory = jail.directory();
1494            let settings_file_name = "settings.toml";
1495            let settings_file_path = directory.join(settings_file_name);
1496
1497            let secrets_file_name = "secrets.toml";
1498            let secrets_file_path = directory.join(secrets_file_name);
1499
1500            let settings_file_contents = format!(
1501                r#"
1502default_profile_name = "{profile_name}"
1503
1504[profiles]
1505[profiles.{profile_name}]
1506api_url = ""
1507auth_server_name = "default"
1508credentials_name = "{credentials_name}"
1509
1510[auth_servers]
1511[auth_servers.default]
1512client_id = "{client_id}"
1513issuer = "{issuer}"
1514"#
1515            );
1516
1517            // A decoy credential named after the profile. Persisting by profile name would write
1518            // here, silently succeed, and leave `{credentials_name}` (the one actually loaded)
1519            // stale, which is exactly the failure this test guards against.
1520            let secrets_file_contents = format!(
1521                r#"
1522[credentials]
1523[credentials.{credentials_name}]
1524[credentials.{credentials_name}.token_payload]
1525access_token = "{expired_access_token}"
1526refresh_token = "{initial_refresh_token}"
1527
1528[credentials.{profile_name}]
1529[credentials.{profile_name}.token_payload]
1530access_token = "decoy_access_token"
1531refresh_token = "decoy_refresh_token"
1532"#
1533            );
1534
1535            jail.create_file(settings_file_name, &settings_file_contents)
1536                .expect("should create test settings.toml");
1537            jail.set_env(
1538                SETTINGS_PATH_VAR,
1539                settings_file_path
1540                    .to_str()
1541                    .expect("settings file path should be a string"),
1542            );
1543
1544            jail.create_file(secrets_file_name, &secrets_file_contents)
1545                .expect("should create test secrets.toml");
1546            jail.set_env(
1547                SECRETS_PATH_VAR,
1548                secrets_file_path
1549                    .to_str()
1550                    .expect("secrets file path should be a string"),
1551            );
1552
1553            runtime.block_on(async {
1554                let configuration = ClientConfiguration::load_profile(profile_name.to_string())
1555                    .expect("should load the profile");
1556
1557                assert_eq!(configuration.profile(), profile_name);
1558                assert_eq!(configuration.credentials_name(), credentials_name);
1559
1560                // The stored access token is expired, so this refreshes and persists.
1561                let access_token = configuration
1562                    .get_bearer_access_token()
1563                    .await
1564                    .expect("should refresh the expired access token");
1565
1566                oidc_mock.assert_async().await;
1567                issuer_mock.assert_async().await;
1568
1569                assert_eq!(
1570                    access_token,
1571                    SecretAccessToken::from(new_access_token.clone())
1572                );
1573
1574                let mut credentials = Secrets::load_from_path(&secrets_file_path)
1575                    .expect("should load secrets")
1576                    .credentials;
1577
1578                let token_payload = credentials
1579                    .remove(credentials_name)
1580                    .expect("should get the credential the profile points at")
1581                    .token_payload
1582                    .expect("should get token payload");
1583
1584                assert_eq!(
1585                    token_payload.access_token,
1586                    Some(SecretAccessToken::from(new_access_token.clone())),
1587                    "the refreshed access token should be persisted under `credentials_name`, \
1588                     which is where the next load reads it from"
1589                );
1590                assert_eq!(
1591                    token_payload.refresh_token,
1592                    Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1593                    "the rotated refresh token should be persisted under `credentials_name`"
1594                );
1595
1596                let decoy_payload = credentials
1597                    .remove(profile_name)
1598                    .expect("decoy credential should still exist")
1599                    .token_payload
1600                    .expect("decoy credential should still have a token payload");
1601
1602                assert_eq!(
1603                    decoy_payload.access_token,
1604                    Some(SecretAccessToken::from("decoy_access_token".to_string())),
1605                    "the credential named after the profile is not the one in use and \
1606                     should be left untouched"
1607                );
1608            });
1609
1610            Ok(())
1611        });
1612    }
1613}