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