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