1use 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 rigetti_pyo3::pyo3_stub_gen::derive::gen_stub_pyclass;
38
39use self::{
40 secrets::{Credential, Secrets, TokenPayload},
41 settings::Settings,
42};
43
44pub(crate) mod error;
45pub mod fs;
46mod oidc;
47mod pkce;
48mod secret_string;
49pub mod secrets;
50pub mod settings;
51pub mod tokens;
52
53pub use error::{LoadError, TokenError};
54#[cfg(feature = "python")]
55pub(crate) mod py;
56
57use settings::AuthServer;
58use tokens::{
59 OAuthGrant, OAuthSession, PkceFlow, RefreshToken, TokenDispatcher, persist_oauth_session,
60};
61
62pub const DEFAULT_PROFILE_NAME: &str = "default";
64pub const PROFILE_NAME_VAR: &str = "QCS_PROFILE_NAME";
66fn env_or_default_profile_name() -> String {
67 env::var(PROFILE_NAME_VAR).unwrap_or_else(|_| DEFAULT_PROFILE_NAME.to_string())
68}
69
70pub const DEFAULT_API_URL: &str = "https://api.qcs.rigetti.com";
72pub const API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_API_URL";
74fn env_or_default_api_url() -> String {
75 env::var(API_URL_VAR).unwrap_or_else(|_| DEFAULT_API_URL.to_string())
76}
77
78pub const DEFAULT_GRPC_API_URL: &str = "https://grpc.qcs.rigetti.com";
80pub const GRPC_API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_GRPC_URL";
82fn env_or_default_grpc_url() -> String {
83 env::var(GRPC_API_URL_VAR).unwrap_or_else(|_| DEFAULT_GRPC_API_URL.to_string())
84}
85
86pub const DEFAULT_QVM_URL: &str = "http://127.0.0.1:5000";
88pub const QVM_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QVM_URL";
90fn env_or_default_qvm_url() -> String {
91 env::var(QVM_URL_VAR).unwrap_or_else(|_| DEFAULT_QVM_URL.to_string())
92}
93
94pub const DEFAULT_QUILC_URL: &str = "tcp://127.0.0.1:5555";
96pub const QUILC_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QUILC_URL";
98fn env_or_default_quilc_url() -> String {
99 env::var(QUILC_URL_VAR).unwrap_or_else(|_| DEFAULT_QUILC_URL.to_string())
100}
101
102#[derive(Clone, Debug, Builder)]
115#[cfg_attr(
116 not(feature = "stubs"),
117 builder_struct_attr(optipy::strip_pyo3(only_stubs)),
118 optipy::strip_pyo3(only_stubs)
119)]
120#[cfg_attr(
121 not(feature = "python"),
122 builder_struct_attr(optipy::strip_pyo3),
123 optipy::strip_pyo3
124)]
125#[cfg_attr(
126 feature = "stubs",
127 builder_struct_attr(gen_stub_pyclass),
128 gen_stub_pyclass
129)]
130#[cfg_attr(
131 feature = "python",
132 builder_struct_attr(pyo3::pyclass(
133 module = "qcs_api_client_common._qcs_api_client_common.configuration",
134 from_py_object
135 )),
136 pyo3::pyclass(
137 module = "qcs_api_client_common._qcs_api_client_common.configuration",
138 frozen,
139 from_py_object
140 )
141)]
142pub struct ClientConfiguration {
143 #[builder(private, default = "env_or_default_profile_name()")]
144 #[builder_field_attr(gen_stub(skip))]
145 profile: String,
146
147 #[builder(private, default = "env_or_default_profile_name()")]
153 #[builder_field_attr(gen_stub(skip))]
154 credentials_name: String,
155
156 #[doc = "The URL for the QCS REST API."]
157 #[builder(default = "env_or_default_api_url()")]
158 #[builder_field_attr(pyo3(get, set))]
159 #[pyo3(get)]
160 api_url: String,
161
162 #[doc = "The URL for the QCS gRPC API."]
163 #[builder(default = "env_or_default_grpc_url()")]
164 #[builder_field_attr(pyo3(get, set))]
165 #[pyo3(get)]
166 grpc_api_url: String,
167
168 #[doc = "The URL of the quilc server."]
169 #[builder(default = "env_or_default_quilc_url()")]
170 #[builder_field_attr(pyo3(get, set))]
171 #[pyo3(get)]
172 quilc_url: String,
173
174 #[doc = "The URL of the QVM server."]
175 #[builder(default = "env_or_default_qvm_url()")]
176 #[builder_field_attr(pyo3(get, set))]
177 #[pyo3(get)]
178 qvm_url: String,
179
180 #[builder(default, setter(custom))]
185 #[builder_field_attr(pyo3(get))]
186 pub(crate) oauth_session: Option<TokenDispatcher>,
187
188 #[builder(private, default = "ConfigSource::Builder")]
189 #[builder_field_attr(gen_stub(skip))]
190 source: ConfigSource,
191
192 #[cfg(feature = "tracing-config")]
194 #[builder(default)]
195 #[builder_field_attr(gen_stub(skip))]
196 tracing_configuration: Option<TracingConfiguration>,
197}
198
199impl ClientConfigurationBuilder {
200 pub fn oauth_session(&mut self, oauth_session: Option<OAuthSession>) -> &mut Self {
205 self.oauth_session = Some(oauth_session.map(Into::into));
206 self
207 }
208}
209
210struct ConfigurationContext {
212 builder: ClientConfigurationBuilder,
213 auth_server: AuthServer,
214 credential: Option<Credential>,
215 source: ConfigSource,
221 credentials_name: String,
224}
225
226impl ConfigurationContext {
227 fn from_profile(profile_name: Option<String>) -> Result<Self, LoadError> {
228 #[cfg(feature = "tracing-config")]
229 match profile_name.as_ref() {
230 None => tracing::debug!("loading default QCS profile"),
231 Some(profile) => {
232 tracing::debug!("loading QCS profile {profile}")
233 }
234 }
235 let settings = Settings::load()?;
236 let secrets = Secrets::load()?;
237 Self::from_sources(settings, secrets, profile_name)
238 }
239
240 fn from_sources(
241 settings: Settings,
242 mut secrets: Secrets,
243 profile_name: Option<String>,
244 ) -> Result<Self, LoadError> {
245 let Settings {
246 default_profile_name,
247 mut profiles,
248 mut auth_servers,
249 file_path: settings_path,
250 } = settings;
251 let profile_name = profile_name
252 .or_else(|| env::var(PROFILE_NAME_VAR).ok())
253 .unwrap_or(default_profile_name);
254 let profile = profiles
255 .remove(&profile_name)
256 .ok_or(LoadError::ProfileNotFound(profile_name.clone()))?;
257 let auth_server = auth_servers
258 .remove(&profile.auth_server_name)
259 .ok_or_else(|| LoadError::AuthServerNotFound(profile.auth_server_name.clone()))?;
260
261 let secrets_path = secrets.file_path;
262 let credentials_name = profile.credentials_name;
263 let credential = secrets.credentials.remove(&credentials_name);
264
265 let api_url = env::var(API_URL_VAR)
266 .unwrap_or(profile.api_url)
267 .trim_end_matches('/')
268 .to_string();
269 let quilc_url = env::var(QUILC_URL_VAR).unwrap_or(profile.applications.pyquil.quilc_url);
270 let qvm_url = env::var(QVM_URL_VAR).unwrap_or(profile.applications.pyquil.qvm_url);
271 let grpc_api_url = env::var(GRPC_API_URL_VAR)
272 .unwrap_or(profile.grpc_api_url)
273 .trim_end_matches('/')
274 .to_string();
275
276 #[cfg(feature = "tracing-config")]
277 let tracing_configuration =
278 TracingConfiguration::from_env().map_err(LoadError::TracingFilterParseError)?;
279
280 let source = match (settings_path, secrets_path) {
281 (Some(settings_path), Some(secrets_path)) => ConfigSource::File {
282 settings_path,
283 secrets_path,
284 },
285 _ => ConfigSource::Default,
286 };
287
288 let mut builder = ClientConfiguration::builder();
289 builder
290 .profile(profile_name)
291 .credentials_name(credentials_name.clone())
292 .source(source.clone())
293 .api_url(api_url)
294 .quilc_url(quilc_url)
295 .qvm_url(qvm_url)
296 .grpc_api_url(grpc_api_url);
297
298 #[cfg(feature = "tracing-config")]
299 {
300 builder.tracing_configuration(tracing_configuration);
301 }
302
303 Ok(Self {
304 builder,
305 auth_server,
306 credential,
307 source,
308 credentials_name,
309 })
310 }
311}
312
313async fn persist_or_warn(
318 oauth_session: &OAuthSession,
319 source: &ConfigSource,
320 credentials_name: &str,
321) {
322 if let Err(_error) = persist_oauth_session(oauth_session, source, credentials_name).await {
323 #[cfg(feature = "tracing")]
324 tracing::warn!(
325 "Refreshed QCS credentials but failed to persist them to the secrets file: {_error}"
326 );
327 }
328}
329
330fn credential_to_oauth_session(
331 credential: Option<Credential>,
332 auth_server: AuthServer,
333) -> Option<OAuthSession> {
334 match credential {
335 Some(Credential {
336 token_payload:
337 Some(TokenPayload {
338 access_token,
339 refresh_token,
340 ..
341 }),
342 }) => Some(OAuthSession::new(
343 OAuthGrant::RefreshToken(RefreshToken::new(refresh_token.unwrap_or_default())),
344 auth_server,
345 access_token,
346 )),
347 _ => None,
348 }
349}
350
351impl ClientConfiguration {
352 #[cfg(test)]
353 fn new(
354 settings: Settings,
355 secrets: Secrets,
356 profile_name: Option<String>,
357 ) -> Result<Self, LoadError> {
358 let ConfigurationContext {
359 mut builder,
360 auth_server,
361 credential,
362 ..
363 } = ConfigurationContext::from_sources(settings, secrets, profile_name)?;
364 let oauth_session = credential_to_oauth_session(credential, auth_server);
365 Ok(builder.oauth_session(oauth_session).build()?)
366 }
367
368 pub fn load_default() -> Result<Self, LoadError> {
374 let base_config = Self::load(None)?;
375 Ok(base_config)
376 }
377
378 pub fn load_profile(profile_name: String) -> Result<Self, LoadError> {
385 Self::load(Some(profile_name))
386 }
387
388 pub async fn load_with_login(
397 cancel_token: CancellationToken,
398 profile_name: Option<String>,
399 ) -> Result<Self, LoadError> {
400 let ConfigurationContext {
401 mut builder,
402 auth_server,
403 credential,
404 source,
405 credentials_name,
406 } = ConfigurationContext::from_profile(profile_name)?;
407
408 if let Some(Credential {
410 token_payload:
411 Some(TokenPayload {
412 access_token,
413 refresh_token,
414 ..
415 }),
416 }) = credential
417 {
418 if let Some(access_token) = access_token {
420 if insecure_validate_token_exp(&access_token).is_ok() {
421 let refresh_token = refresh_token.unwrap_or_default();
422
423 let oauth_session = OAuthSession::new(
424 OAuthGrant::RefreshToken(RefreshToken::new(refresh_token)),
425 auth_server,
426 Some(access_token),
427 );
428 return Ok(builder.oauth_session(Some(oauth_session)).build()?);
429 }
430 }
431
432 if let Some(refresh_token) = refresh_token
434 && !refresh_token.is_empty()
435 {
436 let mut refresh_token = RefreshToken::new(refresh_token);
437
438 if let Ok(access_token) = refresh_token.request_access_token(&auth_server).await {
440 let oauth_session = OAuthSession::new(
441 OAuthGrant::RefreshToken(refresh_token),
442 auth_server,
443 Some(access_token),
444 );
445
446 persist_or_warn(&oauth_session, &source, &credentials_name).await;
448
449 return Ok(builder.oauth_session(Some(oauth_session)).build()?);
450 }
451 }
452 }
453
454 let pkce_flow = PkceFlow::new_login_flow(cancel_token, &auth_server).await?;
456 let access_token = pkce_flow.access_token.clone();
457 let oauth_session =
458 OAuthSession::from_pkce_flow(pkce_flow, auth_server, Some(access_token));
459
460 persist_or_warn(&oauth_session, &source, &credentials_name).await;
465
466 Ok(builder.oauth_session(Some(oauth_session)).build()?)
467 }
468
469 fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
477 let ConfigurationContext {
478 mut builder,
479 auth_server,
480 credential,
481 source: _,
482 credentials_name: _,
483 } = ConfigurationContext::from_profile(profile_name)?;
484 let oauth_session = credential_to_oauth_session(credential, auth_server);
485 Ok(builder.oauth_session(oauth_session).build()?)
486 }
487
488 #[must_use]
490 pub fn builder() -> ClientConfigurationBuilder {
491 ClientConfigurationBuilder::default()
492 }
493
494 #[must_use]
496 pub fn profile(&self) -> &str {
497 &self.profile
498 }
499
500 #[must_use]
503 pub fn credentials_name(&self) -> &str {
504 &self.credentials_name
505 }
506
507 #[must_use]
509 pub fn api_url(&self) -> &str {
510 &self.api_url
511 }
512
513 #[must_use]
515 pub fn grpc_api_url(&self) -> &str {
516 &self.grpc_api_url
517 }
518
519 #[must_use]
521 pub fn quilc_url(&self) -> &str {
522 &self.quilc_url
523 }
524
525 #[must_use]
527 pub fn qvm_url(&self) -> &str {
528 &self.qvm_url
529 }
530
531 #[cfg(feature = "tracing-config")]
533 #[must_use]
534 pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
535 self.tracing_configuration.as_ref()
536 }
537
538 #[must_use]
540 pub const fn source(&self) -> &ConfigSource {
541 &self.source
542 }
543
544 pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
552 Ok(self
553 .oauth_session
554 .as_ref()
555 .ok_or(TokenError::NoRefreshToken)?
556 .tokens()
557 .await)
558 }
559
560 pub async fn get_bearer_access_token(&self) -> Result<SecretAccessToken, TokenError> {
566 let dispatcher = self
567 .oauth_session
568 .as_ref()
569 .ok_or_else(|| TokenError::NoCredentials)?;
570 match dispatcher.validate().await {
571 Ok(tokens) => Ok(tokens),
572 #[allow(unused_variables)]
573 Err(e) => {
574 #[cfg(feature = "tracing-config")]
575 tracing::debug!("Refreshing access token because current one is invalid: {e}");
576 dispatcher
577 .refresh(self.source(), self.credentials_name())
578 .await
579 .map(|e| e.access_token().cloned())?
580 }
581 }
582 }
583
584 pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
590 self.oauth_session
591 .as_ref()
592 .ok_or(TokenError::NoRefreshToken)?
593 .refresh(self.source(), self.credentials_name())
594 .await
595 }
596}
597
598#[derive(Clone, Debug)]
600pub enum ConfigSource {
601 Builder,
603 File {
605 settings_path: PathBuf,
607 secrets_path: PathBuf,
609 },
610 Default,
612}
613
614fn expand_path_from_env_or_default(
615 env_var_name: &str,
616 default: &str,
617) -> Result<PathBuf, LoadError> {
618 match env::var(env_var_name) {
619 Ok(path) => {
620 let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
621 let path_buf: PathBuf = expanded_path.as_ref().into();
622 if !path_buf.exists() {
623 return Err(LoadError::Path {
624 path: path_buf,
625 message: format!("The given path does not exist: {path}"),
626 });
627 }
628 Ok(path_buf)
629 }
630 Err(env::VarError::NotPresent) => {
631 let expanded_path = shellexpand::tilde_with_context(default, || {
632 env::home_dir().map(|path| path.display().to_string())
633 });
634 let path_buf: PathBuf = expanded_path.as_ref().into();
635 if !path_buf.exists() {
636 return Err(LoadError::Path {
637 path: path_buf,
638 message: format!(
639 "Could not find a QCS configuration at the default path: {default}"
640 ),
641 });
642 }
643 Ok(path_buf)
644 }
645 Err(other_error) => Err(LoadError::EnvVar {
646 variable_name: env_var_name.to_string(),
647 message: other_error.to_string(),
648 }),
649 }
650}
651
652#[cfg(test)]
653mod test {
654 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
655
656 use httpmock::prelude::*;
657 use jsonwebtoken::{EncodingKey, Header, encode};
658 use serde::Serialize;
659 use time::{Duration, OffsetDateTime};
660 use tokio_util::sync::CancellationToken;
661
662 use crate::configuration::{
663 API_URL_VAR, AuthServer, ClientConfiguration, DEFAULT_QUILC_URL, GRPC_API_URL_VAR,
664 OAuthGrant, OAuthSession, QUILC_URL_VAR, QVM_URL_VAR, RefreshToken,
665 expand_path_from_env_or_default, oidc,
666 pkce::tests::PkceTestServerHarness,
667 secrets::{
668 SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR, SecretAccessToken, SecretRefreshToken, Secrets,
669 },
670 settings::{SETTINGS_PATH_VAR, Settings},
671 tokens::{RefreshTokenResponse, TokenRefresher},
672 };
673
674 use super::{settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials};
675
676 #[test]
677 fn expands_env_var() {
678 figment::Jail::expect_with(|jail| {
679 let dir = jail.create_dir("~/blah/blah/")?;
680 jail.create_file(dir.join("file.toml"), "")?;
681 jail.set_env("SOME_PATH", "blah/blah");
682 jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
683 let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
684 assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
685
686 Ok(())
687 });
688 }
689
690 #[test]
691 fn uses_env_var_overrides() {
692 figment::Jail::expect_with(|jail| {
693 let quilc_url = "tcp://quilc:5555";
694 let qvm_url = "http://qvm:5000";
695 let grpc_url = "http://grpc:80";
696 let api_url = "http://api:80";
697
698 jail.set_env(QUILC_URL_VAR, quilc_url);
699 jail.set_env(QVM_URL_VAR, qvm_url);
700 jail.set_env(API_URL_VAR, api_url);
701 jail.set_env(GRPC_API_URL_VAR, grpc_url);
702
703 let config = ClientConfiguration::new(
704 Settings::default(),
705 Secrets::default(),
706 Some("default".to_string()),
707 )
708 .expect("Should be able to build default config.");
709
710 assert_eq!(config.quilc_url, quilc_url);
711 assert_eq!(config.qvm_url, qvm_url);
712 assert_eq!(config.grpc_api_url, grpc_url);
713
714 Ok(())
715 });
716 }
717
718 #[tokio::test]
719 async fn test_default_uses_env_var_overrides() {
720 figment::Jail::expect_with(|jail| {
721 let quilc_url = "quilc_url";
722 let qvm_url = "qvm_url";
723 let grpc_url = "grpc_url";
724 let api_url = "api_url";
725
726 jail.set_env(QUILC_URL_VAR, quilc_url);
727 jail.set_env(QVM_URL_VAR, qvm_url);
728 jail.set_env(GRPC_API_URL_VAR, grpc_url);
729 jail.set_env(API_URL_VAR, api_url);
730
731 let config = ClientConfiguration::load_default().unwrap();
732 assert_eq!(config.quilc_url, quilc_url);
733 assert_eq!(config.qvm_url, qvm_url);
734 assert_eq!(config.grpc_api_url, grpc_url);
735 assert_eq!(config.api_url, api_url);
736
737 Ok(())
738 });
739 }
740
741 #[test]
742 fn test_default_loads_settings_with_partial_profile_applications() {
743 figment::Jail::expect_with(|jail| {
744 let directory = jail.directory();
745 let settings_file_name = "settings.toml";
746 let settings_file_path = directory.join(settings_file_name);
747
748 let quilc_url_env_var = "env-var://quilc.url/after";
749
750 let settings_file_contents = r#"
751default_profile_name = "default"
752
753[profiles]
754[profiles.default]
755api_url = ""
756auth_server_name = "default"
757credentials_name = "default"
758applications = {}
759
760[auth_servers]
761[auth_servers.default]
762client_id = ""
763issuer = ""
764"#;
765 jail.create_file(settings_file_name, settings_file_contents)
766 .expect("should create test settings.toml");
767
768 jail.set_env(
769 "QCS_SETTINGS_FILE_PATH",
770 settings_file_path
771 .to_str()
772 .expect("settings file path should be a string"),
773 );
774
775 let config = ClientConfiguration::load_default().unwrap();
777 assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
778
779 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
780
781 let config = ClientConfiguration::load_default().unwrap();
783 assert_eq!(config.quilc_url, quilc_url_env_var);
784
785 Ok(())
786 });
787 }
788
789 #[test]
790 fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
791 figment::Jail::expect_with(|jail| {
792 let directory = jail.directory();
793 let settings_file_name = "settings.toml";
794 let settings_file_path = directory.join(settings_file_name);
795
796 let quilc_url_settings_toml = "settings-toml://quilc.url";
797 let quilc_url_env_var = "env-var://quilc.url/after";
798
799 let settings_file_contents = format!(
800 r#"
801default_profile_name = "default"
802
803[profiles]
804[profiles.default]
805api_url = ""
806auth_server_name = "default"
807credentials_name = "default"
808applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
809
810[auth_servers]
811[auth_servers.default]
812client_id = ""
813issuer = ""
814"#
815 );
816
817 jail.create_file(settings_file_name, &settings_file_contents)
818 .expect("should create test settings.toml");
819
820 jail.set_env(
821 "QCS_SETTINGS_FILE_PATH",
822 settings_file_path
823 .to_str()
824 .expect("settings file path should be a string"),
825 );
826
827 let config = ClientConfiguration::load_default().unwrap();
829 assert_eq!(config.quilc_url, quilc_url_settings_toml);
830
831 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
832
833 let config = ClientConfiguration::load_default().unwrap();
835 assert_eq!(config.quilc_url, quilc_url_env_var);
836
837 Ok(())
838 });
839 }
840
841 #[tokio::test]
842 async fn test_hydrate_access_token_on_load() {
843 let mut config = ClientConfiguration::builder().build().unwrap();
844 let access_token = "test_access_token";
845 figment::Jail::expect_with(|jail| {
846 let directory = jail.directory();
847 let settings_file_name = "settings.toml";
848 let settings_file_path = directory.join(settings_file_name);
849 let secrets_file_name = "secrets.toml";
850 let secrets_file_path = directory.join(secrets_file_name);
851
852 let settings_file_contents = r#"
853default_profile_name = "default"
854
855[profiles]
856[profiles.default]
857api_url = ""
858auth_server_name = "default"
859credentials_name = "default"
860
861[auth_servers]
862[auth_servers.default]
863client_id = ""
864issuer = ""
865"#;
866
867 let secrets_file_contents = format!(
868 r#"
869[credentials]
870[credentials.default]
871[credentials.default.token_payload]
872access_token = "{access_token}"
873expires_in = 3600
874id_token = "id_token"
875refresh_token = "refresh_token"
876scope = "offline_access openid profile email"
877token_type = "Bearer"
878"#
879 );
880
881 jail.create_file(settings_file_name, settings_file_contents)
882 .expect("should create test settings.toml");
883 jail.create_file(secrets_file_name, &secrets_file_contents)
884 .expect("should create test settings.toml");
885
886 jail.set_env(
887 "QCS_SETTINGS_FILE_PATH",
888 settings_file_path
889 .to_str()
890 .expect("settings file path should be a string"),
891 );
892 jail.set_env(
893 "QCS_SECRETS_FILE_PATH",
894 secrets_file_path
895 .to_str()
896 .expect("secrets file path should be a string"),
897 );
898
899 config = ClientConfiguration::load_default().unwrap();
900 Ok(())
901 });
902 assert_eq!(
903 config.get_access_token().await.unwrap().unwrap(),
904 SecretAccessToken::from(access_token)
905 );
906 }
907
908 #[derive(Clone, Debug, Serialize)]
909 struct Claims {
910 exp: i64,
911 iss: String,
912 sub: String,
913 }
914
915 impl Default for Claims {
916 fn default() -> Self {
917 Self {
918 exp: 0,
919 iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
920 sub: "qcs@rigetti.com".to_string(),
921 }
922 }
923 }
924
925 impl Claims {
926 fn new_valid() -> Self {
927 Self {
928 exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
929 ..Self::default()
930 }
931 }
932
933 fn new_expired() -> Self {
934 Self {
935 exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
936 ..Self::default()
937 }
938 }
939
940 fn to_encoded(&self) -> String {
941 encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
942 }
943
944 fn to_access_token(&self) -> SecretAccessToken {
945 SecretAccessToken::from(self.to_encoded())
946 }
947 }
948
949 #[test]
950 fn test_valid_token() {
951 let valid_token = Claims::new_valid().to_access_token();
952 let tokens = OAuthSession::from_refresh_token(
953 RefreshToken::new(SecretRefreshToken::from("unused")),
954 AuthServer::default(),
955 Some(valid_token.clone()),
956 );
957 assert_eq!(
958 tokens
959 .validate()
960 .expect("Token should not fail validation."),
961 valid_token
962 );
963 }
964
965 #[test]
966 fn test_expired_token() {
967 let invalid_token = Claims::new_expired().to_access_token();
968 let tokens = OAuthSession::from_refresh_token(
969 RefreshToken::new(SecretRefreshToken::from("unused")),
970 AuthServer::default(),
971 Some(invalid_token),
972 );
973 assert!(tokens.validate().is_err());
974 }
975
976 #[test]
977 fn test_client_credentials_without_access_token() {
978 let tokens = OAuthSession::from_client_credentials(
979 ClientCredentials::new("client_id", "client_secret"),
980 AuthServer::default(),
981 None,
982 );
983 assert!(tokens.validate().is_err());
984 }
985
986 #[tokio::test]
987 async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
988 let access_token = Claims::new_valid().to_encoded();
989 let mut config = ClientConfiguration::builder().build().unwrap();
990 figment::Jail::expect_with(|jail| {
991 let directory = jail.directory();
992 let settings_file_name = "settings.toml";
993 let settings_file_path = directory.join(settings_file_name);
994 let secrets_file_name = "secrets.toml";
995 let secrets_file_path = directory.join(secrets_file_name);
996
997 let settings_file_contents = r#"
998default_profile_name = "default"
999
1000[profiles]
1001[profiles.default]
1002api_url = ""
1003auth_server_name = "default"
1004credentials_name = "default"
1005
1006[auth_servers]
1007[auth_servers.default]
1008client_id = ""
1009issuer = ""
1010"#;
1011
1012 let secrets_file_contents = format!(
1014 r#"
1015[credentials]
1016[credentials.default]
1017[credentials.default.token_payload]
1018access_token = "{access_token}"
1019expires_in = 3600
1020id_token = "id_token"
1021scope = "offline_access openid profile email"
1022token_type = "Bearer"
1023"#
1024 );
1025
1026 jail.create_file(settings_file_name, settings_file_contents)
1027 .expect("should create test settings.toml");
1028 jail.create_file(secrets_file_name, &secrets_file_contents)
1029 .expect("should create test secrets.toml");
1030
1031 jail.set_env(
1032 "QCS_SETTINGS_FILE_PATH",
1033 settings_file_path
1034 .to_str()
1035 .expect("settings file path should be a string"),
1036 );
1037 jail.set_env(
1038 "QCS_SECRETS_FILE_PATH",
1039 secrets_file_path
1040 .to_str()
1041 .expect("secrets file path should be a string"),
1042 );
1043
1044 config = ClientConfiguration::load_default().unwrap();
1045 Ok(())
1046 });
1047
1048 assert_eq!(
1049 config.get_bearer_access_token().await.unwrap(),
1050 SecretAccessToken::from(access_token)
1051 );
1052 }
1053
1054 #[test]
1056 #[serial_test::serial(oauth2_test_server)]
1057 fn test_pkce_flow_persists_token() {
1058 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1061
1062 let PkceTestServerHarness {
1063 server,
1064 client,
1065 discovery: _,
1066 redirect_port: _,
1067 } = runtime.block_on(PkceTestServerHarness::new());
1068
1069 let client_id = client.client_id;
1070 let issuer = server.issuer().to_string();
1071
1072 figment::Jail::expect_with(|jail| {
1073 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1076
1077 let directory = jail.directory();
1078 let settings_file_name = "settings.toml";
1079 let settings_file_path = directory.join(settings_file_name);
1080
1081 let secrets_file_name = "secrets.toml";
1082 let secrets_file_path = directory.join(secrets_file_name);
1083
1084 let settings_file_contents = format!(
1085 r#"
1086default_profile_name = "default"
1087
1088[profiles]
1089[profiles.default]
1090api_url = ""
1091auth_server_name = "default"
1092credentials_name = "default"
1093
1094[auth_servers]
1095[auth_servers.default]
1096client_id = "{client_id}"
1097issuer = "{issuer}"
1098"#
1099 );
1100
1101 let secrets_file_contents = r#"
1102[credentials]
1103[credentials.default]
1104[credentials.default.token_payload]
1105access_token = ""
1106"#;
1107
1108 jail.create_file(settings_file_name, &settings_file_contents)
1109 .expect("should create test settings.toml");
1110
1111 jail.set_env(
1112 SETTINGS_PATH_VAR,
1113 settings_file_path
1114 .to_str()
1115 .expect("settings file path should be a string"),
1116 );
1117
1118 jail.create_file(secrets_file_name, secrets_file_contents)
1119 .expect("should create test secrets.toml");
1120
1121 jail.set_env(
1122 SECRETS_PATH_VAR,
1123 secrets_file_path
1124 .to_str()
1125 .expect("secrets file path should be a string"),
1126 );
1127
1128 runtime.block_on(async {
1130 let cancel_token = CancellationToken::new();
1131 let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1133 .await
1134 .expect("should load configuration");
1135 let oauth_session = configuration.refresh().await.expect("should refresh");
1136 let token = oauth_session.validate().expect("token should be valid");
1137
1138 let configuration =
1140 ClientConfiguration::load_default().expect("should load configuration");
1141
1142 let oauth_session = configuration
1143 .oauth_session()
1144 .await
1145 .expect("should get oauth session");
1146
1147 let token_payload = Secrets::load_from_path(&secrets_file_path)
1148 .expect("should load secrets")
1149 .credentials
1150 .remove("default")
1151 .expect("should get default credentials")
1152 .token_payload
1153 .expect("should get token payload");
1154
1155 assert_eq!(
1156 token,
1157 oauth_session.validate().expect("should contain token"),
1158 "session: {oauth_session:?}, token_payload: {token_payload:?}",
1159 );
1160 assert_eq!(
1161 token_payload.access_token,
1162 Some(token),
1163 "session: {oauth_session:?}, token_payload: {token_payload:?}"
1164 );
1165 assert_ne!(
1166 token_payload.refresh_token, None,
1167 "session: {oauth_session:?}, token_payload: {token_payload:?}"
1168 );
1169 });
1170
1171 Ok(())
1172 });
1173
1174 drop(server);
1175 }
1176
1177 #[test]
1184 #[serial_test::serial(oauth2_test_server)]
1185 fn test_load_with_login_persists_login_flow_token_without_explicit_refresh() {
1186 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1189
1190 let PkceTestServerHarness {
1191 server,
1192 client,
1193 discovery: _,
1194 redirect_port: _,
1195 } = runtime.block_on(PkceTestServerHarness::new());
1196
1197 let client_id = client.client_id;
1198 let issuer = server.issuer().to_string();
1199
1200 figment::Jail::expect_with(|jail| {
1201 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1204
1205 let directory = jail.directory();
1206 let settings_file_name = "settings.toml";
1207 let settings_file_path = directory.join(settings_file_name);
1208
1209 let secrets_file_name = "secrets.toml";
1210 let secrets_file_path = directory.join(secrets_file_name);
1211
1212 let settings_file_contents = format!(
1213 r#"
1214default_profile_name = "default"
1215
1216[profiles]
1217[profiles.default]
1218api_url = ""
1219auth_server_name = "default"
1220credentials_name = "default"
1221
1222[auth_servers]
1223[auth_servers.default]
1224client_id = "{client_id}"
1225issuer = "{issuer}"
1226"#
1227 );
1228
1229 let secrets_file_contents = r#"
1230[credentials]
1231[credentials.default]
1232[credentials.default.token_payload]
1233access_token = ""
1234"#;
1235
1236 jail.create_file(settings_file_name, &settings_file_contents)
1237 .expect("should create test settings.toml");
1238
1239 jail.set_env(
1240 SETTINGS_PATH_VAR,
1241 settings_file_path
1242 .to_str()
1243 .expect("settings file path should be a string"),
1244 );
1245
1246 jail.create_file(secrets_file_name, secrets_file_contents)
1247 .expect("should create test secrets.toml");
1248
1249 jail.set_env(
1250 SECRETS_PATH_VAR,
1251 secrets_file_path
1252 .to_str()
1253 .expect("secrets file path should be a string"),
1254 );
1255
1256 runtime.block_on(async {
1257 let cancel_token = CancellationToken::new();
1258
1259 let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1262 .await
1263 .expect("should perform a login flow");
1264
1265 let oauth_session = configuration
1266 .oauth_session()
1267 .await
1268 .expect("should get oauth session");
1269 let token = oauth_session.validate().expect("token should be valid");
1270
1271 let token_payload = Secrets::load_from_path(&secrets_file_path)
1272 .expect("should load secrets")
1273 .credentials
1274 .remove("default")
1275 .expect("should get default credentials")
1276 .token_payload
1277 .expect("should get token payload");
1278
1279 assert_eq!(
1280 token_payload.access_token,
1281 Some(token),
1282 "the access token from the login flow should be persisted without an \
1283 explicit follow-up refresh"
1284 );
1285 assert!(
1286 token_payload.refresh_token.is_some(),
1287 "the refresh token from the login flow should be persisted without an \
1288 explicit follow-up refresh"
1289 );
1290 });
1291
1292 Ok(())
1293 });
1294
1295 drop(server);
1296 }
1297
1298 #[test]
1306 #[serial_test::serial(oauth2_test_server)]
1307 fn test_load_with_login_persists_rotated_refresh_token_on_refresh() {
1308 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1309
1310 let mock_server = runtime.block_on(MockServer::start_async());
1311
1312 let new_access_token = Claims::new_valid().to_encoded();
1313 let rotated_refresh_token = "rotated_refresh_token".to_string();
1314
1315 let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1316 when.method(GET).path("/.well-known/openid-configuration");
1317 then.status(200)
1318 .json_body_obj(&oidc::Discovery::new_for_test(
1319 mock_server.base_url().parse().unwrap(),
1320 ));
1321 }));
1322
1323 let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1324 when.method(POST).path("/v1/token");
1325 then.status(200).json_body_obj(&RefreshTokenResponse {
1326 access_token: SecretAccessToken::from(new_access_token.clone()),
1327 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1328 });
1329 }));
1330
1331 let client_id = "client_id";
1332 let issuer = mock_server.base_url();
1333 let initial_refresh_token = "initial_refresh_token";
1334 let expired_access_token = Claims::new_expired().to_encoded();
1335
1336 figment::Jail::expect_with(|jail| {
1337 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1338
1339 let directory = jail.directory();
1340 let settings_file_name = "settings.toml";
1341 let settings_file_path = directory.join(settings_file_name);
1342
1343 let secrets_file_name = "secrets.toml";
1344 let secrets_file_path = directory.join(secrets_file_name);
1345
1346 let settings_file_contents = format!(
1347 r#"
1348default_profile_name = "default"
1349
1350[profiles]
1351[profiles.default]
1352api_url = ""
1353auth_server_name = "default"
1354credentials_name = "default"
1355
1356[auth_servers]
1357[auth_servers.default]
1358client_id = "{client_id}"
1359issuer = "{issuer}"
1360"#
1361 );
1362
1363 let secrets_file_contents = format!(
1364 r#"
1365[credentials]
1366[credentials.default]
1367[credentials.default.token_payload]
1368access_token = "{expired_access_token}"
1369refresh_token = "{initial_refresh_token}"
1370"#
1371 );
1372
1373 jail.create_file(settings_file_name, &settings_file_contents)
1374 .expect("should create test settings.toml");
1375 jail.set_env(
1376 SETTINGS_PATH_VAR,
1377 settings_file_path
1378 .to_str()
1379 .expect("settings file path should be a string"),
1380 );
1381
1382 jail.create_file(secrets_file_name, &secrets_file_contents)
1383 .expect("should create test secrets.toml");
1384 jail.set_env(
1385 SECRETS_PATH_VAR,
1386 secrets_file_path
1387 .to_str()
1388 .expect("secrets file path should be a string"),
1389 );
1390
1391 runtime.block_on(async {
1392 let cancel_token = CancellationToken::new();
1393
1394 let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1397 .await
1398 .expect("should refresh using the stored refresh token");
1399
1400 oidc_mock.assert_async().await;
1401 issuer_mock.assert_async().await;
1402
1403 let oauth_session = configuration
1404 .oauth_session()
1405 .await
1406 .expect("should get oauth session");
1407
1408 assert_eq!(
1409 oauth_session.access_token().cloned().ok(),
1410 Some(SecretAccessToken::from(new_access_token.clone())),
1411 "in-memory access token should be the freshly refreshed one"
1412 );
1413
1414 match oauth_session.payload() {
1415 OAuthGrant::RefreshToken(payload) => {
1416 assert_eq!(
1417 payload.refresh_token,
1418 SecretRefreshToken::from(rotated_refresh_token.clone()),
1419 "in-memory refresh token should be updated to the rotated value"
1420 );
1421 }
1422 other => panic!("expected a RefreshToken grant, got {other:?}"),
1423 }
1424
1425 let token_payload = Secrets::load_from_path(&secrets_file_path)
1426 .expect("should load secrets")
1427 .credentials
1428 .remove("default")
1429 .expect("should get default credentials")
1430 .token_payload
1431 .expect("should get token payload");
1432
1433 assert_eq!(
1434 token_payload.access_token,
1435 Some(SecretAccessToken::from(new_access_token.clone())),
1436 "new access token should be persisted to the secrets file"
1437 );
1438 assert_eq!(
1439 token_payload.refresh_token,
1440 Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1441 "rotated refresh token should be persisted to the secrets file, otherwise \
1442 the next process to load this profile will retry the stale, \
1443 already-consumed refresh token and be forced back into a login flow"
1444 );
1445 });
1446
1447 Ok(())
1448 });
1449 }
1450
1451 #[test]
1463 #[serial_test::serial(oauth2_test_server)]
1464 fn test_refresh_persists_to_credentials_name_not_profile_name() {
1465 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1466
1467 let mock_server = runtime.block_on(MockServer::start_async());
1468
1469 let new_access_token = Claims::new_valid().to_encoded();
1470 let rotated_refresh_token = "rotated_refresh_token".to_string();
1471
1472 let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1473 when.method(GET).path("/.well-known/openid-configuration");
1474 then.status(200)
1475 .json_body_obj(&oidc::Discovery::new_for_test(
1476 mock_server.base_url().parse().unwrap(),
1477 ));
1478 }));
1479
1480 let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1481 when.method(POST).path("/v1/token");
1482 then.status(200).json_body_obj(&RefreshTokenResponse {
1483 access_token: SecretAccessToken::from(new_access_token.clone()),
1484 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1485 });
1486 }));
1487
1488 let client_id = "client_id";
1489 let issuer = mock_server.base_url();
1490 let initial_refresh_token = "initial_refresh_token";
1491 let expired_access_token = Claims::new_expired().to_encoded();
1492
1493 let profile_name = "funnel";
1495 let credentials_name = "shared";
1496
1497 figment::Jail::expect_with(|jail| {
1498 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1499
1500 let directory = jail.directory();
1501 let settings_file_name = "settings.toml";
1502 let settings_file_path = directory.join(settings_file_name);
1503
1504 let secrets_file_name = "secrets.toml";
1505 let secrets_file_path = directory.join(secrets_file_name);
1506
1507 let settings_file_contents = format!(
1508 r#"
1509default_profile_name = "{profile_name}"
1510
1511[profiles]
1512[profiles.{profile_name}]
1513api_url = ""
1514auth_server_name = "default"
1515credentials_name = "{credentials_name}"
1516
1517[auth_servers]
1518[auth_servers.default]
1519client_id = "{client_id}"
1520issuer = "{issuer}"
1521"#
1522 );
1523
1524 let secrets_file_contents = format!(
1528 r#"
1529[credentials]
1530[credentials.{credentials_name}]
1531[credentials.{credentials_name}.token_payload]
1532access_token = "{expired_access_token}"
1533refresh_token = "{initial_refresh_token}"
1534
1535[credentials.{profile_name}]
1536[credentials.{profile_name}.token_payload]
1537access_token = "decoy_access_token"
1538refresh_token = "decoy_refresh_token"
1539"#
1540 );
1541
1542 jail.create_file(settings_file_name, &settings_file_contents)
1543 .expect("should create test settings.toml");
1544 jail.set_env(
1545 SETTINGS_PATH_VAR,
1546 settings_file_path
1547 .to_str()
1548 .expect("settings file path should be a string"),
1549 );
1550
1551 jail.create_file(secrets_file_name, &secrets_file_contents)
1552 .expect("should create test secrets.toml");
1553 jail.set_env(
1554 SECRETS_PATH_VAR,
1555 secrets_file_path
1556 .to_str()
1557 .expect("secrets file path should be a string"),
1558 );
1559
1560 runtime.block_on(async {
1561 let configuration = ClientConfiguration::load_profile(profile_name.to_string())
1562 .expect("should load the profile");
1563
1564 assert_eq!(configuration.profile(), profile_name);
1565 assert_eq!(configuration.credentials_name(), credentials_name);
1566
1567 let access_token = configuration
1569 .get_bearer_access_token()
1570 .await
1571 .expect("should refresh the expired access token");
1572
1573 oidc_mock.assert_async().await;
1574 issuer_mock.assert_async().await;
1575
1576 assert_eq!(
1577 access_token,
1578 SecretAccessToken::from(new_access_token.clone())
1579 );
1580
1581 let mut credentials = Secrets::load_from_path(&secrets_file_path)
1582 .expect("should load secrets")
1583 .credentials;
1584
1585 let token_payload = credentials
1586 .remove(credentials_name)
1587 .expect("should get the credential the profile points at")
1588 .token_payload
1589 .expect("should get token payload");
1590
1591 assert_eq!(
1592 token_payload.access_token,
1593 Some(SecretAccessToken::from(new_access_token.clone())),
1594 "the refreshed access token should be persisted under `credentials_name`, \
1595 which is where the next load reads it from"
1596 );
1597 assert_eq!(
1598 token_payload.refresh_token,
1599 Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1600 "the rotated refresh token should be persisted under `credentials_name`"
1601 );
1602
1603 let decoy_payload = credentials
1604 .remove(profile_name)
1605 .expect("decoy credential should still exist")
1606 .token_payload
1607 .expect("decoy credential should still have a token payload");
1608
1609 assert_eq!(
1610 decoy_payload.access_token,
1611 Some(SecretAccessToken::from("decoy_access_token".to_string())),
1612 "the credential named after the profile is not the one in use and \
1613 should be left untouched"
1614 );
1615 });
1616
1617 Ok(())
1618 });
1619 }
1620}