1#[cfg(feature = "tracing-config")]
30use crate::tracing_configuration::TracingConfiguration;
31use derive_builder::Builder;
32use std::{env, path::PathBuf};
33
34#[cfg(feature = "python")]
35use pyo3::prelude::*;
36
37use self::{
38 secrets::{Credential, Secrets, TokenPayload},
39 settings::Settings,
40};
41
42mod error;
43#[cfg(feature = "python")]
44mod py;
45mod secrets;
46mod settings;
47mod tokens;
48
49pub use error::{LoadError, TokenError};
50#[cfg(feature = "python")]
51pub(crate) use py::*;
52pub use secrets::{DEFAULT_SECRETS_PATH, SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR};
53pub use settings::{AuthServer, DEFAULT_SETTINGS_PATH, SETTINGS_PATH_VAR};
54pub use tokens::{
55 ClientCredentials, ExternallyManaged, OAuthGrant, OAuthSession, RefreshFunction, RefreshToken,
56 TokenDispatcher, TokenRefresher,
57};
58
59const QCS_AUDIENCE: &str = "api://qcs";
60
61pub const DEFAULT_PROFILE_NAME: &str = "default";
63pub const PROFILE_NAME_VAR: &str = "QCS_PROFILE_NAME";
65fn env_or_default_profile_name() -> String {
66 env::var(PROFILE_NAME_VAR).unwrap_or_else(|_| DEFAULT_PROFILE_NAME.to_string())
67}
68
69pub const DEFAULT_API_URL: &str = "https://api.qcs.rigetti.com";
71pub const API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_API_URL";
73fn env_or_default_api_url() -> String {
74 env::var(API_URL_VAR).unwrap_or_else(|_| DEFAULT_API_URL.to_string())
75}
76
77pub const DEFAULT_GRPC_API_URL: &str = "https://grpc.qcs.rigetti.com";
79pub const GRPC_API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_GRPC_URL";
81fn env_or_default_grpc_url() -> String {
82 env::var(GRPC_API_URL_VAR).unwrap_or_else(|_| DEFAULT_GRPC_API_URL.to_string())
83}
84
85pub const DEFAULT_QVM_URL: &str = "http://127.0.0.1:5000";
87pub const QVM_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QVM_URL";
89fn env_or_default_qvm_url() -> String {
90 env::var(QVM_URL_VAR).unwrap_or_else(|_| DEFAULT_QVM_URL.to_string())
91}
92
93pub const DEFAULT_QUILC_URL: &str = "tcp://127.0.0.1:5555";
95pub const QUILC_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QUILC_URL";
97fn env_or_default_quilc_url() -> String {
98 env::var(QUILC_URL_VAR).unwrap_or_else(|_| DEFAULT_QUILC_URL.to_string())
99}
100
101#[derive(Clone, Debug, Builder)]
114#[cfg_attr(feature = "python", pyclass)]
115pub struct ClientConfiguration {
116 #[builder(private, default = "env_or_default_profile_name()")]
117 profile: String,
118
119 #[doc = "The URL for the QCS REST API."]
120 #[builder(default = "env_or_default_api_url()")]
121 api_url: String,
122
123 #[doc = "The URL for the QCS gRPC API."]
124 #[builder(default = "env_or_default_grpc_url()")]
125 grpc_api_url: String,
126
127 #[doc = "The URL of the quilc server."]
128 #[builder(default = "env_or_default_quilc_url()")]
129 quilc_url: String,
130
131 #[doc = "The URL of the QVM server."]
132 #[builder(default = "env_or_default_qvm_url()")]
133 qvm_url: String,
134
135 #[builder(default, setter(custom))]
140 pub(crate) oauth_session: Option<TokenDispatcher>,
141
142 #[builder(private, default = "ConfigSource::Builder")]
143 source: ConfigSource,
144
145 #[cfg(feature = "tracing-config")]
147 #[builder(default)]
148 tracing_configuration: Option<TracingConfiguration>,
149}
150
151impl ClientConfigurationBuilder {
152 pub fn oauth_session(&mut self, oauth_session: Option<OAuthSession>) -> &mut Self {
157 self.oauth_session = Some(oauth_session.map(Into::into));
158 self
159 }
160}
161
162impl ClientConfiguration {
163 fn new(
164 settings: Settings,
165 mut secrets: Secrets,
166 profile_name: Option<String>,
167 ) -> Result<Self, LoadError> {
168 let Settings {
169 default_profile_name,
170 mut profiles,
171 mut auth_servers,
172 file_path: settings_path,
173 } = settings;
174 let profile_name = profile_name
175 .or_else(|| env::var(PROFILE_NAME_VAR).ok())
176 .unwrap_or(default_profile_name);
177 let profile = profiles
178 .remove(&profile_name)
179 .ok_or(LoadError::ProfileNotFound(profile_name.clone()))?;
180 let auth_server = auth_servers
181 .remove(&profile.auth_server_name)
182 .ok_or_else(|| LoadError::AuthServerNotFound(profile.auth_server_name.clone()))?;
183
184 let secrets_path = secrets.file_path;
185 let credential = secrets.credentials.remove(&profile.credentials_name);
186 let oauth_session = match credential {
187 Some(Credential {
188 token_payload:
189 Some(TokenPayload {
190 access_token,
191 refresh_token,
192 ..
193 }),
194 }) => {
195 Some(OAuthSession::new(
196 OAuthGrant::RefreshToken(RefreshToken::new(
197 refresh_token.unwrap_or_default(),
205 )),
206 auth_server,
207 access_token,
208 ))
209 }
210 _ => None,
211 };
212
213 let api_url = env::var(API_URL_VAR).unwrap_or(profile.api_url);
214 let quilc_url = env::var(QUILC_URL_VAR).unwrap_or(profile.applications.pyquil.quilc_url);
215 let qvm_url = env::var(QVM_URL_VAR).unwrap_or(profile.applications.pyquil.qvm_url);
216 let grpc_api_url = env::var(GRPC_API_URL_VAR).unwrap_or(profile.grpc_api_url);
217
218 #[cfg(feature = "tracing-config")]
219 let tracing_configuration =
220 TracingConfiguration::from_env().map_err(LoadError::TracingFilterParseError)?;
221
222 let source = match (settings_path, secrets_path) {
223 (Some(settings_path), Some(secrets_path)) => ConfigSource::File {
224 settings_path,
225 secrets_path,
226 },
227 _ => ConfigSource::Default,
228 };
229
230 let mut builder = Self::builder();
231 builder
232 .oauth_session(oauth_session)
233 .profile(profile_name)
234 .source(source)
235 .api_url(api_url)
236 .quilc_url(quilc_url)
237 .qvm_url(qvm_url)
238 .grpc_api_url(grpc_api_url);
239
240 #[cfg(feature = "tracing-config")]
241 {
242 builder.tracing_configuration(tracing_configuration);
243 };
244
245 Ok({
246 builder
247 .build()
248 .expect("curated build process should not fail")
249 })
250 }
251
252 pub fn load_default() -> Result<Self, LoadError> {
258 let base_config = Self::load(None)?;
259 Ok(base_config)
260 }
261
262 pub fn load_profile(profile_name: String) -> Result<Self, LoadError> {
269 Self::load(Some(profile_name))
270 }
271
272 fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
280 #[cfg(feature = "tracing-config")]
281 match profile_name.as_ref() {
282 None => tracing::debug!("loading default QCS profile"),
283 Some(profile) => tracing::debug!("loading QCS profile {profile}"),
284 }
285 let settings = Settings::load()?;
286 let secrets = Secrets::load()?;
287
288 Self::new(settings, secrets, profile_name)
289 }
290
291 #[must_use]
293 pub fn builder() -> ClientConfigurationBuilder {
294 ClientConfigurationBuilder::default()
295 }
296
297 #[must_use]
299 pub fn profile(&self) -> &str {
300 &self.profile
301 }
302
303 #[must_use]
305 pub fn api_url(&self) -> &str {
306 &self.api_url
307 }
308
309 #[must_use]
311 pub fn grpc_api_url(&self) -> &str {
312 &self.grpc_api_url
313 }
314
315 #[must_use]
317 pub fn quilc_url(&self) -> &str {
318 &self.quilc_url
319 }
320
321 #[must_use]
323 pub fn qvm_url(&self) -> &str {
324 &self.qvm_url
325 }
326
327 #[cfg(feature = "tracing-config")]
329 #[must_use]
330 pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
331 self.tracing_configuration.as_ref()
332 }
333
334 #[must_use]
336 pub const fn source(&self) -> &ConfigSource {
337 &self.source
338 }
339
340 pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
348 Ok(self
349 .oauth_session
350 .as_ref()
351 .ok_or(TokenError::NoRefreshToken)?
352 .tokens()
353 .await)
354 }
355
356 pub async fn get_bearer_access_token(&self) -> Result<String, TokenError> {
362 let dispatcher = self
363 .oauth_session
364 .as_ref()
365 .ok_or_else(|| TokenError::NoCredentials)?;
366 match dispatcher.validate().await {
367 Ok(tokens) => Ok(tokens),
368 #[allow(unused_variables)]
369 Err(e) => {
370 #[cfg(feature = "tracing-config")]
371 tracing::debug!("Refreshing access token because current one is invalid: {e}");
372 dispatcher
373 .refresh(self.source(), self.profile())
374 .await
375 .map(|e| e.access_token().map(ToString::to_string))?
376 }
377 }
378 }
379
380 pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
386 self.oauth_session
387 .as_ref()
388 .ok_or(TokenError::NoRefreshToken)?
389 .refresh(self.source(), self.profile())
390 .await
391 }
392}
393
394#[derive(Clone, Debug)]
396pub enum ConfigSource {
397 Builder,
399 File {
401 settings_path: PathBuf,
403 secrets_path: PathBuf,
405 },
406 Default,
408}
409
410fn expand_path_from_env_or_default(
411 env_var_name: &str,
412 default: &str,
413) -> Result<PathBuf, LoadError> {
414 match env::var(env_var_name) {
415 Ok(path) => {
416 let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
417 let path_buf: PathBuf = expanded_path.as_ref().into();
418 if !path_buf.exists() {
419 return Err(LoadError::Path {
420 path: path_buf,
421 message: format!("The given path does not exist: {path}"),
422 });
423 }
424 Ok(path_buf)
425 }
426 Err(env::VarError::NotPresent) => {
427 let expanded_path = shellexpand::tilde(default);
428 let path_buf: PathBuf = expanded_path.as_ref().into();
429 if !path_buf.exists() {
430 return Err(LoadError::Path {
431 path: path_buf,
432 message: format!(
433 "Could not find a QCS configuration at the default path: {default}"
434 ),
435 });
436 }
437 Ok(path_buf)
438 }
439 Err(other_error) => Err(LoadError::EnvVar {
440 variable_name: env_var_name.to_string(),
441 message: other_error.to_string(),
442 }),
443 }
444}
445
446#[cfg(test)]
447mod test {
448
449 use jsonwebtoken::{encode, EncodingKey, Header};
450 use serde::Serialize;
451 use time::{Duration, OffsetDateTime};
452
453 use crate::configuration::{
454 expand_path_from_env_or_default, secrets::Secrets, settings::Settings, AuthServer,
455 ClientConfiguration, OAuthSession, RefreshToken, API_URL_VAR, DEFAULT_QUILC_URL,
456 GRPC_API_URL_VAR, QUILC_URL_VAR, QVM_URL_VAR,
457 };
458
459 use super::{
460 settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials, TokenRefresher,
461 QCS_AUDIENCE,
462 };
463
464 #[test]
465 fn expands_env_var() {
466 figment::Jail::expect_with(|jail| {
467 let dir = jail.create_dir("~/blah/blah/")?;
468 jail.create_file(dir.join("file.toml"), "")?;
469 jail.set_env("SOME_PATH", "blah/blah");
470 jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
471 let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
472 assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
473
474 Ok(())
475 });
476 }
477
478 #[test]
479 fn uses_env_var_overrides() {
480 figment::Jail::expect_with(|jail| {
481 let quilc_url = "tcp://quilc:5555";
482 let qvm_url = "http://qvm:5000";
483 let grpc_url = "http://grpc:80";
484 let api_url = "http://api:80";
485
486 jail.set_env(QUILC_URL_VAR, quilc_url);
487 jail.set_env(QVM_URL_VAR, qvm_url);
488 jail.set_env(API_URL_VAR, api_url);
489 jail.set_env(GRPC_API_URL_VAR, grpc_url);
490
491 let config = ClientConfiguration::new(
492 Settings::default(),
493 Secrets::default(),
494 Some("default".to_string()),
495 )
496 .expect("Should be able to build default config.");
497
498 assert_eq!(config.quilc_url, quilc_url);
499 assert_eq!(config.qvm_url, qvm_url);
500 assert_eq!(config.grpc_api_url, grpc_url);
501
502 Ok(())
503 });
504 }
505
506 #[tokio::test]
507 async fn test_default_uses_env_var_overrides() {
508 figment::Jail::expect_with(|jail| {
509 let quilc_url = "quilc_url";
510 let qvm_url = "qvm_url";
511 let grpc_url = "grpc_url";
512 let api_url = "api_url";
513
514 jail.set_env(QUILC_URL_VAR, quilc_url);
515 jail.set_env(QVM_URL_VAR, qvm_url);
516 jail.set_env(GRPC_API_URL_VAR, grpc_url);
517 jail.set_env(API_URL_VAR, api_url);
518
519 let config = ClientConfiguration::load_default().unwrap();
520 assert_eq!(config.quilc_url, quilc_url);
521 assert_eq!(config.qvm_url, qvm_url);
522 assert_eq!(config.grpc_api_url, grpc_url);
523 assert_eq!(config.api_url, api_url);
524
525 Ok(())
526 });
527 }
528
529 #[test]
530 fn test_default_loads_settings_with_partial_profile_applications() {
531 figment::Jail::expect_with(|jail| {
532 let directory = jail.directory();
533 let settings_file_name = "settings.toml";
534 let settings_file_path = directory.join(settings_file_name);
535
536 let quilc_url_env_var = "env-var://quilc.url/after";
537
538 let settings_file_contents = r#"
539default_profile_name = "default"
540
541[profiles]
542[profiles.default]
543api_url = ""
544auth_server_name = "default"
545credentials_name = "default"
546applications = {}
547
548[auth_servers]
549[auth_servers.default]
550client_id = ""
551issuer = ""
552"#;
553 jail.create_file(settings_file_name, settings_file_contents)
554 .expect("should create test settings.toml");
555
556 jail.set_env(
557 "QCS_SETTINGS_FILE_PATH",
558 settings_file_path
559 .to_str()
560 .expect("settings file path should be a string"),
561 );
562
563 let config = ClientConfiguration::load_default().unwrap();
565 assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
566
567 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
568
569 let config = ClientConfiguration::load_default().unwrap();
571 assert_eq!(config.quilc_url, quilc_url_env_var);
572
573 Ok(())
574 });
575 }
576
577 #[test]
578 fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
579 figment::Jail::expect_with(|jail| {
580 let directory = jail.directory();
581 let settings_file_name = "settings.toml";
582 let settings_file_path = directory.join(settings_file_name);
583
584 let quilc_url_settings_toml = "settings-toml://quilc.url";
585 let quilc_url_env_var = "env-var://quilc.url/after";
586
587 let settings_file_contents = format!(
588 r#"
589default_profile_name = "default"
590
591[profiles]
592[profiles.default]
593api_url = ""
594auth_server_name = "default"
595credentials_name = "default"
596applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
597
598[auth_servers]
599[auth_servers.default]
600client_id = ""
601issuer = ""
602"#
603 );
604
605 jail.create_file(settings_file_name, &settings_file_contents)
606 .expect("should create test settings.toml");
607
608 jail.set_env(
609 "QCS_SETTINGS_FILE_PATH",
610 settings_file_path
611 .to_str()
612 .expect("settings file path should be a string"),
613 );
614
615 let config = ClientConfiguration::load_default().unwrap();
617 assert_eq!(config.quilc_url, quilc_url_settings_toml);
618
619 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
620
621 let config = ClientConfiguration::load_default().unwrap();
623 assert_eq!(config.quilc_url, quilc_url_env_var);
624
625 Ok(())
626 });
627 }
628
629 #[tokio::test]
630 async fn test_hydrate_access_token_on_load() {
631 let mut config = ClientConfiguration::builder().build().unwrap();
632 let access_token = "test_access_token";
633 figment::Jail::expect_with(|jail| {
634 let directory = jail.directory();
635 let settings_file_name = "settings.toml";
636 let settings_file_path = directory.join(settings_file_name);
637 let secrets_file_name = "secrets.toml";
638 let secrets_file_path = directory.join(secrets_file_name);
639
640 let settings_file_contents = r#"
641default_profile_name = "default"
642
643[profiles]
644[profiles.default]
645api_url = ""
646auth_server_name = "default"
647credentials_name = "default"
648
649[auth_servers]
650[auth_servers.default]
651client_id = ""
652issuer = ""
653"#;
654
655 let secrets_file_contents = format!(
656 r#"
657[credentials]
658[credentials.default]
659[credentials.default.token_payload]
660access_token = "{access_token}"
661expires_in = 3600
662id_token = "id_token"
663refresh_token = "refresh_token"
664scope = "offline_access openid profile email"
665token_type = "Bearer"
666"#
667 );
668
669 jail.create_file(settings_file_name, settings_file_contents)
670 .expect("should create test settings.toml");
671 jail.create_file(secrets_file_name, &secrets_file_contents)
672 .expect("should create test settings.toml");
673
674 jail.set_env(
675 "QCS_SETTINGS_FILE_PATH",
676 settings_file_path
677 .to_str()
678 .expect("settings file path should be a string"),
679 );
680 jail.set_env(
681 "QCS_SECRETS_FILE_PATH",
682 secrets_file_path
683 .to_str()
684 .expect("secrets file path should be a string"),
685 );
686
687 config = ClientConfiguration::load_default().unwrap();
688 Ok(())
689 });
690 assert_eq!(
691 config.get_access_token().await.unwrap(),
692 Some(access_token.to_string())
693 );
694 }
695
696 #[derive(Clone, Debug, Serialize)]
697 struct Claims {
698 exp: i64,
699 aud: String,
700 iss: String,
701 sub: String,
702 }
703
704 impl Default for Claims {
705 fn default() -> Self {
706 Self {
707 exp: 0,
708 aud: QCS_AUDIENCE.to_string(),
709 iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
710 sub: "qcs@rigetti.com".to_string(),
711 }
712 }
713 }
714
715 impl Claims {
716 fn new_valid() -> Self {
717 Self {
718 exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
719 ..Self::default()
720 }
721 }
722
723 fn new_expired() -> Self {
724 Self {
725 exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
726 ..Self::default()
727 }
728 }
729
730 fn to_encoded(&self) -> String {
731 encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
732 }
733 }
734
735 #[test]
736 fn test_valid_token() {
737 let valid_token = Claims::new_valid().to_encoded();
738 let tokens = OAuthSession::from_refresh_token(
739 RefreshToken::new(valid_token.clone()),
740 AuthServer::default(),
741 Some(valid_token.clone()),
742 );
743 assert_eq!(
744 tokens
745 .validate()
746 .expect("Token should not fail validation."),
747 valid_token
748 );
749 }
750
751 #[test]
752 fn test_expired_token() {
753 let invalid_token = Claims::new_expired().to_encoded();
754 let tokens = OAuthSession::from_refresh_token(
755 RefreshToken::new(invalid_token),
756 AuthServer::default(),
757 None,
758 );
759 assert!(tokens.validate().is_err());
760 }
761
762 #[test]
763 fn test_client_credentials_without_access_token() {
764 let tokens = OAuthSession::from_client_credentials(
765 ClientCredentials::new("client_id".to_string(), "client_secret".to_string()),
766 AuthServer::default(),
767 None,
768 );
769 assert!(tokens.validate().is_err());
770 }
771
772 #[tokio::test]
773 async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
774 let access_token = Claims::new_valid().to_encoded();
775 let mut config = ClientConfiguration::builder().build().unwrap();
776 figment::Jail::expect_with(|jail| {
777 let directory = jail.directory();
778 let settings_file_name = "settings.toml";
779 let settings_file_path = directory.join(settings_file_name);
780 let secrets_file_name = "secrets.toml";
781 let secrets_file_path = directory.join(secrets_file_name);
782
783 let settings_file_contents = r#"
784default_profile_name = "default"
785
786[profiles]
787[profiles.default]
788api_url = ""
789auth_server_name = "default"
790credentials_name = "default"
791
792[auth_servers]
793[auth_servers.default]
794client_id = ""
795issuer = ""
796"#;
797
798 let secrets_file_contents = format!(
800 r#"
801[credentials]
802[credentials.default]
803[credentials.default.token_payload]
804access_token = "{access_token}"
805expires_in = 3600
806id_token = "id_token"
807scope = "offline_access openid profile email"
808token_type = "Bearer"
809"#
810 );
811
812 jail.create_file(settings_file_name, settings_file_contents)
813 .expect("should create test settings.toml");
814 jail.create_file(secrets_file_name, &secrets_file_contents)
815 .expect("should create test secrets.toml");
816
817 jail.set_env(
818 "QCS_SETTINGS_FILE_PATH",
819 settings_file_path
820 .to_str()
821 .expect("settings file path should be a string"),
822 );
823 jail.set_env(
824 "QCS_SECRETS_FILE_PATH",
825 secrets_file_path
826 .to_str()
827 .expect("secrets file path should be a string"),
828 );
829
830 config = ClientConfiguration::load_default().unwrap();
831 Ok(())
832 });
833
834 assert_eq!(
835 config.get_bearer_access_token().await.unwrap(),
836 access_token.to_string()
837 );
838 }
839}