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