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