1use crate::configuration::{secrets::SecretAccessToken, tokens::insecure_validate_token_exp};
62#[cfg(feature = "tracing-config")]
63use crate::tracing_configuration::TracingConfiguration;
64use derive_builder::Builder;
65use std::{env, path::PathBuf};
66use tokio_util::sync::CancellationToken;
67
68#[cfg(feature = "stubs")]
69use rigetti_pyo3::pyo3_stub_gen::derive::gen_stub_pyclass;
70
71use self::{
72 secrets::{Credential, Secrets, TokenPayload},
73 settings::Settings,
74};
75
76pub(crate) mod error;
77mod external_command;
78pub mod fs;
79mod login;
80mod oidc;
81mod pkce;
82mod secret_string;
83pub mod secrets;
84pub mod settings;
85pub mod tokens;
86
87pub use error::{LoadError, TokenError};
88#[cfg(feature = "python")]
89pub(crate) mod py;
90
91use pkce::RedirectBinding;
92use settings::AuthServer;
93use tokens::{
94 OAuthGrant, OAuthSession, PkceFlow, RefreshToken, TokenDispatcher, persist_oauth_session,
95};
96
97pub const DEFAULT_PROFILE_NAME: &str = "default";
99pub const PROFILE_NAME_VAR: &str = "QCS_PROFILE_NAME";
101fn env_or_default_profile_name() -> String {
102 env::var(PROFILE_NAME_VAR).unwrap_or_else(|_| DEFAULT_PROFILE_NAME.to_string())
103}
104
105pub const DEFAULT_API_URL: &str = "https://api.qcs.rigetti.com";
107pub const API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_API_URL";
109fn env_or_default_api_url() -> String {
110 env::var(API_URL_VAR).unwrap_or_else(|_| DEFAULT_API_URL.to_string())
111}
112
113pub const DEFAULT_GRPC_API_URL: &str = "https://grpc.qcs.rigetti.com";
115pub const GRPC_API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_GRPC_URL";
117fn env_or_default_grpc_url() -> String {
118 env::var(GRPC_API_URL_VAR).unwrap_or_else(|_| DEFAULT_GRPC_API_URL.to_string())
119}
120
121pub const DEFAULT_QVM_URL: &str = "http://127.0.0.1:5000";
123pub const QVM_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QVM_URL";
125fn env_or_default_qvm_url() -> String {
126 env::var(QVM_URL_VAR).unwrap_or_else(|_| DEFAULT_QVM_URL.to_string())
127}
128
129pub const DEFAULT_QUILC_URL: &str = "tcp://127.0.0.1:5555";
131pub const QUILC_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QUILC_URL";
133fn env_or_default_quilc_url() -> String {
134 env::var(QUILC_URL_VAR).unwrap_or_else(|_| DEFAULT_QUILC_URL.to_string())
135}
136
137#[derive(Clone, Debug, Builder)]
150#[cfg_attr(
151 not(feature = "stubs"),
152 builder_struct_attr(optipy::strip_pyo3(only_stubs)),
153 optipy::strip_pyo3(only_stubs)
154)]
155#[cfg_attr(
156 not(feature = "python"),
157 builder_struct_attr(optipy::strip_pyo3),
158 optipy::strip_pyo3
159)]
160#[cfg_attr(
161 feature = "stubs",
162 builder_struct_attr(gen_stub_pyclass),
163 gen_stub_pyclass
164)]
165#[cfg_attr(
166 feature = "python",
167 builder_struct_attr(pyo3::pyclass(
168 module = "qcs_api_client_common._qcs_api_client_common.configuration",
169 from_py_object
170 )),
171 pyo3::pyclass(
172 module = "qcs_api_client_common._qcs_api_client_common.configuration",
173 frozen,
174 from_py_object
175 )
176)]
177pub struct ClientConfiguration {
178 #[builder(private, default = "env_or_default_profile_name()")]
179 #[builder_field_attr(gen_stub(skip))]
180 profile: String,
181
182 #[builder(private, default = "env_or_default_profile_name()")]
188 #[builder_field_attr(gen_stub(skip))]
189 credentials_name: String,
190
191 #[doc = "The URL for the QCS REST API."]
192 #[builder(default = "env_or_default_api_url()")]
193 #[builder_field_attr(pyo3(get, set))]
194 #[pyo3(get)]
195 api_url: String,
196
197 #[doc = "The URL for the QCS gRPC API."]
198 #[builder(default = "env_or_default_grpc_url()")]
199 #[builder_field_attr(pyo3(get, set))]
200 #[pyo3(get)]
201 grpc_api_url: String,
202
203 #[doc = "The URL of the quilc server."]
204 #[builder(default = "env_or_default_quilc_url()")]
205 #[builder_field_attr(pyo3(get, set))]
206 #[pyo3(get)]
207 quilc_url: String,
208
209 #[doc = "The URL of the QVM server."]
210 #[builder(default = "env_or_default_qvm_url()")]
211 #[builder_field_attr(pyo3(get, set))]
212 #[pyo3(get)]
213 qvm_url: String,
214
215 #[builder(default, setter(custom))]
220 #[builder_field_attr(pyo3(get))]
221 pub(crate) oauth_session: Option<TokenDispatcher>,
222
223 #[builder(private, default = "ConfigSource::Builder")]
224 #[builder_field_attr(gen_stub(skip))]
225 source: ConfigSource,
226
227 #[cfg(feature = "tracing-config")]
229 #[builder(default)]
230 #[builder_field_attr(gen_stub(skip))]
231 tracing_configuration: Option<TracingConfiguration>,
232}
233
234impl ClientConfigurationBuilder {
235 pub fn oauth_session(&mut self, oauth_session: Option<OAuthSession>) -> &mut Self {
240 self.oauth_session = Some(oauth_session.map(Into::into));
241 self
242 }
243}
244
245struct ConfigurationContext {
247 builder: ClientConfigurationBuilder,
248 auth_server: AuthServer,
249 credential: Option<Credential>,
250 source: ConfigSource,
256 credentials_name: String,
259}
260
261impl ConfigurationContext {
262 fn from_profile(profile_name: Option<String>) -> Result<Self, LoadError> {
263 #[cfg(feature = "tracing-config")]
264 match profile_name.as_ref() {
265 None => tracing::debug!("loading default QCS profile"),
266 Some(profile) => {
267 tracing::debug!("loading QCS profile {profile}")
268 }
269 }
270 let settings = Settings::load()?;
271 let secrets = Secrets::load()?;
272 Self::from_sources(settings, secrets, profile_name)
273 }
274
275 fn from_sources(
276 settings: Settings,
277 mut secrets: Secrets,
278 profile_name: Option<String>,
279 ) -> Result<Self, LoadError> {
280 let Settings {
281 default_profile_name,
282 mut profiles,
283 mut auth_servers,
284 file_path: settings_path,
285 } = settings;
286 let profile_name = profile_name
287 .or_else(|| env::var(PROFILE_NAME_VAR).ok())
288 .unwrap_or(default_profile_name);
289 let profile = profiles
290 .remove(&profile_name)
291 .ok_or(LoadError::ProfileNotFound(profile_name.clone()))?;
292 let auth_server = auth_servers
293 .remove(&profile.auth_server_name)
294 .ok_or_else(|| LoadError::AuthServerNotFound(profile.auth_server_name.clone()))?;
295
296 let secrets_path = secrets.file_path;
297 let credentials_name = profile.credentials_name;
298 let credential = secrets.credentials.remove(&credentials_name);
299
300 let api_url = env::var(API_URL_VAR)
301 .unwrap_or(profile.api_url)
302 .trim_end_matches('/')
303 .to_string();
304 let quilc_url = env::var(QUILC_URL_VAR).unwrap_or(profile.applications.pyquil.quilc_url);
305 let qvm_url = env::var(QVM_URL_VAR).unwrap_or(profile.applications.pyquil.qvm_url);
306 let grpc_api_url = env::var(GRPC_API_URL_VAR)
307 .unwrap_or(profile.grpc_api_url)
308 .trim_end_matches('/')
309 .to_string();
310
311 #[cfg(feature = "tracing-config")]
312 let tracing_configuration =
313 TracingConfiguration::from_env().map_err(LoadError::TracingFilterParseError)?;
314
315 let source = match (settings_path, secrets_path) {
316 (Some(settings_path), Some(secrets_path)) => ConfigSource::File {
317 settings_path,
318 secrets_path,
319 },
320 _ => ConfigSource::Default,
321 };
322
323 let mut builder = ClientConfiguration::builder();
324 builder
325 .profile(profile_name)
326 .credentials_name(credentials_name.clone())
327 .source(source.clone())
328 .api_url(api_url)
329 .quilc_url(quilc_url)
330 .qvm_url(qvm_url)
331 .grpc_api_url(grpc_api_url);
332
333 #[cfg(feature = "tracing-config")]
334 {
335 builder.tracing_configuration(tracing_configuration);
336 }
337
338 Ok(Self {
339 builder,
340 auth_server,
341 credential,
342 source,
343 credentials_name,
344 })
345 }
346}
347
348async fn persist_or_warn(
353 oauth_session: &OAuthSession,
354 source: &ConfigSource,
355 credentials_name: &str,
356) {
357 if let Err(_error) = persist_oauth_session(oauth_session, source, credentials_name).await {
358 #[cfg(feature = "tracing")]
359 tracing::warn!(
360 "Refreshed QCS credentials but failed to persist them to the secrets file: {_error}"
361 );
362 }
363}
364
365fn credential_to_oauth_session(
366 credential: Option<Credential>,
367 auth_server: AuthServer,
368) -> Option<OAuthSession> {
369 match credential? {
370 Credential::TokenPayload(token_payload) => {
371 let TokenPayload {
372 access_token,
373 refresh_token,
374 ..
375 } = token_payload;
376
377 Some(OAuthSession::new(
378 OAuthGrant::RefreshToken(RefreshToken::new(refresh_token.unwrap_or_default())),
379 auth_server,
380 access_token,
381 ))
382 }
383 Credential::ExternallyManaged(externally_managed) => Some(OAuthSession::new(
384 OAuthGrant::ExternallyManaged(externally_managed.into()),
385 auth_server,
386 None,
387 )),
388 Credential::ClientCredentials(client_credentials) => {
389 let grant = OAuthGrant::ClientCredentials(client_credentials);
390 Some(OAuthSession::new(grant, auth_server, None))
391 }
392 }
393}
394
395impl ClientConfiguration {
396 #[cfg(test)]
397 fn new(
398 settings: Settings,
399 secrets: Secrets,
400 profile_name: Option<String>,
401 ) -> Result<Self, LoadError> {
402 let ConfigurationContext {
403 mut builder,
404 auth_server,
405 credential,
406 ..
407 } = ConfigurationContext::from_sources(settings, secrets, profile_name)?;
408 let oauth_session = credential_to_oauth_session(credential, auth_server);
409 Ok(builder.oauth_session(oauth_session).build()?)
410 }
411
412 pub fn load_default() -> Result<Self, LoadError> {
418 let base_config = Self::load(None)?;
419 Ok(base_config)
420 }
421
422 pub fn load_profile(profile_name: String) -> Result<Self, LoadError> {
429 Self::load(Some(profile_name))
430 }
431
432 pub async fn load_with_login(
441 cancel_token: CancellationToken,
442 profile_name: Option<String>,
443 ) -> Result<Self, LoadError> {
444 Self::load_with_login_with_redirect(cancel_token, profile_name, RedirectBinding::default())
445 .await
446 }
447
448 pub(crate) async fn load_with_login_with_redirect(
450 cancel_token: CancellationToken,
451 profile_name: Option<String>,
452 redirect: RedirectBinding,
453 ) -> Result<Self, LoadError> {
454 let ConfigurationContext {
455 mut builder,
456 auth_server,
457 credential,
458 source,
459 credentials_name,
460 } = ConfigurationContext::from_profile(profile_name)?;
461
462 match credential {
463 credential @ Some(
465 Credential::ExternallyManaged(_) | Credential::ClientCredentials(_),
466 ) => {
467 let oauth_session = credential_to_oauth_session(credential, auth_server);
468 return Ok(builder.oauth_session(oauth_session).build()?);
469 }
470 Some(Credential::TokenPayload(TokenPayload {
471 access_token,
472 refresh_token,
473 ..
474 })) => {
475 if let Some(access_token) = access_token
476 && insecure_validate_token_exp(&access_token).is_ok()
477 {
478 let refresh_token = refresh_token.clone().unwrap_or_default();
479 let oauth_session = OAuthSession::new(
480 OAuthGrant::RefreshToken(RefreshToken::new(refresh_token)),
481 auth_server,
482 Some(access_token),
483 );
484 return Ok(builder.oauth_session(Some(oauth_session)).build()?);
485 }
486
487 if let Some(refresh_token) = refresh_token
489 && !refresh_token.is_empty()
490 {
491 let mut refresh_token = RefreshToken::new(refresh_token);
492
493 if let Ok(access_token) = refresh_token.request_access_token(&auth_server).await
495 {
496 let oauth_session = OAuthSession::new(
497 OAuthGrant::RefreshToken(refresh_token),
498 auth_server,
499 Some(access_token),
500 );
501
502 persist_or_warn(&oauth_session, &source, &credentials_name).await;
504
505 return Ok(builder.oauth_session(Some(oauth_session)).build()?);
506 }
507 }
508
509 #[cfg(feature = "tracing")]
510 tracing::debug!("Stored credentials are invalid, falling back to login flow");
511 }
512 None => {
513 #[cfg(feature = "tracing")]
514 tracing::debug!("No stored credentials found, falling back to login flow",);
515 }
516 }
517
518 let pkce_flow =
520 PkceFlow::new_login_flow_with_redirect(cancel_token, &auth_server, redirect).await?;
521 let access_token = pkce_flow.access_token.clone();
522 let oauth_session =
523 OAuthSession::from_pkce_flow(pkce_flow, auth_server, Some(access_token));
524
525 persist_or_warn(&oauth_session, &source, &credentials_name).await;
530
531 Ok(builder.oauth_session(Some(oauth_session)).build()?)
532 }
533
534 fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
542 let ConfigurationContext {
543 mut builder,
544 auth_server,
545 credential,
546 ..
547 } = ConfigurationContext::from_profile(profile_name)?;
548 let oauth_session = credential_to_oauth_session(credential, auth_server);
549 Ok(builder.oauth_session(oauth_session).build()?)
550 }
551
552 #[must_use]
554 pub fn builder() -> ClientConfigurationBuilder {
555 ClientConfigurationBuilder::default()
556 }
557
558 #[must_use]
560 pub fn profile(&self) -> &str {
561 &self.profile
562 }
563
564 #[must_use]
567 pub fn credentials_name(&self) -> &str {
568 &self.credentials_name
569 }
570
571 #[must_use]
573 pub fn api_url(&self) -> &str {
574 &self.api_url
575 }
576
577 #[must_use]
579 pub fn grpc_api_url(&self) -> &str {
580 &self.grpc_api_url
581 }
582
583 #[must_use]
585 pub fn quilc_url(&self) -> &str {
586 &self.quilc_url
587 }
588
589 #[must_use]
591 pub fn qvm_url(&self) -> &str {
592 &self.qvm_url
593 }
594
595 #[cfg(feature = "tracing-config")]
597 #[must_use]
598 pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
599 self.tracing_configuration.as_ref()
600 }
601
602 #[must_use]
604 pub const fn source(&self) -> &ConfigSource {
605 &self.source
606 }
607
608 pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
616 Ok(self
617 .oauth_session
618 .as_ref()
619 .ok_or(TokenError::NoRefreshToken)?
620 .tokens()
621 .await)
622 }
623
624 pub async fn get_bearer_access_token(&self) -> Result<SecretAccessToken, TokenError> {
630 let dispatcher = self
631 .oauth_session
632 .as_ref()
633 .ok_or_else(|| TokenError::NoCredentials)?;
634 match dispatcher.validate().await {
635 Ok(tokens) => Ok(tokens),
636 #[allow(unused_variables)]
637 Err(e) => {
638 #[cfg(feature = "tracing-config")]
639 tracing::debug!("Refreshing access token because current one is invalid: {e}");
640 dispatcher
641 .refresh(self.source(), self.credentials_name())
642 .await
643 .map(|e| e.access_token().cloned())?
644 }
645 }
646 }
647
648 pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
654 self.oauth_session
655 .as_ref()
656 .ok_or(TokenError::NoRefreshToken)?
657 .refresh(self.source(), self.credentials_name())
658 .await
659 }
660}
661
662#[derive(Clone, Debug)]
664pub enum ConfigSource {
665 Builder,
667 File {
669 settings_path: PathBuf,
671 secrets_path: PathBuf,
673 },
674 Default,
676}
677
678fn expand_path_from_env_or_default(
679 env_var_name: &str,
680 default: &str,
681) -> Result<PathBuf, LoadError> {
682 match env::var(env_var_name) {
683 Ok(path) => {
684 let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
685 let path_buf: PathBuf = expanded_path.as_ref().into();
686 if !path_buf.exists() {
687 return Err(LoadError::Path {
688 path: path_buf,
689 message: format!("The given path does not exist: {path}"),
690 });
691 }
692 Ok(path_buf)
693 }
694 Err(env::VarError::NotPresent) => {
695 let expanded_path = shellexpand::tilde_with_context(default, || {
696 env::home_dir().map(|path| path.display().to_string())
697 });
698 let path_buf: PathBuf = expanded_path.as_ref().into();
699 if !path_buf.exists() {
700 return Err(LoadError::Path {
701 path: path_buf,
702 message: format!(
703 "Could not find a QCS configuration at the default path: {default}"
704 ),
705 });
706 }
707 Ok(path_buf)
708 }
709 Err(other_error) => Err(LoadError::EnvVar {
710 variable_name: env_var_name.to_string(),
711 message: other_error.to_string(),
712 }),
713 }
714}
715
716#[cfg(test)]
717mod test {
718 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
719
720 use httpmock::prelude::*;
721 use jsonwebtoken::{EncodingKey, Header, encode};
722 use serde::Serialize;
723 use time::{Duration, OffsetDateTime};
724 use tokio_util::sync::CancellationToken;
725
726 use crate::configuration::{
727 API_URL_VAR, AuthServer, ClientConfiguration, DEFAULT_QUILC_URL, GRPC_API_URL_VAR,
728 OAuthGrant, OAuthSession, QUILC_URL_VAR, QVM_URL_VAR, RefreshToken,
729 expand_path_from_env_or_default, oidc,
730 pkce::{RedirectBinding, tests::PkceTestServerHarness},
731 secrets::{
732 Credential, SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR, SecretAccessToken,
733 SecretRefreshToken, Secrets, TokenPayload,
734 },
735 settings::{SETTINGS_PATH_VAR, Settings},
736 tokens::{ClientCredentialsResponse, RefreshTokenResponse, TokenRefresher},
737 };
738
739 use super::{settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials};
740
741 fn expect_token_payload(credential: Credential) -> TokenPayload {
743 match credential {
744 Credential::TokenPayload(payload) => payload,
745 Credential::ExternallyManaged(_) | Credential::ClientCredentials(_) => {
746 panic!("expected a token payload credential")
747 }
748 }
749 }
750
751 #[test]
752 fn expands_env_var() {
753 figment::Jail::expect_with(|jail| {
754 let dir = jail.create_dir("~/blah/blah/")?;
755 jail.create_file(dir.join("file.toml"), "")?;
756 jail.set_env("SOME_PATH", "blah/blah");
757 jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
758 let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
759 assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
760
761 Ok(())
762 });
763 }
764
765 #[test]
766 fn uses_env_var_overrides() {
767 figment::Jail::expect_with(|jail| {
768 let quilc_url = "tcp://quilc:5555";
769 let qvm_url = "http://qvm:5000";
770 let grpc_url = "http://grpc:80";
771 let api_url = "http://api:80";
772
773 jail.set_env(QUILC_URL_VAR, quilc_url);
774 jail.set_env(QVM_URL_VAR, qvm_url);
775 jail.set_env(API_URL_VAR, api_url);
776 jail.set_env(GRPC_API_URL_VAR, grpc_url);
777
778 let config = ClientConfiguration::new(
779 Settings::default(),
780 Secrets::default(),
781 Some("default".to_string()),
782 )
783 .expect("Should be able to build default config.");
784
785 assert_eq!(config.quilc_url, quilc_url);
786 assert_eq!(config.qvm_url, qvm_url);
787 assert_eq!(config.grpc_api_url, grpc_url);
788
789 Ok(())
790 });
791 }
792
793 #[tokio::test]
794 async fn test_default_uses_env_var_overrides() {
795 figment::Jail::expect_with(|jail| {
796 let quilc_url = "quilc_url";
797 let qvm_url = "qvm_url";
798 let grpc_url = "grpc_url";
799 let api_url = "api_url";
800
801 jail.set_env(QUILC_URL_VAR, quilc_url);
802 jail.set_env(QVM_URL_VAR, qvm_url);
803 jail.set_env(GRPC_API_URL_VAR, grpc_url);
804 jail.set_env(API_URL_VAR, api_url);
805
806 let config = ClientConfiguration::load_default().unwrap();
807 assert_eq!(config.quilc_url, quilc_url);
808 assert_eq!(config.qvm_url, qvm_url);
809 assert_eq!(config.grpc_api_url, grpc_url);
810 assert_eq!(config.api_url, api_url);
811
812 Ok(())
813 });
814 }
815
816 #[test]
817 fn test_default_loads_settings_with_partial_profile_applications() {
818 figment::Jail::expect_with(|jail| {
819 let directory = jail.directory();
820 let settings_file_name = "settings.toml";
821 let settings_file_path = directory.join(settings_file_name);
822
823 let quilc_url_env_var = "env-var://quilc.url/after";
824
825 let settings_file_contents = r#"
826default_profile_name = "default"
827
828[profiles]
829[profiles.default]
830api_url = ""
831auth_server_name = "default"
832credentials_name = "default"
833applications = {}
834
835[auth_servers]
836[auth_servers.default]
837client_id = ""
838issuer = ""
839"#;
840 jail.create_file(settings_file_name, settings_file_contents)
841 .expect("should create test settings.toml");
842
843 jail.set_env(
844 "QCS_SETTINGS_FILE_PATH",
845 settings_file_path
846 .to_str()
847 .expect("settings file path should be a string"),
848 );
849
850 let config = ClientConfiguration::load_default().unwrap();
852 assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
853
854 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
855
856 let config = ClientConfiguration::load_default().unwrap();
858 assert_eq!(config.quilc_url, quilc_url_env_var);
859
860 Ok(())
861 });
862 }
863
864 #[test]
865 fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
866 figment::Jail::expect_with(|jail| {
867 let directory = jail.directory();
868 let settings_file_name = "settings.toml";
869 let settings_file_path = directory.join(settings_file_name);
870
871 let quilc_url_settings_toml = "settings-toml://quilc.url";
872 let quilc_url_env_var = "env-var://quilc.url/after";
873
874 let settings_file_contents = format!(
875 r#"
876default_profile_name = "default"
877
878[profiles]
879[profiles.default]
880api_url = ""
881auth_server_name = "default"
882credentials_name = "default"
883applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
884
885[auth_servers]
886[auth_servers.default]
887client_id = ""
888issuer = ""
889"#
890 );
891
892 jail.create_file(settings_file_name, &settings_file_contents)
893 .expect("should create test settings.toml");
894
895 jail.set_env(
896 "QCS_SETTINGS_FILE_PATH",
897 settings_file_path
898 .to_str()
899 .expect("settings file path should be a string"),
900 );
901
902 let config = ClientConfiguration::load_default().unwrap();
904 assert_eq!(config.quilc_url, quilc_url_settings_toml);
905
906 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
907
908 let config = ClientConfiguration::load_default().unwrap();
910 assert_eq!(config.quilc_url, quilc_url_env_var);
911
912 Ok(())
913 });
914 }
915
916 #[tokio::test]
917 async fn test_hydrate_access_token_on_load() {
918 let mut config = ClientConfiguration::builder().build().unwrap();
919 let access_token = "test_access_token";
920 figment::Jail::expect_with(|jail| {
921 let directory = jail.directory();
922 let settings_file_name = "settings.toml";
923 let settings_file_path = directory.join(settings_file_name);
924 let secrets_file_name = "secrets.toml";
925 let secrets_file_path = directory.join(secrets_file_name);
926
927 let settings_file_contents = r#"
928default_profile_name = "default"
929
930[profiles]
931[profiles.default]
932api_url = ""
933auth_server_name = "default"
934credentials_name = "default"
935
936[auth_servers]
937[auth_servers.default]
938client_id = ""
939issuer = ""
940"#;
941
942 let secrets_file_contents = format!(
943 r#"
944[credentials]
945[credentials.default]
946[credentials.default.token_payload]
947access_token = "{access_token}"
948expires_in = 3600
949id_token = "id_token"
950refresh_token = "refresh_token"
951scope = "offline_access openid profile email"
952token_type = "Bearer"
953"#
954 );
955
956 jail.create_file(settings_file_name, settings_file_contents)
957 .expect("should create test settings.toml");
958 jail.create_file(secrets_file_name, &secrets_file_contents)
959 .expect("should create test settings.toml");
960
961 jail.set_env(
962 "QCS_SETTINGS_FILE_PATH",
963 settings_file_path
964 .to_str()
965 .expect("settings file path should be a string"),
966 );
967 jail.set_env(
968 "QCS_SECRETS_FILE_PATH",
969 secrets_file_path
970 .to_str()
971 .expect("secrets file path should be a string"),
972 );
973
974 config = ClientConfiguration::load_default().unwrap();
975 Ok(())
976 });
977 assert_eq!(
978 config.get_access_token().await.unwrap().unwrap(),
979 SecretAccessToken::from(access_token)
980 );
981 }
982
983 #[derive(Clone, Debug, Serialize)]
984 struct Claims {
985 exp: i64,
986 iss: String,
987 sub: String,
988 }
989
990 impl Default for Claims {
991 fn default() -> Self {
992 Self {
993 exp: 0,
994 iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
995 sub: "qcs@rigetti.com".to_string(),
996 }
997 }
998 }
999
1000 impl Claims {
1001 fn new_valid() -> Self {
1002 Self {
1003 exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
1004 ..Self::default()
1005 }
1006 }
1007
1008 fn new_expired() -> Self {
1009 Self {
1010 exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
1011 ..Self::default()
1012 }
1013 }
1014
1015 fn to_encoded(&self) -> String {
1016 encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
1017 }
1018
1019 fn to_access_token(&self) -> SecretAccessToken {
1020 SecretAccessToken::from(self.to_encoded())
1021 }
1022 }
1023
1024 #[test]
1025 fn test_valid_token() {
1026 let valid_token = Claims::new_valid().to_access_token();
1027 let tokens = OAuthSession::from_refresh_token(
1028 RefreshToken::new(SecretRefreshToken::from("unused")),
1029 AuthServer::default(),
1030 Some(valid_token.clone()),
1031 );
1032 assert_eq!(
1033 tokens
1034 .validate()
1035 .expect("Token should not fail validation."),
1036 valid_token
1037 );
1038 }
1039
1040 #[test]
1041 fn test_expired_token() {
1042 let invalid_token = Claims::new_expired().to_access_token();
1043 let tokens = OAuthSession::from_refresh_token(
1044 RefreshToken::new(SecretRefreshToken::from("unused")),
1045 AuthServer::default(),
1046 Some(invalid_token),
1047 );
1048 assert!(tokens.validate().is_err());
1049 }
1050
1051 #[test]
1052 fn test_client_credentials_without_access_token() {
1053 let tokens = OAuthSession::from_client_credentials(
1054 ClientCredentials::new("client_id", "client_secret"),
1055 AuthServer::default(),
1056 None,
1057 );
1058 assert!(tokens.validate().is_err());
1059 }
1060
1061 #[tokio::test]
1062 async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
1063 let access_token = Claims::new_valid().to_encoded();
1064 let mut config = ClientConfiguration::builder().build().unwrap();
1065 figment::Jail::expect_with(|jail| {
1066 let directory = jail.directory();
1067 let settings_file_name = "settings.toml";
1068 let settings_file_path = directory.join(settings_file_name);
1069 let secrets_file_name = "secrets.toml";
1070 let secrets_file_path = directory.join(secrets_file_name);
1071
1072 let settings_file_contents = r#"
1073default_profile_name = "default"
1074
1075[profiles]
1076[profiles.default]
1077api_url = ""
1078auth_server_name = "default"
1079credentials_name = "default"
1080
1081[auth_servers]
1082[auth_servers.default]
1083client_id = ""
1084issuer = ""
1085"#;
1086
1087 let secrets_file_contents = format!(
1089 r#"
1090[credentials]
1091[credentials.default]
1092[credentials.default.token_payload]
1093access_token = "{access_token}"
1094expires_in = 3600
1095id_token = "id_token"
1096scope = "offline_access openid profile email"
1097token_type = "Bearer"
1098"#
1099 );
1100
1101 jail.create_file(settings_file_name, settings_file_contents)
1102 .expect("should create test settings.toml");
1103 jail.create_file(secrets_file_name, &secrets_file_contents)
1104 .expect("should create test secrets.toml");
1105
1106 jail.set_env(
1107 "QCS_SETTINGS_FILE_PATH",
1108 settings_file_path
1109 .to_str()
1110 .expect("settings file path should be a string"),
1111 );
1112 jail.set_env(
1113 "QCS_SECRETS_FILE_PATH",
1114 secrets_file_path
1115 .to_str()
1116 .expect("secrets file path should be a string"),
1117 );
1118
1119 config = ClientConfiguration::load_default().unwrap();
1120 Ok(())
1121 });
1122
1123 assert_eq!(
1124 config.get_bearer_access_token().await.unwrap(),
1125 SecretAccessToken::from(access_token)
1126 );
1127 }
1128
1129 #[test]
1131 fn test_pkce_flow_persists_token() {
1132 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1135
1136 let PkceTestServerHarness {
1137 server,
1138 client,
1139 discovery: _,
1140 redirect_listener,
1141 } = runtime.block_on(PkceTestServerHarness::new());
1142
1143 let client_id = client.client_id;
1144 let issuer = server.issuer().to_string();
1145
1146 figment::Jail::expect_with(move |jail| {
1147 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1150
1151 let directory = jail.directory();
1152 let settings_file_name = "settings.toml";
1153 let settings_file_path = directory.join(settings_file_name);
1154
1155 let secrets_file_name = "secrets.toml";
1156 let secrets_file_path = directory.join(secrets_file_name);
1157
1158 let settings_file_contents = format!(
1159 r#"
1160default_profile_name = "default"
1161
1162[profiles]
1163[profiles.default]
1164api_url = ""
1165auth_server_name = "default"
1166credentials_name = "default"
1167
1168[auth_servers]
1169[auth_servers.default]
1170client_id = "{client_id}"
1171issuer = "{issuer}"
1172"#
1173 );
1174
1175 let secrets_file_contents = r#"
1176[credentials]
1177[credentials.default]
1178[credentials.default.token_payload]
1179access_token = ""
1180"#;
1181
1182 jail.create_file(settings_file_name, &settings_file_contents)
1183 .expect("should create test settings.toml");
1184
1185 jail.set_env(
1186 SETTINGS_PATH_VAR,
1187 settings_file_path
1188 .to_str()
1189 .expect("settings file path should be a string"),
1190 );
1191
1192 jail.create_file(secrets_file_name, secrets_file_contents)
1193 .expect("should create test secrets.toml");
1194
1195 jail.set_env(
1196 SECRETS_PATH_VAR,
1197 secrets_file_path
1198 .to_str()
1199 .expect("secrets file path should be a string"),
1200 );
1201
1202 runtime.block_on(async {
1204 let cancel_token = CancellationToken::new();
1205 let configuration = ClientConfiguration::load_with_login_with_redirect(
1207 cancel_token,
1208 None,
1209 RedirectBinding::Bound(redirect_listener),
1210 )
1211 .await
1212 .expect("should load configuration");
1213 let oauth_session = configuration.refresh().await.expect("should refresh");
1214 let token = oauth_session.validate().expect("token should be valid");
1215
1216 let configuration =
1218 ClientConfiguration::load_default().expect("should load configuration");
1219
1220 let oauth_session = configuration
1221 .oauth_session()
1222 .await
1223 .expect("should get oauth session");
1224
1225 let token_payload = expect_token_payload(
1226 Secrets::load_from_path(&secrets_file_path)
1227 .expect("should load secrets")
1228 .credentials
1229 .remove("default")
1230 .expect("should get default credentials"),
1231 );
1232
1233 assert_eq!(
1234 token,
1235 oauth_session.validate().expect("should contain token"),
1236 "session: {oauth_session:?}, token_payload: {token_payload:?}",
1237 );
1238 assert_eq!(
1239 token_payload.access_token,
1240 Some(token),
1241 "session: {oauth_session:?}, token_payload: {token_payload:?}"
1242 );
1243 assert_ne!(
1244 token_payload.refresh_token, None,
1245 "session: {oauth_session:?}, token_payload: {token_payload:?}"
1246 );
1247 });
1248
1249 Ok(())
1250 });
1251
1252 drop(server);
1253 }
1254
1255 #[test]
1262 fn test_load_with_login_persists_login_flow_token_without_explicit_refresh() {
1263 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1266
1267 let PkceTestServerHarness {
1268 server,
1269 client,
1270 discovery: _,
1271 redirect_listener,
1272 } = runtime.block_on(PkceTestServerHarness::new());
1273
1274 let client_id = client.client_id;
1275 let issuer = server.issuer().to_string();
1276
1277 figment::Jail::expect_with(move |jail| {
1278 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1281
1282 let directory = jail.directory();
1283 let settings_file_name = "settings.toml";
1284 let settings_file_path = directory.join(settings_file_name);
1285
1286 let secrets_file_name = "secrets.toml";
1287 let secrets_file_path = directory.join(secrets_file_name);
1288
1289 let settings_file_contents = format!(
1290 r#"
1291default_profile_name = "default"
1292
1293[profiles]
1294[profiles.default]
1295api_url = ""
1296auth_server_name = "default"
1297credentials_name = "default"
1298
1299[auth_servers]
1300[auth_servers.default]
1301client_id = "{client_id}"
1302issuer = "{issuer}"
1303"#
1304 );
1305
1306 let secrets_file_contents = r#"
1307[credentials]
1308[credentials.default]
1309[credentials.default.token_payload]
1310access_token = ""
1311"#;
1312
1313 jail.create_file(settings_file_name, &settings_file_contents)
1314 .expect("should create test settings.toml");
1315
1316 jail.set_env(
1317 SETTINGS_PATH_VAR,
1318 settings_file_path
1319 .to_str()
1320 .expect("settings file path should be a string"),
1321 );
1322
1323 jail.create_file(secrets_file_name, secrets_file_contents)
1324 .expect("should create test secrets.toml");
1325
1326 jail.set_env(
1327 SECRETS_PATH_VAR,
1328 secrets_file_path
1329 .to_str()
1330 .expect("secrets file path should be a string"),
1331 );
1332
1333 runtime.block_on(async {
1334 let cancel_token = CancellationToken::new();
1335
1336 let configuration = ClientConfiguration::load_with_login_with_redirect(
1339 cancel_token,
1340 None,
1341 RedirectBinding::Bound(redirect_listener),
1342 )
1343 .await
1344 .expect("should perform a login flow");
1345
1346 let oauth_session = configuration
1347 .oauth_session()
1348 .await
1349 .expect("should get oauth session");
1350 let token = oauth_session.validate().expect("token should be valid");
1351
1352 let token_payload = expect_token_payload(
1353 Secrets::load_from_path(&secrets_file_path)
1354 .expect("should load secrets")
1355 .credentials
1356 .remove("default")
1357 .expect("should get default credentials"),
1358 );
1359
1360 assert_eq!(
1361 token_payload.access_token,
1362 Some(token),
1363 "the access token from the login flow should be persisted without an \
1364 explicit follow-up refresh"
1365 );
1366 assert!(
1367 token_payload.refresh_token.is_some(),
1368 "the refresh token from the login flow should be persisted without an \
1369 explicit follow-up refresh"
1370 );
1371 });
1372
1373 Ok(())
1374 });
1375
1376 drop(server);
1377 }
1378
1379 #[test]
1387 fn test_load_with_login_persists_rotated_refresh_token_on_refresh() {
1388 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1389
1390 let mock_server = runtime.block_on(MockServer::start_async());
1391
1392 let new_access_token = Claims::new_valid().to_encoded();
1393 let rotated_refresh_token = "rotated_refresh_token".to_string();
1394
1395 let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1396 when.method(GET).path("/.well-known/openid-configuration");
1397 then.status(200)
1398 .json_body_obj(&oidc::Discovery::new_for_test(
1399 mock_server.base_url().parse().unwrap(),
1400 ));
1401 }));
1402
1403 let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1404 when.method(POST).path("/v1/token");
1405 then.status(200).json_body_obj(&RefreshTokenResponse {
1406 access_token: SecretAccessToken::from(new_access_token.clone()),
1407 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1408 });
1409 }));
1410
1411 let client_id = "client_id";
1412 let issuer = mock_server.base_url();
1413 let initial_refresh_token = "initial_refresh_token";
1414 let expired_access_token = Claims::new_expired().to_encoded();
1415
1416 figment::Jail::expect_with(|jail| {
1417 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1418
1419 let directory = jail.directory();
1420 let settings_file_name = "settings.toml";
1421 let settings_file_path = directory.join(settings_file_name);
1422
1423 let secrets_file_name = "secrets.toml";
1424 let secrets_file_path = directory.join(secrets_file_name);
1425
1426 let settings_file_contents = format!(
1427 r#"
1428default_profile_name = "default"
1429
1430[profiles]
1431[profiles.default]
1432api_url = ""
1433auth_server_name = "default"
1434credentials_name = "default"
1435
1436[auth_servers]
1437[auth_servers.default]
1438client_id = "{client_id}"
1439issuer = "{issuer}"
1440"#
1441 );
1442
1443 let secrets_file_contents = format!(
1444 r#"
1445[credentials]
1446[credentials.default]
1447[credentials.default.token_payload]
1448access_token = "{expired_access_token}"
1449refresh_token = "{initial_refresh_token}"
1450"#
1451 );
1452
1453 jail.create_file(settings_file_name, &settings_file_contents)
1454 .expect("should create test settings.toml");
1455 jail.set_env(
1456 SETTINGS_PATH_VAR,
1457 settings_file_path
1458 .to_str()
1459 .expect("settings file path should be a string"),
1460 );
1461
1462 jail.create_file(secrets_file_name, &secrets_file_contents)
1463 .expect("should create test secrets.toml");
1464 jail.set_env(
1465 SECRETS_PATH_VAR,
1466 secrets_file_path
1467 .to_str()
1468 .expect("secrets file path should be a string"),
1469 );
1470
1471 runtime.block_on(async {
1472 let cancel_token = CancellationToken::new();
1473
1474 let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1477 .await
1478 .expect("should refresh using the stored refresh token");
1479
1480 oidc_mock.assert_async().await;
1481 issuer_mock.assert_async().await;
1482
1483 let oauth_session = configuration
1484 .oauth_session()
1485 .await
1486 .expect("should get oauth session");
1487
1488 assert_eq!(
1489 oauth_session.access_token().cloned().ok(),
1490 Some(SecretAccessToken::from(new_access_token.clone())),
1491 "in-memory access token should be the freshly refreshed one"
1492 );
1493
1494 match oauth_session.payload() {
1495 OAuthGrant::RefreshToken(payload) => {
1496 assert_eq!(
1497 payload.refresh_token,
1498 SecretRefreshToken::from(rotated_refresh_token.clone()),
1499 "in-memory refresh token should be updated to the rotated value"
1500 );
1501 }
1502 other => panic!("expected a RefreshToken grant, got {other:?}"),
1503 }
1504
1505 let token_payload = expect_token_payload(
1506 Secrets::load_from_path(&secrets_file_path)
1507 .expect("should load secrets")
1508 .credentials
1509 .remove("default")
1510 .expect("should get default credentials"),
1511 );
1512
1513 assert_eq!(
1514 token_payload.access_token,
1515 Some(SecretAccessToken::from(new_access_token.clone())),
1516 "new access token should be persisted to the secrets file"
1517 );
1518 assert_eq!(
1519 token_payload.refresh_token,
1520 Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1521 "rotated refresh token should be persisted to the secrets file, otherwise \
1522 the next process to load this profile will retry the stale, \
1523 already-consumed refresh token and be forced back into a login flow"
1524 );
1525 });
1526
1527 Ok(())
1528 });
1529 }
1530
1531 #[test]
1543 fn test_refresh_persists_to_credentials_name_not_profile_name() {
1544 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1545
1546 let mock_server = runtime.block_on(MockServer::start_async());
1547
1548 let new_access_token = Claims::new_valid().to_encoded();
1549 let rotated_refresh_token = "rotated_refresh_token".to_string();
1550
1551 let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1552 when.method(GET).path("/.well-known/openid-configuration");
1553 then.status(200)
1554 .json_body_obj(&oidc::Discovery::new_for_test(
1555 mock_server.base_url().parse().unwrap(),
1556 ));
1557 }));
1558
1559 let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1560 when.method(POST).path("/v1/token");
1561 then.status(200).json_body_obj(&RefreshTokenResponse {
1562 access_token: SecretAccessToken::from(new_access_token.clone()),
1563 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1564 });
1565 }));
1566
1567 let client_id = "client_id";
1568 let issuer = mock_server.base_url();
1569 let initial_refresh_token = "initial_refresh_token";
1570 let expired_access_token = Claims::new_expired().to_encoded();
1571
1572 let profile_name = "funnel";
1574 let credentials_name = "shared";
1575
1576 figment::Jail::expect_with(|jail| {
1577 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1578
1579 let directory = jail.directory();
1580 let settings_file_name = "settings.toml";
1581 let settings_file_path = directory.join(settings_file_name);
1582
1583 let secrets_file_name = "secrets.toml";
1584 let secrets_file_path = directory.join(secrets_file_name);
1585
1586 let settings_file_contents = format!(
1587 r#"
1588default_profile_name = "{profile_name}"
1589
1590[profiles]
1591[profiles.{profile_name}]
1592api_url = ""
1593auth_server_name = "default"
1594credentials_name = "{credentials_name}"
1595
1596[auth_servers]
1597[auth_servers.default]
1598client_id = "{client_id}"
1599issuer = "{issuer}"
1600"#
1601 );
1602
1603 let secrets_file_contents = format!(
1607 r#"
1608[credentials]
1609[credentials.{credentials_name}]
1610[credentials.{credentials_name}.token_payload]
1611access_token = "{expired_access_token}"
1612refresh_token = "{initial_refresh_token}"
1613
1614[credentials.{profile_name}]
1615[credentials.{profile_name}.token_payload]
1616access_token = "decoy_access_token"
1617refresh_token = "decoy_refresh_token"
1618"#
1619 );
1620
1621 jail.create_file(settings_file_name, &settings_file_contents)
1622 .expect("should create test settings.toml");
1623 jail.set_env(
1624 SETTINGS_PATH_VAR,
1625 settings_file_path
1626 .to_str()
1627 .expect("settings file path should be a string"),
1628 );
1629
1630 jail.create_file(secrets_file_name, &secrets_file_contents)
1631 .expect("should create test secrets.toml");
1632 jail.set_env(
1633 SECRETS_PATH_VAR,
1634 secrets_file_path
1635 .to_str()
1636 .expect("secrets file path should be a string"),
1637 );
1638
1639 runtime.block_on(async {
1640 let configuration = ClientConfiguration::load_profile(profile_name.to_string())
1641 .expect("should load the profile");
1642
1643 assert_eq!(configuration.profile(), profile_name);
1644 assert_eq!(configuration.credentials_name(), credentials_name);
1645
1646 let access_token = configuration
1648 .get_bearer_access_token()
1649 .await
1650 .expect("should refresh the expired access token");
1651
1652 oidc_mock.assert_async().await;
1653 issuer_mock.assert_async().await;
1654
1655 assert_eq!(
1656 access_token,
1657 SecretAccessToken::from(new_access_token.clone())
1658 );
1659
1660 let mut credentials = Secrets::load_from_path(&secrets_file_path)
1661 .expect("should load secrets")
1662 .credentials;
1663
1664 let token_payload = expect_token_payload(
1665 credentials
1666 .remove(credentials_name)
1667 .expect("should get the credential the profile points at"),
1668 );
1669
1670 assert_eq!(
1671 token_payload.access_token,
1672 Some(SecretAccessToken::from(new_access_token.clone())),
1673 "the refreshed access token should be persisted under `credentials_name`, \
1674 which is where the next load reads it from"
1675 );
1676 assert_eq!(
1677 token_payload.refresh_token,
1678 Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1679 "the rotated refresh token should be persisted under `credentials_name`"
1680 );
1681
1682 let decoy_payload = expect_token_payload(
1683 credentials
1684 .remove(profile_name)
1685 .expect("decoy credential should still exist"),
1686 );
1687
1688 assert_eq!(
1689 decoy_payload.access_token,
1690 Some(SecretAccessToken::from("decoy_access_token".to_string())),
1691 "the credential named after the profile is not the one in use and \
1692 should be left untouched"
1693 );
1694 });
1695
1696 Ok(())
1697 });
1698 }
1699
1700 fn externally_managed_config_files(access_token: &str) -> (&'static str, String) {
1703 let settings = r#"
1704default_profile_name = "default"
1705
1706[profiles]
1707[profiles.default]
1708api_url = ""
1709auth_server_name = "default"
1710credentials_name = "default"
1711
1712[auth_servers]
1713[auth_servers.default]
1714client_id = ""
1715issuer = ""
1716"#;
1717
1718 let (program, flag) = super::external_command::shell();
1719 let args = format!(r#"["{flag}", "echo {access_token}"]"#);
1720
1721 let secrets = format!(
1722 r"
1723[credentials]
1724[credentials.default]
1725[credentials.default.externally_managed]
1726command = {program:?}
1727args = {args}
1728"
1729 );
1730
1731 (settings, secrets)
1732 }
1733
1734 fn jail_config_files(
1736 jail: &mut figment::Jail,
1737 settings: &str,
1738 secrets: &str,
1739 ) -> std::path::PathBuf {
1740 let directory = jail.directory().to_path_buf();
1741 jail.create_file("settings.toml", settings)
1742 .expect("should create test settings.toml");
1743 jail.create_file("secrets.toml", secrets)
1744 .expect("should create test secrets.toml");
1745
1746 jail.set_env(
1747 SETTINGS_PATH_VAR,
1748 directory
1749 .join("settings.toml")
1750 .to_str()
1751 .expect("settings path should be a string"),
1752 );
1753 let secrets_path = directory.join("secrets.toml");
1754 jail.set_env(
1755 SECRETS_PATH_VAR,
1756 secrets_path
1757 .to_str()
1758 .expect("secrets path should be a string"),
1759 );
1760
1761 secrets_path
1762 }
1763
1764 #[test]
1767 fn test_externally_managed_credential_runs_the_configured_program() {
1768 let access_token = Claims::new_valid().to_encoded();
1769 let (settings, secrets) = externally_managed_config_files(&access_token);
1770
1771 figment::Jail::expect_with(|jail| {
1772 jail_config_files(jail, settings, &secrets);
1773
1774 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1775 runtime.block_on(async {
1776 let config = ClientConfiguration::load_default().expect("should load config");
1777
1778 assert_eq!(
1779 config
1780 .get_bearer_access_token()
1781 .await
1782 .expect("should get an access token from the external program"),
1783 SecretAccessToken::from(access_token.clone())
1784 );
1785 });
1786
1787 Ok(())
1788 });
1789 }
1790
1791 #[test]
1794 fn test_externally_managed_credential_is_never_persisted() {
1795 let access_token = Claims::new_valid().to_encoded();
1796 let (settings, secrets) = externally_managed_config_files(&access_token);
1797
1798 figment::Jail::expect_with(|jail| {
1799 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1800 let secrets_path = jail_config_files(jail, settings, &secrets);
1801 let before =
1802 std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
1803
1804 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1805 runtime.block_on(async {
1806 let config = ClientConfiguration::load_default().expect("should load config");
1807 config
1808 .refresh()
1809 .await
1810 .expect("should refresh via the external program");
1811 });
1812
1813 let after =
1814 std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
1815 assert_eq!(
1816 before, after,
1817 "an externally managed credential's tokens should never be written to disk"
1818 );
1819
1820 Ok(())
1821 });
1822 }
1823
1824 #[test]
1828 fn test_externally_managed_credential_skips_the_login_flow() {
1829 let access_token = Claims::new_valid().to_encoded();
1830 let (settings, secrets) = externally_managed_config_files(&access_token);
1831
1832 figment::Jail::expect_with(|jail| {
1833 jail_config_files(jail, settings, &secrets);
1834
1835 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1836 runtime.block_on(async {
1837 let config = ClientConfiguration::load_with_login(CancellationToken::new(), None)
1838 .await
1839 .expect("should load without an interactive login");
1840
1841 assert_eq!(
1842 config
1843 .get_bearer_access_token()
1844 .await
1845 .expect("should get an access token from the external program"),
1846 SecretAccessToken::from(access_token.clone())
1847 );
1848 });
1849
1850 Ok(())
1851 });
1852 }
1853
1854 #[cfg(unix)]
1857 #[test]
1858 fn test_externally_managed_credential_reuses_a_valid_token() {
1859 let access_token = Claims::new_valid().to_encoded();
1860 let (settings, _) = externally_managed_config_files(&access_token);
1861
1862 figment::Jail::expect_with(|jail| {
1863 let counter = jail.directory().join("invocations");
1864 let secrets = format!(
1866 r#"
1867[credentials]
1868[credentials.default]
1869[credentials.default.externally_managed]
1870command = "/bin/sh"
1871args = ["-c", "echo run >> {counter} && printf '%s' '{access_token}'"]
1872"#,
1873 counter = counter.display(),
1874 );
1875 jail_config_files(jail, settings, &secrets);
1876
1877 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1878 runtime.block_on(async {
1879 let config = ClientConfiguration::load_default().expect("should load config");
1880 for _ in 0..5_u8 {
1881 config
1882 .get_bearer_access_token()
1883 .await
1884 .expect("should get an access token");
1885 }
1886 });
1887
1888 let invocations = std::fs::read_to_string(&counter)
1889 .expect("the program should have run at least once")
1890 .lines()
1891 .count();
1892 assert_eq!(
1893 invocations, 1,
1894 "a valid token should be reused across requests, not re-fetched"
1895 );
1896
1897 Ok(())
1898 });
1899 }
1900
1901 #[cfg(unix)]
1907 #[test]
1908 fn test_externally_managed_credential_does_not_loop_on_invalid_tokens() {
1909 let (settings, _) = externally_managed_config_files("unused");
1910 let expired = Claims::new_expired().to_encoded();
1911
1912 for (label, token) in [("expired", expired.as_str()), ("opaque", "not-a-jwt")] {
1914 figment::Jail::expect_with(|jail| {
1915 let counter = jail.directory().join("invocations");
1916 let secrets = format!(
1918 r#"
1919[credentials]
1920[credentials.default]
1921[credentials.default.externally_managed]
1922command = "/bin/sh"
1923args = ["-c", "echo run >> {counter} && printf '%s' '{token}'"]
1924"#,
1925 counter = counter.display(),
1926 );
1927 jail_config_files(jail, settings, &secrets);
1928
1929 let requests = 3_usize;
1930 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1931 runtime.block_on(async {
1932 let config = ClientConfiguration::load_default().expect("should load config");
1933
1934 tokio::time::timeout(std::time::Duration::from_secs(30), async {
1936 for _ in 0..requests {
1937 assert_eq!(
1938 config
1939 .get_bearer_access_token()
1940 .await
1941 .expect(
1942 "an unvalidatable token is still returned to the caller"
1943 )
1944 .secret(),
1945 token,
1946 "the {label} token from the program should be returned as-is"
1947 );
1948 }
1949 })
1950 .await
1951 .expect("requesting a token should not loop on the external program");
1952 });
1953
1954 let invocations = std::fs::read_to_string(&counter)
1955 .expect("the program should have run at least once")
1956 .lines()
1957 .count();
1958 assert_eq!(
1959 invocations, requests,
1960 "a program returning {label} tokens should run once per request, not \
1961 repeatedly until it produces a valid one"
1962 );
1963
1964 Ok(())
1965 });
1966 }
1967 }
1968
1969 #[test]
1972 fn test_externally_managed_credential_takes_precedence_over_stored_tokens() {
1973 let access_token = Claims::new_valid().to_encoded();
1974 let (settings, secrets) = externally_managed_config_files(&access_token);
1975 let secrets = format!(
1976 "{secrets}\n[credentials.default.token_payload]\naccess_token = \"{}\"\n",
1977 Claims::new_valid().to_encoded()
1978 );
1979
1980 figment::Jail::expect_with(|jail| {
1981 jail_config_files(jail, settings, &secrets);
1982
1983 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1984 runtime.block_on(async {
1985 let config = ClientConfiguration::load_default().expect("should load config");
1986
1987 assert_eq!(
1988 config
1989 .get_bearer_access_token()
1990 .await
1991 .expect("should get an access token from the external program"),
1992 SecretAccessToken::from(access_token.clone()),
1993 "the external program should win over the stored access token"
1994 );
1995 });
1996
1997 Ok(())
1998 });
1999 }
2000
2001 const TEST_CLIENT_ID: &str = "a-client-id";
2003 const TEST_CLIENT_SECRET: &str = "a-client-secret";
2004
2005 fn expected_basic_auth(client_id: &str, client_secret: &str) -> String {
2006 use base64::Engine as _;
2007
2008 let encoded = base64::engine::general_purpose::STANDARD
2009 .encode(format!("{client_id}:{client_secret}"));
2010 format!("Basic {encoded}")
2011 }
2012
2013 fn client_credentials_fixture_files(issuer: &str) -> (String, String) {
2015 let settings = format!(
2016 r#"
2017default_profile_name = "default"
2018
2019[profiles]
2020[profiles.default]
2021api_url = ""
2022auth_server_name = "default"
2023credentials_name = "default"
2024
2025[auth_servers]
2026[auth_servers.default]
2027client_id = "ignored"
2028issuer = "{issuer}"
2029"#
2030 );
2031
2032 let secrets = format!(
2033 r#"
2034[credentials]
2035[credentials.default.client_credentials]
2036client_id = "{TEST_CLIENT_ID}"
2037client_secret = "{TEST_CLIENT_SECRET}"
2038"#
2039 );
2040
2041 (settings, secrets)
2042 }
2043
2044 fn mock_client_credentials_issuer<'server>(
2045 runtime: &tokio::runtime::Runtime,
2046 mock_server: &'server MockServer,
2047 access_token: &str,
2048 ) -> (httpmock::Mock<'server>, httpmock::Mock<'server>) {
2049 let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
2050 when.method(GET).path("/.well-known/openid-configuration");
2051 then.status(200)
2052 .json_body_obj(&oidc::Discovery::new_for_test(
2053 mock_server.base_url().parse().unwrap(),
2054 ));
2055 }));
2056
2057 let authorization = expected_basic_auth(TEST_CLIENT_ID, TEST_CLIENT_SECRET);
2058 let access_token = access_token.to_string();
2059 let token_mock = runtime.block_on(mock_server.mock_async(|when, then| {
2060 when.method(POST)
2061 .path("/v1/token")
2062 .header("authorization", authorization)
2063 .body_includes("grant_type=client_credentials");
2064 then.status(200).json_body_obj(&ClientCredentialsResponse {
2065 access_token: SecretAccessToken::from(access_token),
2066 });
2067 }));
2068
2069 (oidc_mock, token_mock)
2070 }
2071
2072 #[test]
2074 fn test_client_credentials_credential_authenticates_as_its_own_client_id() {
2075 let access_token = Claims::new_valid().to_encoded();
2076
2077 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2078 let mock_server = runtime.block_on(MockServer::start_async());
2079 let (oidc_mock, token_mock) =
2080 mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2081
2082 let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2083
2084 figment::Jail::expect_with(|jail| {
2085 jail_config_files(jail, &settings, &secrets);
2086
2087 runtime.block_on(async {
2088 let config = ClientConfiguration::load_default().expect("should load config");
2089
2090 assert_eq!(
2091 config
2092 .get_bearer_access_token()
2093 .await
2094 .expect("should exchange the client secret for an access token"),
2095 SecretAccessToken::from(access_token.clone()),
2096 );
2097 });
2098
2099 oidc_mock.assert();
2100 token_mock.assert();
2101
2102 Ok(())
2103 });
2104 }
2105
2106 #[test]
2107 fn test_client_credentials_credential_is_never_persisted() {
2108 let access_token = Claims::new_valid().to_encoded();
2109
2110 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2111 let mock_server = runtime.block_on(MockServer::start_async());
2112 let (_oidc_mock, _token_mock) =
2113 mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2114
2115 let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2116
2117 figment::Jail::expect_with(|jail| {
2118 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
2119 let secrets_path = jail_config_files(jail, &settings, &secrets);
2120 let before =
2121 std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
2122
2123 runtime.block_on(async {
2124 let config = ClientConfiguration::load_default().expect("should load config");
2125 config
2126 .refresh()
2127 .await
2128 .expect("should exchange the client secret for an access token");
2129 });
2130
2131 let after =
2132 std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
2133 assert_eq!(
2134 before, after,
2135 "a client credentials grant has nothing to persist: no refresh token, and an \
2136 access token the secret can mint again at any time"
2137 );
2138
2139 Ok(())
2140 });
2141 }
2142
2143 #[test]
2144 fn test_client_credentials_credential_skips_the_login_flow() {
2145 let access_token = Claims::new_valid().to_encoded();
2146
2147 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2148 let mock_server = runtime.block_on(MockServer::start_async());
2149 let (_oidc_mock, token_mock) =
2150 mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2151
2152 let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2153
2154 figment::Jail::expect_with(|jail| {
2155 jail_config_files(jail, &settings, &secrets);
2156
2157 runtime.block_on(async {
2158 let config = ClientConfiguration::load_with_login(CancellationToken::new(), None)
2159 .await
2160 .expect("should load without an interactive login");
2161
2162 assert_eq!(
2163 config
2164 .get_bearer_access_token()
2165 .await
2166 .expect("should exchange the client secret for an access token"),
2167 SecretAccessToken::from(access_token.clone()),
2168 );
2169 });
2170
2171 token_mock.assert();
2172
2173 Ok(())
2174 });
2175 }
2176}