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