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