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