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;
45mod oidc;
46mod pkce;
47mod secret_string;
48pub mod secrets;
49pub mod settings;
50pub mod tokens;
51
52pub use error::{LoadError, TokenError};
53#[cfg(feature = "python")]
54pub(crate) mod py;
55
56use settings::AuthServer;
57use tokens::{OAuthGrant, OAuthSession, PkceFlow, RefreshToken, TokenDispatcher};
58
59/// Default profile name.
60pub const DEFAULT_PROFILE_NAME: &str = "default";
61/// Setting this environment variable will change which profile is used from the loaded config files
62pub const PROFILE_NAME_VAR: &str = "QCS_PROFILE_NAME";
63fn env_or_default_profile_name() -> String {
64    env::var(PROFILE_NAME_VAR).unwrap_or_else(|_| DEFAULT_PROFILE_NAME.to_string())
65}
66
67/// Default URL to access the QCS API.
68pub const DEFAULT_API_URL: &str = "https://api.qcs.rigetti.com";
69/// Setting this environment variable will override the URL used to connect to the QCS REST API.
70pub const API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_API_URL";
71fn env_or_default_api_url() -> String {
72    env::var(API_URL_VAR).unwrap_or_else(|_| DEFAULT_API_URL.to_string())
73}
74
75/// Default URL to access the gRPC API.
76pub const DEFAULT_GRPC_API_URL: &str = "https://grpc.qcs.rigetti.com";
77/// Setting this environment variable will override the URL used to connect to the GRPC server.
78pub const GRPC_API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_GRPC_URL";
79fn env_or_default_grpc_url() -> String {
80    env::var(GRPC_API_URL_VAR).unwrap_or_else(|_| DEFAULT_GRPC_API_URL.to_string())
81}
82
83/// Default URL to access QVM.
84pub const DEFAULT_QVM_URL: &str = "http://127.0.0.1:5000";
85/// Setting this environment variable will override the URL used to access the QVM.
86pub const QVM_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QVM_URL";
87fn env_or_default_qvm_url() -> String {
88    env::var(QVM_URL_VAR).unwrap_or_else(|_| DEFAULT_QVM_URL.to_string())
89}
90
91/// Default URL to access `quilc`.
92pub const DEFAULT_QUILC_URL: &str = "tcp://127.0.0.1:5555";
93/// Setting this environment variable will override the URL used to access quilc.
94pub const QUILC_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QUILC_URL";
95fn env_or_default_quilc_url() -> String {
96    env::var(QUILC_URL_VAR).unwrap_or_else(|_| DEFAULT_QUILC_URL.to_string())
97}
98
99/// A configuration suitable for use as a QCS API Client.
100///
101/// This configuration can be constructed in a few ways.
102///
103/// The most common way is to use [`ClientConfiguration::load_default`]. This will load the
104/// configuration associated with your default QCS profile.
105///
106/// When loading your config, any values set by environment variables will override the values in
107/// your configuration files.
108///
109/// You can also build a configuration from scratch using [`ClientConfigurationBuilder`]. Using a
110/// builder bypasses configuration files and environment overrides.
111#[derive(Clone, Debug, Builder)]
112#[cfg_attr(
113    not(feature = "stubs"),
114    builder_struct_attr(optipy::strip_pyo3(only_stubs)),
115    optipy::strip_pyo3(only_stubs)
116)]
117#[cfg_attr(
118    not(feature = "python"),
119    builder_struct_attr(optipy::strip_pyo3),
120    optipy::strip_pyo3
121)]
122#[cfg_attr(
123    feature = "stubs",
124    builder_struct_attr(gen_stub_pyclass),
125    gen_stub_pyclass
126)]
127#[cfg_attr(
128    feature = "python",
129    builder_struct_attr(pyo3::pyclass(module = "qcs_api_client_common.configuration")),
130    pyo3::pyclass(module = "qcs_api_client_common.configuration", frozen)
131)]
132pub struct ClientConfiguration {
133    #[builder(private, default = "env_or_default_profile_name()")]
134    #[builder_field_attr(gen_stub(skip))]
135    profile: String,
136
137    #[doc = "The URL for the QCS REST API."]
138    #[builder(default = "env_or_default_api_url()")]
139    #[builder_field_attr(pyo3(get, set))]
140    #[pyo3(get)]
141    api_url: String,
142
143    #[doc = "The URL for the QCS gRPC API."]
144    #[builder(default = "env_or_default_grpc_url()")]
145    #[builder_field_attr(pyo3(get, set))]
146    #[pyo3(get)]
147    grpc_api_url: String,
148
149    #[doc = "The URL of the quilc server."]
150    #[builder(default = "env_or_default_quilc_url()")]
151    #[builder_field_attr(pyo3(get, set))]
152    #[pyo3(get)]
153    quilc_url: String,
154
155    #[doc = "The URL of the QVM server."]
156    #[builder(default = "env_or_default_qvm_url()")]
157    #[builder_field_attr(pyo3(get, set))]
158    #[pyo3(get)]
159    qvm_url: String,
160
161    /// Provides a single, semi-shared access to user credential tokens.
162    ///
163    /// Note that the tokens are *not* shared when the `ClientConfiguration` is created multiple
164    /// times, e.g. through [`ClientConfiguration::load_default`].
165    #[builder(default, setter(custom))]
166    #[builder_field_attr(pyo3(get))]
167    pub(crate) oauth_session: Option<TokenDispatcher>,
168
169    #[builder(private, default = "ConfigSource::Builder")]
170    #[builder_field_attr(gen_stub(skip))]
171    source: ConfigSource,
172
173    /// Configuration for tracing of network API calls. If `None`, tracing is disabled.
174    #[cfg(feature = "tracing-config")]
175    #[builder(default)]
176    #[builder_field_attr(gen_stub(skip))]
177    tracing_configuration: Option<TracingConfiguration>,
178}
179
180impl ClientConfigurationBuilder {
181    /// The [`OAuthSession`] to use to authenticate with the QCS API.
182    ///
183    /// When set to [`None`], the configuration will not manage an OAuth Session, and access to the
184    /// QCS API will be limited to unauthenticated routes.
185    pub fn oauth_session(&mut self, oauth_session: Option<OAuthSession>) -> &mut Self {
186        self.oauth_session = Some(oauth_session.map(Into::into));
187        self
188    }
189}
190
191/// The common context used to build a [`ClientConfiguration`].
192struct ConfigurationContext {
193    builder: ClientConfigurationBuilder,
194    auth_server: AuthServer,
195    credential: Option<Credential>,
196}
197
198impl ConfigurationContext {
199    fn from_profile(profile_name: Option<String>) -> Result<Self, LoadError> {
200        #[cfg(feature = "tracing-config")]
201        match profile_name.as_ref() {
202            None => tracing::debug!("loading default QCS profile"),
203            Some(profile) => {
204                tracing::debug!("loading QCS profile {profile}")
205            }
206        }
207        let settings = Settings::load()?;
208        let secrets = Secrets::load()?;
209        Self::from_sources(settings, secrets, profile_name)
210    }
211
212    fn from_sources(
213        settings: Settings,
214        mut secrets: Secrets,
215        profile_name: Option<String>,
216    ) -> Result<Self, LoadError> {
217        let Settings {
218            default_profile_name,
219            mut profiles,
220            mut auth_servers,
221            file_path: settings_path,
222        } = settings;
223        let profile_name = profile_name
224            .or_else(|| env::var(PROFILE_NAME_VAR).ok())
225            .unwrap_or(default_profile_name);
226        let profile = profiles
227            .remove(&profile_name)
228            .ok_or(LoadError::ProfileNotFound(profile_name.clone()))?;
229        let auth_server = auth_servers
230            .remove(&profile.auth_server_name)
231            .ok_or_else(|| LoadError::AuthServerNotFound(profile.auth_server_name.clone()))?;
232
233        let secrets_path = secrets.file_path;
234        let credential = secrets.credentials.remove(&profile.credentials_name);
235
236        let api_url = env::var(API_URL_VAR)
237            .unwrap_or(profile.api_url)
238            .trim_end_matches('/')
239            .to_string();
240        let quilc_url = env::var(QUILC_URL_VAR).unwrap_or(profile.applications.pyquil.quilc_url);
241        let qvm_url = env::var(QVM_URL_VAR).unwrap_or(profile.applications.pyquil.qvm_url);
242        let grpc_api_url = env::var(GRPC_API_URL_VAR)
243            .unwrap_or(profile.grpc_api_url)
244            .trim_end_matches('/')
245            .to_string();
246
247        #[cfg(feature = "tracing-config")]
248        let tracing_configuration =
249            TracingConfiguration::from_env().map_err(LoadError::TracingFilterParseError)?;
250
251        let source = match (settings_path, secrets_path) {
252            (Some(settings_path), Some(secrets_path)) => ConfigSource::File {
253                settings_path,
254                secrets_path,
255            },
256            _ => ConfigSource::Default,
257        };
258
259        let mut builder = ClientConfiguration::builder();
260        builder
261            .profile(profile_name)
262            .source(source)
263            .api_url(api_url)
264            .quilc_url(quilc_url)
265            .qvm_url(qvm_url)
266            .grpc_api_url(grpc_api_url);
267
268        #[cfg(feature = "tracing-config")]
269        {
270            builder.tracing_configuration(tracing_configuration);
271        }
272
273        Ok(Self {
274            builder,
275            auth_server,
276            credential,
277        })
278    }
279}
280
281fn credential_to_oauth_session(
282    credential: Option<Credential>,
283    auth_server: AuthServer,
284) -> Option<OAuthSession> {
285    match credential {
286        Some(Credential {
287            token_payload:
288                Some(TokenPayload {
289                    access_token,
290                    refresh_token,
291                    ..
292                }),
293        }) => Some(OAuthSession::new(
294            OAuthGrant::RefreshToken(RefreshToken::new(refresh_token.unwrap_or_default())),
295            auth_server,
296            access_token,
297        )),
298        _ => None,
299    }
300}
301
302impl ClientConfiguration {
303    #[cfg(test)]
304    fn new(
305        settings: Settings,
306        secrets: Secrets,
307        profile_name: Option<String>,
308    ) -> Result<Self, LoadError> {
309        let ConfigurationContext {
310            mut builder,
311            auth_server,
312            credential,
313        } = ConfigurationContext::from_sources(settings, secrets, profile_name)?;
314        let oauth_session = credential_to_oauth_session(credential, auth_server);
315        Ok(builder.oauth_session(oauth_session).build()?)
316    }
317
318    /// Attempts to load config files
319    ///
320    /// # Errors
321    ///
322    /// See [`LoadError`]
323    pub fn load_default() -> Result<Self, LoadError> {
324        let base_config = Self::load(None)?;
325        Ok(base_config)
326    }
327
328    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
329    /// specified profile.
330    ///
331    /// # Errors
332    ///
333    /// See [`LoadError`]
334    pub fn load_profile(profile_name: String) -> Result<Self, LoadError> {
335        Self::load(Some(profile_name))
336    }
337
338    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
339    /// specified profile. If no `profile_name` is provided, then a default configuration is
340    /// loaded. When stored OAuth credentials are unavailable, this method falls back to an
341    /// interactive PKCE login flow.
342    ///
343    /// # Errors
344    ///
345    /// See [`LoadError`]
346    pub async fn load_with_login(
347        cancel_token: CancellationToken,
348        profile_name: Option<String>,
349    ) -> Result<Self, LoadError> {
350        let ConfigurationContext {
351            mut builder,
352            auth_server,
353            credential,
354        } = ConfigurationContext::from_profile(profile_name)?;
355
356        // If the stored access or refresh tokens are valid, skip the login flow
357        if let Some(Credential {
358            token_payload:
359                Some(TokenPayload {
360                    access_token,
361                    refresh_token,
362                    ..
363                }),
364        }) = credential
365        {
366            // The current access token is valid, use it
367            if let Some(access_token) = access_token {
368                if insecure_validate_token_exp(&access_token).is_ok() {
369                    let refresh_token = refresh_token.unwrap_or_default();
370
371                    let oauth_session = OAuthSession::new(
372                        OAuthGrant::RefreshToken(RefreshToken::new(refresh_token)),
373                        auth_server,
374                        Some(access_token),
375                    );
376                    return Ok(builder.oauth_session(Some(oauth_session)).build()?);
377                }
378            }
379
380            // The access token is invalid, try to refresh it
381            if let Some(refresh_token) = refresh_token {
382                if !refresh_token.is_empty() {
383                    let mut refresh_token = RefreshToken::new(refresh_token);
384
385                    // If the refresh token is valid, use it
386                    if let Ok(access_token) = refresh_token.request_access_token(&auth_server).await
387                    {
388                        let oauth_session = OAuthSession::new(
389                            OAuthGrant::RefreshToken(refresh_token),
390                            auth_server,
391                            Some(access_token),
392                        );
393
394                        return Ok(builder.oauth_session(Some(oauth_session)).build()?);
395                    }
396                }
397            }
398        }
399
400        // At this point the stored credentials are known to be invalid, so a login is required
401        let pkce_flow = PkceFlow::new_login_flow(cancel_token, &auth_server).await?;
402        let access_token = pkce_flow.access_token.clone();
403        let oauth_session =
404            OAuthSession::from_pkce_flow(pkce_flow, auth_server, Some(access_token));
405
406        Ok(builder.oauth_session(Some(oauth_session)).build()?)
407    }
408
409    /// Attempts to load a QCS configuration and creates a [`ClientConfiguration`] using the
410    /// specified profile. If no `profile_name` is provided, then a default configuration is
411    /// loaded.
412    ///
413    /// # Errors
414    ///
415    /// See [`LoadError`]
416    fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
417        let ConfigurationContext {
418            mut builder,
419            auth_server,
420            credential,
421        } = ConfigurationContext::from_profile(profile_name)?;
422        let oauth_session = credential_to_oauth_session(credential, auth_server);
423        Ok(builder.oauth_session(oauth_session).build()?)
424    }
425
426    /// Get a [`ClientConfigurationBuilder`]
427    #[must_use]
428    pub fn builder() -> ClientConfigurationBuilder {
429        ClientConfigurationBuilder::default()
430    }
431
432    /// Get the name of the profile that was loaded, if any.
433    #[must_use]
434    pub fn profile(&self) -> &str {
435        &self.profile
436    }
437
438    /// Get the URL of the QCS REST API.
439    #[must_use]
440    pub fn api_url(&self) -> &str {
441        &self.api_url
442    }
443
444    /// Get the URL of the QCS gRPC API.
445    #[must_use]
446    pub fn grpc_api_url(&self) -> &str {
447        &self.grpc_api_url
448    }
449
450    /// Get the URL of the quilc server.
451    #[must_use]
452    pub fn quilc_url(&self) -> &str {
453        &self.quilc_url
454    }
455
456    /// Get the URL of the QVM server.
457    #[must_use]
458    pub fn qvm_url(&self) -> &str {
459        &self.qvm_url
460    }
461
462    /// Get the [`TracingConfiguration`].
463    #[cfg(feature = "tracing-config")]
464    #[must_use]
465    pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
466        self.tracing_configuration.as_ref()
467    }
468
469    /// Get the source of the configuration.
470    #[must_use]
471    pub const fn source(&self) -> &ConfigSource {
472        &self.source
473    }
474
475    /// Get a copy of the current [`OAuthSession`].
476    ///
477    /// Note: This is a _copy_, the contained tokens will become stale once they expire.
478    ///
479    /// # Errors
480    ///
481    /// See [`TokenError`]
482    pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
483        Ok(self
484            .oauth_session
485            .as_ref()
486            .ok_or(TokenError::NoRefreshToken)?
487            .tokens()
488            .await)
489    }
490
491    /// Gets the `Bearer` access token, refreshing it if it is expired.
492    ///
493    /// # Errors
494    ///
495    /// See [`TokenError`].
496    pub async fn get_bearer_access_token(&self) -> Result<SecretAccessToken, TokenError> {
497        let dispatcher = self
498            .oauth_session
499            .as_ref()
500            .ok_or_else(|| TokenError::NoCredentials)?;
501        match dispatcher.validate().await {
502            Ok(tokens) => Ok(tokens),
503            #[allow(unused_variables)]
504            Err(e) => {
505                #[cfg(feature = "tracing-config")]
506                tracing::debug!("Refreshing access token because current one is invalid: {e}");
507                dispatcher
508                    .refresh(self.source(), self.profile())
509                    .await
510                    .map(|e| e.access_token().cloned())?
511            }
512        }
513    }
514
515    /// Refreshes the [`Tokens`] in use and returns the new bearer access token.
516    ///
517    /// # Errors
518    ///
519    /// See [`TokenError`]
520    pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
521        self.oauth_session
522            .as_ref()
523            .ok_or(TokenError::NoRefreshToken)?
524            .refresh(self.source(), self.profile())
525            .await
526    }
527}
528
529/// Describes how a [`ClientConfiguration`] was initialized.
530#[derive(Clone, Debug)]
531pub enum ConfigSource {
532    /// A [`ClientConfiguration`] derived from a [`ClientConfigurationBuilder`]
533    Builder,
534    /// A [`ClientConfiguration`] derived from at least one file.
535    File {
536        /// The path to the QCS `settings.toml` file used to initialize the [`ClientConfiguration`].
537        settings_path: PathBuf,
538        /// The path to a QCS `secrets.toml` file used to initialize the [`ClientConfiguration`].
539        secrets_path: PathBuf,
540    },
541    /// A [`ClientConfiguration`] derived from default values.
542    Default,
543}
544
545fn expand_path_from_env_or_default(
546    env_var_name: &str,
547    default: &str,
548) -> Result<PathBuf, LoadError> {
549    match env::var(env_var_name) {
550        Ok(path) => {
551            let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
552            let path_buf: PathBuf = expanded_path.as_ref().into();
553            if !path_buf.exists() {
554                return Err(LoadError::Path {
555                    path: path_buf,
556                    message: format!("The given path does not exist: {path}"),
557                });
558            }
559            Ok(path_buf)
560        }
561        Err(env::VarError::NotPresent) => {
562            let expanded_path = shellexpand::tilde_with_context(default, || {
563                env::home_dir().map(|path| path.display().to_string())
564            });
565            let path_buf: PathBuf = expanded_path.as_ref().into();
566            if !path_buf.exists() {
567                return Err(LoadError::Path {
568                    path: path_buf,
569                    message: format!(
570                        "Could not find a QCS configuration at the default path: {default}"
571                    ),
572                });
573            }
574            Ok(path_buf)
575        }
576        Err(other_error) => Err(LoadError::EnvVar {
577            variable_name: env_var_name.to_string(),
578            message: other_error.to_string(),
579        }),
580    }
581}
582
583#[cfg(test)]
584mod test {
585    #![allow(clippy::result_large_err, reason = "happens in figment tests")]
586
587    use jsonwebtoken::{EncodingKey, Header, encode};
588    use serde::Serialize;
589    use time::{Duration, OffsetDateTime};
590    use tokio_util::sync::CancellationToken;
591
592    use crate::configuration::{
593        API_URL_VAR, AuthServer, ClientConfiguration, DEFAULT_QUILC_URL, GRPC_API_URL_VAR,
594        OAuthSession, QUILC_URL_VAR, QVM_URL_VAR, RefreshToken, expand_path_from_env_or_default,
595        pkce::tests::PkceTestServerHarness,
596        secrets::{
597            SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR, SecretAccessToken, SecretRefreshToken, Secrets,
598        },
599        settings::{SETTINGS_PATH_VAR, Settings},
600        tokens::TokenRefresher,
601    };
602
603    use super::{settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials};
604
605    #[test]
606    fn expands_env_var() {
607        figment::Jail::expect_with(|jail| {
608            let dir = jail.create_dir("~/blah/blah/")?;
609            jail.create_file(dir.join("file.toml"), "")?;
610            jail.set_env("SOME_PATH", "blah/blah");
611            jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
612            let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
613            assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
614
615            Ok(())
616        });
617    }
618
619    #[test]
620    fn uses_env_var_overrides() {
621        figment::Jail::expect_with(|jail| {
622            let quilc_url = "tcp://quilc:5555";
623            let qvm_url = "http://qvm:5000";
624            let grpc_url = "http://grpc:80";
625            let api_url = "http://api:80";
626
627            jail.set_env(QUILC_URL_VAR, quilc_url);
628            jail.set_env(QVM_URL_VAR, qvm_url);
629            jail.set_env(API_URL_VAR, api_url);
630            jail.set_env(GRPC_API_URL_VAR, grpc_url);
631
632            let config = ClientConfiguration::new(
633                Settings::default(),
634                Secrets::default(),
635                Some("default".to_string()),
636            )
637            .expect("Should be able to build default config.");
638
639            assert_eq!(config.quilc_url, quilc_url);
640            assert_eq!(config.qvm_url, qvm_url);
641            assert_eq!(config.grpc_api_url, grpc_url);
642
643            Ok(())
644        });
645    }
646
647    #[tokio::test]
648    async fn test_default_uses_env_var_overrides() {
649        figment::Jail::expect_with(|jail| {
650            let quilc_url = "quilc_url";
651            let qvm_url = "qvm_url";
652            let grpc_url = "grpc_url";
653            let api_url = "api_url";
654
655            jail.set_env(QUILC_URL_VAR, quilc_url);
656            jail.set_env(QVM_URL_VAR, qvm_url);
657            jail.set_env(GRPC_API_URL_VAR, grpc_url);
658            jail.set_env(API_URL_VAR, api_url);
659
660            let config = ClientConfiguration::load_default().unwrap();
661            assert_eq!(config.quilc_url, quilc_url);
662            assert_eq!(config.qvm_url, qvm_url);
663            assert_eq!(config.grpc_api_url, grpc_url);
664            assert_eq!(config.api_url, api_url);
665
666            Ok(())
667        });
668    }
669
670    #[test]
671    fn test_default_loads_settings_with_partial_profile_applications() {
672        figment::Jail::expect_with(|jail| {
673            let directory = jail.directory();
674            let settings_file_name = "settings.toml";
675            let settings_file_path = directory.join(settings_file_name);
676
677            let quilc_url_env_var = "env-var://quilc.url/after";
678
679            let settings_file_contents = r#"
680default_profile_name = "default"
681
682[profiles]
683[profiles.default]
684api_url = ""
685auth_server_name = "default"
686credentials_name = "default"
687applications = {}
688
689[auth_servers]
690[auth_servers.default]
691client_id = ""
692issuer = ""
693"#;
694            jail.create_file(settings_file_name, settings_file_contents)
695                .expect("should create test settings.toml");
696
697            jail.set_env(
698                "QCS_SETTINGS_FILE_PATH",
699                settings_file_path
700                    .to_str()
701                    .expect("settings file path should be a string"),
702            );
703
704            // before setting env var
705            let config = ClientConfiguration::load_default().unwrap();
706            assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
707
708            jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
709
710            // after setting env var
711            let config = ClientConfiguration::load_default().unwrap();
712            assert_eq!(config.quilc_url, quilc_url_env_var);
713
714            Ok(())
715        });
716    }
717
718    #[test]
719    fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
720        figment::Jail::expect_with(|jail| {
721            let directory = jail.directory();
722            let settings_file_name = "settings.toml";
723            let settings_file_path = directory.join(settings_file_name);
724
725            let quilc_url_settings_toml = "settings-toml://quilc.url";
726            let quilc_url_env_var = "env-var://quilc.url/after";
727
728            let settings_file_contents = format!(
729                r#"
730default_profile_name = "default"
731
732[profiles]
733[profiles.default]
734api_url = ""
735auth_server_name = "default"
736credentials_name = "default"
737applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
738
739[auth_servers]
740[auth_servers.default]
741client_id = ""
742issuer = ""
743"#
744            );
745
746            jail.create_file(settings_file_name, &settings_file_contents)
747                .expect("should create test settings.toml");
748
749            jail.set_env(
750                "QCS_SETTINGS_FILE_PATH",
751                settings_file_path
752                    .to_str()
753                    .expect("settings file path should be a string"),
754            );
755
756            // before setting env var
757            let config = ClientConfiguration::load_default().unwrap();
758            assert_eq!(config.quilc_url, quilc_url_settings_toml);
759
760            jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
761
762            // after setting env var
763            let config = ClientConfiguration::load_default().unwrap();
764            assert_eq!(config.quilc_url, quilc_url_env_var);
765
766            Ok(())
767        });
768    }
769
770    #[tokio::test]
771    async fn test_hydrate_access_token_on_load() {
772        let mut config = ClientConfiguration::builder().build().unwrap();
773        let access_token = "test_access_token";
774        figment::Jail::expect_with(|jail| {
775            let directory = jail.directory();
776            let settings_file_name = "settings.toml";
777            let settings_file_path = directory.join(settings_file_name);
778            let secrets_file_name = "secrets.toml";
779            let secrets_file_path = directory.join(secrets_file_name);
780
781            let settings_file_contents = r#"
782default_profile_name = "default"
783
784[profiles]
785[profiles.default]
786api_url = ""
787auth_server_name = "default"
788credentials_name = "default"
789
790[auth_servers]
791[auth_servers.default]
792client_id = ""
793issuer = ""
794"#;
795
796            let secrets_file_contents = format!(
797                r#"
798[credentials]
799[credentials.default]
800[credentials.default.token_payload]
801access_token = "{access_token}"
802expires_in = 3600
803id_token = "id_token"
804refresh_token = "refresh_token"
805scope = "offline_access openid profile email"
806token_type = "Bearer"
807"#
808            );
809
810            jail.create_file(settings_file_name, settings_file_contents)
811                .expect("should create test settings.toml");
812            jail.create_file(secrets_file_name, &secrets_file_contents)
813                .expect("should create test settings.toml");
814
815            jail.set_env(
816                "QCS_SETTINGS_FILE_PATH",
817                settings_file_path
818                    .to_str()
819                    .expect("settings file path should be a string"),
820            );
821            jail.set_env(
822                "QCS_SECRETS_FILE_PATH",
823                secrets_file_path
824                    .to_str()
825                    .expect("secrets file path should be a string"),
826            );
827
828            config = ClientConfiguration::load_default().unwrap();
829            Ok(())
830        });
831        assert_eq!(
832            config.get_access_token().await.unwrap().unwrap(),
833            SecretAccessToken::from(access_token)
834        );
835    }
836
837    #[derive(Clone, Debug, Serialize)]
838    struct Claims {
839        exp: i64,
840        iss: String,
841        sub: String,
842    }
843
844    impl Default for Claims {
845        fn default() -> Self {
846            Self {
847                exp: 0,
848                iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
849                sub: "qcs@rigetti.com".to_string(),
850            }
851        }
852    }
853
854    impl Claims {
855        fn new_valid() -> Self {
856            Self {
857                exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
858                ..Self::default()
859            }
860        }
861
862        fn new_expired() -> Self {
863            Self {
864                exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
865                ..Self::default()
866            }
867        }
868
869        fn to_encoded(&self) -> String {
870            encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
871        }
872
873        fn to_access_token(&self) -> SecretAccessToken {
874            SecretAccessToken::from(self.to_encoded())
875        }
876    }
877
878    #[test]
879    fn test_valid_token() {
880        let valid_token = Claims::new_valid().to_access_token();
881        let tokens = OAuthSession::from_refresh_token(
882            RefreshToken::new(SecretRefreshToken::from("unused")),
883            AuthServer::default(),
884            Some(valid_token.clone()),
885        );
886        assert_eq!(
887            tokens
888                .validate()
889                .expect("Token should not fail validation."),
890            valid_token
891        );
892    }
893
894    #[test]
895    fn test_expired_token() {
896        let invalid_token = Claims::new_expired().to_access_token();
897        let tokens = OAuthSession::from_refresh_token(
898            RefreshToken::new(SecretRefreshToken::from("unused")),
899            AuthServer::default(),
900            Some(invalid_token),
901        );
902        assert!(tokens.validate().is_err());
903    }
904
905    #[test]
906    fn test_client_credentials_without_access_token() {
907        let tokens = OAuthSession::from_client_credentials(
908            ClientCredentials::new("client_id", "client_secret"),
909            AuthServer::default(),
910            None,
911        );
912        assert!(tokens.validate().is_err());
913    }
914
915    #[tokio::test]
916    async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
917        let access_token = Claims::new_valid().to_encoded();
918        let mut config = ClientConfiguration::builder().build().unwrap();
919        figment::Jail::expect_with(|jail| {
920            let directory = jail.directory();
921            let settings_file_name = "settings.toml";
922            let settings_file_path = directory.join(settings_file_name);
923            let secrets_file_name = "secrets.toml";
924            let secrets_file_path = directory.join(secrets_file_name);
925
926            let settings_file_contents = r#"
927default_profile_name = "default"
928
929[profiles]
930[profiles.default]
931api_url = ""
932auth_server_name = "default"
933credentials_name = "default"
934
935[auth_servers]
936[auth_servers.default]
937client_id = ""
938issuer = ""
939"#;
940
941            // note this has no `refresh_token` property
942            let secrets_file_contents = format!(
943                r#"
944[credentials]
945[credentials.default]
946[credentials.default.token_payload]
947access_token = "{access_token}"
948expires_in = 3600
949id_token = "id_token"
950scope = "offline_access openid profile email"
951token_type = "Bearer"
952"#
953            );
954
955            jail.create_file(settings_file_name, settings_file_contents)
956                .expect("should create test settings.toml");
957            jail.create_file(secrets_file_name, &secrets_file_contents)
958                .expect("should create test secrets.toml");
959
960            jail.set_env(
961                "QCS_SETTINGS_FILE_PATH",
962                settings_file_path
963                    .to_str()
964                    .expect("settings file path should be a string"),
965            );
966            jail.set_env(
967                "QCS_SECRETS_FILE_PATH",
968                secrets_file_path
969                    .to_str()
970                    .expect("secrets file path should be a string"),
971            );
972
973            config = ClientConfiguration::load_default().unwrap();
974            Ok(())
975        });
976
977        assert_eq!(
978            config.get_bearer_access_token().await.unwrap(),
979            SecretAccessToken::from(access_token)
980        );
981    }
982
983    /// Exercises the PKCE login flow end-to-end, ensuring that the token is persisted to the secrets file.
984    #[test]
985    #[serial_test::serial(oauth2_test_server)]
986    fn test_pkce_flow_persists_token() {
987        // Because we need to block on the runtime inside the jail function,
988        // we have to create one manually here instead of relying on #[tokio::test].
989        let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
990
991        let PkceTestServerHarness {
992            server,
993            client,
994            discovery: _,
995            redirect_port: _,
996        } = runtime.block_on(PkceTestServerHarness::new());
997
998        let client_id = client.client_id;
999        let issuer = server.issuer().to_string();
1000
1001        figment::Jail::expect_with(|jail| {
1002            // In CI, the secrets file is mounted as read-only,
1003            // but these tmp testing files should be writable.
1004            jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1005
1006            let directory = jail.directory();
1007            let settings_file_name = "settings.toml";
1008            let settings_file_path = directory.join(settings_file_name);
1009
1010            let secrets_file_name = "secrets.toml";
1011            let secrets_file_path = directory.join(secrets_file_name);
1012
1013            let settings_file_contents = format!(
1014                r#"
1015default_profile_name = "default"
1016
1017[profiles]
1018[profiles.default]
1019api_url = ""
1020auth_server_name = "default"
1021credentials_name = "default"
1022
1023[auth_servers]
1024[auth_servers.default]
1025client_id = "{client_id}"
1026issuer = "{issuer}"
1027"#
1028            );
1029
1030            let secrets_file_contents = r#"
1031[credentials]
1032[credentials.default]
1033[credentials.default.token_payload]
1034access_token = ""
1035"#;
1036
1037            jail.create_file(settings_file_name, &settings_file_contents)
1038                .expect("should create test settings.toml");
1039
1040            jail.set_env(
1041                SETTINGS_PATH_VAR,
1042                settings_file_path
1043                    .to_str()
1044                    .expect("settings file path should be a string"),
1045            );
1046
1047            jail.create_file(secrets_file_name, secrets_file_contents)
1048                .expect("should create test secrets.toml");
1049
1050            jail.set_env(
1051                SECRETS_PATH_VAR,
1052                secrets_file_path
1053                    .to_str()
1054                    .expect("secrets file path should be a string"),
1055            );
1056
1057            // should perform a login flow, which persists the token to the secrets file.
1058            runtime.block_on(async {
1059                let cancel_token = CancellationToken::new();
1060                // should load the configuration and perform a login flow
1061                let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1062                    .await
1063                    .expect("should load configuration");
1064                let oauth_session = configuration.refresh().await.expect("should refresh");
1065                let token = oauth_session.validate().expect("token should be valid");
1066
1067                // now, the configuration should load without needing to perform a login flow
1068                let configuration =
1069                    ClientConfiguration::load_default().expect("should load configuration");
1070
1071                let oauth_session = configuration
1072                    .oauth_session()
1073                    .await
1074                    .expect("should get oauth session");
1075
1076                let token_payload = Secrets::load_from_path(&secrets_file_path)
1077                    .expect("should load secrets")
1078                    .credentials
1079                    .remove("default")
1080                    .expect("should get default credentials")
1081                    .token_payload
1082                    .expect("should get token payload");
1083
1084                assert_eq!(
1085                    token,
1086                    oauth_session.validate().expect("should contain token"),
1087                    "session: {oauth_session:?}, token_payload: {token_payload:?}",
1088                );
1089                assert_eq!(
1090                    token_payload.access_token,
1091                    Some(token),
1092                    "session: {oauth_session:?}, token_payload: {token_payload:?}"
1093                );
1094                assert_ne!(
1095                    token_payload.refresh_token, None,
1096                    "session: {oauth_session:?}, token_payload: {token_payload:?}"
1097                );
1098            });
1099
1100            Ok(())
1101        });
1102
1103        drop(server);
1104    }
1105}