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