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 AuthTokens, OAuthGrant, OAuthSession, 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 login_tokens =
520 AuthTokens::interactive_login_with_redirect(cancel_token, &auth_server, redirect)
521 .await?;
522 let access_token = login_tokens.access_token.clone();
523 let oauth_session =
524 OAuthSession::from_interactive_login(login_tokens, auth_server, Some(access_token));
525
526 persist_or_warn(&oauth_session, &source, &credentials_name).await;
531
532 Ok(builder.oauth_session(Some(oauth_session)).build()?)
533 }
534
535 fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
543 let ConfigurationContext {
544 mut builder,
545 auth_server,
546 credential,
547 ..
548 } = ConfigurationContext::from_profile(profile_name)?;
549 let oauth_session = credential_to_oauth_session(credential, auth_server);
550 Ok(builder.oauth_session(oauth_session).build()?)
551 }
552
553 #[must_use]
555 pub fn builder() -> ClientConfigurationBuilder {
556 ClientConfigurationBuilder::default()
557 }
558
559 #[must_use]
561 pub fn profile(&self) -> &str {
562 &self.profile
563 }
564
565 #[must_use]
568 pub fn credentials_name(&self) -> &str {
569 &self.credentials_name
570 }
571
572 #[must_use]
574 pub fn api_url(&self) -> &str {
575 &self.api_url
576 }
577
578 #[must_use]
580 pub fn grpc_api_url(&self) -> &str {
581 &self.grpc_api_url
582 }
583
584 #[must_use]
586 pub fn quilc_url(&self) -> &str {
587 &self.quilc_url
588 }
589
590 #[must_use]
592 pub fn qvm_url(&self) -> &str {
593 &self.qvm_url
594 }
595
596 #[cfg(feature = "tracing-config")]
598 #[must_use]
599 pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
600 self.tracing_configuration.as_ref()
601 }
602
603 #[must_use]
605 pub const fn source(&self) -> &ConfigSource {
606 &self.source
607 }
608
609 pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
617 Ok(self
618 .oauth_session
619 .as_ref()
620 .ok_or(TokenError::NoRefreshToken)?
621 .tokens()
622 .await)
623 }
624
625 pub async fn get_bearer_access_token(&self) -> Result<SecretAccessToken, TokenError> {
631 let dispatcher = self
632 .oauth_session
633 .as_ref()
634 .ok_or_else(|| TokenError::NoCredentials)?;
635 match dispatcher.validate().await {
636 Ok(tokens) => Ok(tokens),
637 #[allow(unused_variables)]
638 Err(e) => {
639 #[cfg(feature = "tracing-config")]
640 tracing::debug!("Refreshing access token because current one is invalid: {e}");
641 dispatcher
642 .refresh(self.source(), self.credentials_name())
643 .await
644 .map(|e| e.access_token().cloned())?
645 }
646 }
647 }
648
649 pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
655 self.oauth_session
656 .as_ref()
657 .ok_or(TokenError::NoRefreshToken)?
658 .refresh(self.source(), self.credentials_name())
659 .await
660 }
661}
662
663#[derive(Clone, Debug)]
665pub enum ConfigSource {
666 Builder,
668 File {
670 settings_path: PathBuf,
672 secrets_path: PathBuf,
674 },
675 Default,
677}
678
679fn expand_path_from_env_or_default(
680 env_var_name: &str,
681 default: &str,
682) -> Result<PathBuf, LoadError> {
683 match env::var(env_var_name) {
684 Ok(path) => {
685 let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
686 let path_buf: PathBuf = expanded_path.as_ref().into();
687 if !path_buf.exists() {
688 return Err(LoadError::Path {
689 path: path_buf,
690 message: format!("The given path does not exist: {path}"),
691 });
692 }
693 Ok(path_buf)
694 }
695 Err(env::VarError::NotPresent) => {
696 let expanded_path = shellexpand::tilde_with_context(default, || {
697 env::home_dir().map(|path| path.display().to_string())
698 });
699 let path_buf: PathBuf = expanded_path.as_ref().into();
700 if !path_buf.exists() {
701 return Err(LoadError::Path {
702 path: path_buf,
703 message: format!(
704 "Could not find a QCS configuration at the default path: {default}"
705 ),
706 });
707 }
708 Ok(path_buf)
709 }
710 Err(other_error) => Err(LoadError::EnvVar {
711 variable_name: env_var_name.to_string(),
712 message: other_error.to_string(),
713 }),
714 }
715}
716
717#[cfg(test)]
718mod test {
719 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
720
721 use httpmock::prelude::*;
722 use jsonwebtoken::{EncodingKey, Header, encode};
723 use serde::Serialize;
724 use time::{Duration, OffsetDateTime};
725 use tokio_util::sync::CancellationToken;
726
727 use crate::configuration::{
728 API_URL_VAR, AuthServer, ClientConfiguration, DEFAULT_QUILC_URL, GRPC_API_URL_VAR,
729 OAuthGrant, OAuthSession, QUILC_URL_VAR, QVM_URL_VAR, RefreshToken,
730 expand_path_from_env_or_default, oidc,
731 pkce::{RedirectBinding, tests::PkceTestServerHarness},
732 secrets::{
733 Credential, SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR, SecretAccessToken,
734 SecretRefreshToken, Secrets, TokenPayload,
735 },
736 settings::{SETTINGS_PATH_VAR, Settings},
737 tokens::{ClientCredentialsResponse, RefreshTokenResponse, TokenRefresher},
738 };
739
740 use super::{settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials};
741
742 fn expect_token_payload(credential: Credential) -> TokenPayload {
744 match credential {
745 Credential::TokenPayload(payload) => payload,
746 Credential::ExternallyManaged(_) | Credential::ClientCredentials(_) => {
747 panic!("expected a token payload credential")
748 }
749 }
750 }
751
752 #[test]
753 fn expands_env_var() {
754 figment::Jail::expect_with(|jail| {
755 let dir = jail.create_dir("~/blah/blah/")?;
756 jail.create_file(dir.join("file.toml"), "")?;
757 jail.set_env("SOME_PATH", "blah/blah");
758 jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
759 let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
760 assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
761
762 Ok(())
763 });
764 }
765
766 #[test]
767 fn uses_env_var_overrides() {
768 figment::Jail::expect_with(|jail| {
769 let quilc_url = "tcp://quilc:5555";
770 let qvm_url = "http://qvm:5000";
771 let grpc_url = "http://grpc:80";
772 let api_url = "http://api:80";
773
774 jail.set_env(QUILC_URL_VAR, quilc_url);
775 jail.set_env(QVM_URL_VAR, qvm_url);
776 jail.set_env(API_URL_VAR, api_url);
777 jail.set_env(GRPC_API_URL_VAR, grpc_url);
778
779 let config = ClientConfiguration::new(
780 Settings::default(),
781 Secrets::default(),
782 Some("default".to_string()),
783 )
784 .expect("Should be able to build default config.");
785
786 assert_eq!(config.quilc_url, quilc_url);
787 assert_eq!(config.qvm_url, qvm_url);
788 assert_eq!(config.grpc_api_url, grpc_url);
789
790 Ok(())
791 });
792 }
793
794 #[tokio::test]
795 async fn test_default_uses_env_var_overrides() {
796 figment::Jail::expect_with(|jail| {
797 let quilc_url = "quilc_url";
798 let qvm_url = "qvm_url";
799 let grpc_url = "grpc_url";
800 let api_url = "api_url";
801
802 jail.set_env(QUILC_URL_VAR, quilc_url);
803 jail.set_env(QVM_URL_VAR, qvm_url);
804 jail.set_env(GRPC_API_URL_VAR, grpc_url);
805 jail.set_env(API_URL_VAR, api_url);
806
807 let config = ClientConfiguration::load_default().unwrap();
808 assert_eq!(config.quilc_url, quilc_url);
809 assert_eq!(config.qvm_url, qvm_url);
810 assert_eq!(config.grpc_api_url, grpc_url);
811 assert_eq!(config.api_url, api_url);
812
813 Ok(())
814 });
815 }
816
817 #[test]
818 fn test_default_loads_settings_with_partial_profile_applications() {
819 figment::Jail::expect_with(|jail| {
820 let directory = jail.directory();
821 let settings_file_name = "settings.toml";
822 let settings_file_path = directory.join(settings_file_name);
823
824 let quilc_url_env_var = "env-var://quilc.url/after";
825
826 let settings_file_contents = r#"
827default_profile_name = "default"
828
829[profiles]
830[profiles.default]
831api_url = ""
832auth_server_name = "default"
833credentials_name = "default"
834applications = {}
835
836[auth_servers]
837[auth_servers.default]
838client_id = ""
839issuer = ""
840"#;
841 jail.create_file(settings_file_name, settings_file_contents)
842 .expect("should create test settings.toml");
843
844 jail.set_env(
845 "QCS_SETTINGS_FILE_PATH",
846 settings_file_path
847 .to_str()
848 .expect("settings file path should be a string"),
849 );
850
851 let config = ClientConfiguration::load_default().unwrap();
853 assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
854
855 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
856
857 let config = ClientConfiguration::load_default().unwrap();
859 assert_eq!(config.quilc_url, quilc_url_env_var);
860
861 Ok(())
862 });
863 }
864
865 #[test]
866 fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
867 figment::Jail::expect_with(|jail| {
868 let directory = jail.directory();
869 let settings_file_name = "settings.toml";
870 let settings_file_path = directory.join(settings_file_name);
871
872 let quilc_url_settings_toml = "settings-toml://quilc.url";
873 let quilc_url_env_var = "env-var://quilc.url/after";
874
875 let settings_file_contents = format!(
876 r#"
877default_profile_name = "default"
878
879[profiles]
880[profiles.default]
881api_url = ""
882auth_server_name = "default"
883credentials_name = "default"
884applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
885
886[auth_servers]
887[auth_servers.default]
888client_id = ""
889issuer = ""
890"#
891 );
892
893 jail.create_file(settings_file_name, &settings_file_contents)
894 .expect("should create test settings.toml");
895
896 jail.set_env(
897 "QCS_SETTINGS_FILE_PATH",
898 settings_file_path
899 .to_str()
900 .expect("settings file path should be a string"),
901 );
902
903 let config = ClientConfiguration::load_default().unwrap();
905 assert_eq!(config.quilc_url, quilc_url_settings_toml);
906
907 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
908
909 let config = ClientConfiguration::load_default().unwrap();
911 assert_eq!(config.quilc_url, quilc_url_env_var);
912
913 Ok(())
914 });
915 }
916
917 #[tokio::test]
918 async fn test_hydrate_access_token_on_load() {
919 let mut config = ClientConfiguration::builder().build().unwrap();
920 let access_token = "test_access_token";
921 figment::Jail::expect_with(|jail| {
922 let directory = jail.directory();
923 let settings_file_name = "settings.toml";
924 let settings_file_path = directory.join(settings_file_name);
925 let secrets_file_name = "secrets.toml";
926 let secrets_file_path = directory.join(secrets_file_name);
927
928 let settings_file_contents = r#"
929default_profile_name = "default"
930
931[profiles]
932[profiles.default]
933api_url = ""
934auth_server_name = "default"
935credentials_name = "default"
936
937[auth_servers]
938[auth_servers.default]
939client_id = ""
940issuer = ""
941"#;
942
943 let secrets_file_contents = format!(
944 r#"
945[credentials]
946[credentials.default]
947[credentials.default.token_payload]
948access_token = "{access_token}"
949expires_in = 3600
950id_token = "id_token"
951refresh_token = "refresh_token"
952scope = "offline_access openid profile email"
953token_type = "Bearer"
954"#
955 );
956
957 jail.create_file(settings_file_name, settings_file_contents)
958 .expect("should create test settings.toml");
959 jail.create_file(secrets_file_name, &secrets_file_contents)
960 .expect("should create test settings.toml");
961
962 jail.set_env(
963 "QCS_SETTINGS_FILE_PATH",
964 settings_file_path
965 .to_str()
966 .expect("settings file path should be a string"),
967 );
968 jail.set_env(
969 "QCS_SECRETS_FILE_PATH",
970 secrets_file_path
971 .to_str()
972 .expect("secrets file path should be a string"),
973 );
974
975 config = ClientConfiguration::load_default().unwrap();
976 Ok(())
977 });
978 assert_eq!(
979 config.get_access_token().await.unwrap().unwrap(),
980 SecretAccessToken::from(access_token)
981 );
982 }
983
984 #[derive(Clone, Debug, Serialize)]
985 struct Claims {
986 exp: i64,
987 iss: String,
988 sub: String,
989 }
990
991 impl Default for Claims {
992 fn default() -> Self {
993 Self {
994 exp: 0,
995 iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
996 sub: "qcs@rigetti.com".to_string(),
997 }
998 }
999 }
1000
1001 impl Claims {
1002 fn new_valid() -> Self {
1003 Self {
1004 exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
1005 ..Self::default()
1006 }
1007 }
1008
1009 fn new_expired() -> Self {
1010 Self {
1011 exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
1012 ..Self::default()
1013 }
1014 }
1015
1016 fn to_encoded(&self) -> String {
1017 encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
1018 }
1019
1020 fn to_access_token(&self) -> SecretAccessToken {
1021 SecretAccessToken::from(self.to_encoded())
1022 }
1023 }
1024
1025 #[test]
1026 fn test_valid_token() {
1027 let valid_token = Claims::new_valid().to_access_token();
1028 let tokens = OAuthSession::from_refresh_token(
1029 RefreshToken::new(SecretRefreshToken::from("unused")),
1030 AuthServer::default(),
1031 Some(valid_token.clone()),
1032 );
1033 assert_eq!(
1034 tokens
1035 .validate()
1036 .expect("Token should not fail validation."),
1037 valid_token
1038 );
1039 }
1040
1041 #[test]
1042 fn test_expired_token() {
1043 let invalid_token = Claims::new_expired().to_access_token();
1044 let tokens = OAuthSession::from_refresh_token(
1045 RefreshToken::new(SecretRefreshToken::from("unused")),
1046 AuthServer::default(),
1047 Some(invalid_token),
1048 );
1049 assert!(tokens.validate().is_err());
1050 }
1051
1052 #[test]
1053 fn test_client_credentials_without_access_token() {
1054 let tokens = OAuthSession::from_client_credentials(
1055 ClientCredentials::new("client_id", "client_secret"),
1056 AuthServer::default(),
1057 None,
1058 );
1059 assert!(tokens.validate().is_err());
1060 }
1061
1062 #[tokio::test]
1063 async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
1064 let access_token = Claims::new_valid().to_encoded();
1065 let mut config = ClientConfiguration::builder().build().unwrap();
1066 figment::Jail::expect_with(|jail| {
1067 let directory = jail.directory();
1068 let settings_file_name = "settings.toml";
1069 let settings_file_path = directory.join(settings_file_name);
1070 let secrets_file_name = "secrets.toml";
1071 let secrets_file_path = directory.join(secrets_file_name);
1072
1073 let settings_file_contents = r#"
1074default_profile_name = "default"
1075
1076[profiles]
1077[profiles.default]
1078api_url = ""
1079auth_server_name = "default"
1080credentials_name = "default"
1081
1082[auth_servers]
1083[auth_servers.default]
1084client_id = ""
1085issuer = ""
1086"#;
1087
1088 let secrets_file_contents = format!(
1090 r#"
1091[credentials]
1092[credentials.default]
1093[credentials.default.token_payload]
1094access_token = "{access_token}"
1095expires_in = 3600
1096id_token = "id_token"
1097scope = "offline_access openid profile email"
1098token_type = "Bearer"
1099"#
1100 );
1101
1102 jail.create_file(settings_file_name, settings_file_contents)
1103 .expect("should create test settings.toml");
1104 jail.create_file(secrets_file_name, &secrets_file_contents)
1105 .expect("should create test secrets.toml");
1106
1107 jail.set_env(
1108 "QCS_SETTINGS_FILE_PATH",
1109 settings_file_path
1110 .to_str()
1111 .expect("settings file path should be a string"),
1112 );
1113 jail.set_env(
1114 "QCS_SECRETS_FILE_PATH",
1115 secrets_file_path
1116 .to_str()
1117 .expect("secrets file path should be a string"),
1118 );
1119
1120 config = ClientConfiguration::load_default().unwrap();
1121 Ok(())
1122 });
1123
1124 assert_eq!(
1125 config.get_bearer_access_token().await.unwrap(),
1126 SecretAccessToken::from(access_token)
1127 );
1128 }
1129
1130 #[test]
1132 fn test_pkce_flow_persists_token() {
1133 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1136
1137 let PkceTestServerHarness {
1138 server,
1139 client,
1140 discovery: _,
1141 redirect_listener,
1142 } = runtime.block_on(PkceTestServerHarness::new());
1143
1144 let client_id = client.client_id;
1145 let issuer = server.issuer().to_string();
1146
1147 figment::Jail::expect_with(move |jail| {
1148 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1151
1152 let directory = jail.directory();
1153 let settings_file_name = "settings.toml";
1154 let settings_file_path = directory.join(settings_file_name);
1155
1156 let secrets_file_name = "secrets.toml";
1157 let secrets_file_path = directory.join(secrets_file_name);
1158
1159 let settings_file_contents = format!(
1160 r#"
1161default_profile_name = "default"
1162
1163[profiles]
1164[profiles.default]
1165api_url = ""
1166auth_server_name = "default"
1167credentials_name = "default"
1168
1169[auth_servers]
1170[auth_servers.default]
1171client_id = "{client_id}"
1172issuer = "{issuer}"
1173"#
1174 );
1175
1176 let secrets_file_contents = r#"
1177[credentials]
1178[credentials.default]
1179[credentials.default.token_payload]
1180access_token = ""
1181"#;
1182
1183 jail.create_file(settings_file_name, &settings_file_contents)
1184 .expect("should create test settings.toml");
1185
1186 jail.set_env(
1187 SETTINGS_PATH_VAR,
1188 settings_file_path
1189 .to_str()
1190 .expect("settings file path should be a string"),
1191 );
1192
1193 jail.create_file(secrets_file_name, secrets_file_contents)
1194 .expect("should create test secrets.toml");
1195
1196 jail.set_env(
1197 SECRETS_PATH_VAR,
1198 secrets_file_path
1199 .to_str()
1200 .expect("secrets file path should be a string"),
1201 );
1202
1203 runtime.block_on(async {
1205 let cancel_token = CancellationToken::new();
1206 let configuration = ClientConfiguration::load_with_login_with_redirect(
1208 cancel_token,
1209 None,
1210 RedirectBinding::Bound(redirect_listener),
1211 )
1212 .await
1213 .expect("should load configuration");
1214 let oauth_session = configuration.refresh().await.expect("should refresh");
1215 let token = oauth_session.validate().expect("token should be valid");
1216
1217 let configuration =
1219 ClientConfiguration::load_default().expect("should load configuration");
1220
1221 let oauth_session = configuration
1222 .oauth_session()
1223 .await
1224 .expect("should get oauth session");
1225
1226 let token_payload = expect_token_payload(
1227 Secrets::load_from_path(&secrets_file_path)
1228 .expect("should load secrets")
1229 .credentials
1230 .remove("default")
1231 .expect("should get default credentials"),
1232 );
1233
1234 assert_eq!(
1235 token,
1236 oauth_session.validate().expect("should contain token"),
1237 "session: {oauth_session:?}, token_payload: {token_payload:?}",
1238 );
1239 assert_eq!(
1240 token_payload.access_token,
1241 Some(token),
1242 "session: {oauth_session:?}, token_payload: {token_payload:?}"
1243 );
1244 assert_ne!(
1245 token_payload.refresh_token, None,
1246 "session: {oauth_session:?}, token_payload: {token_payload:?}"
1247 );
1248 });
1249
1250 Ok(())
1251 });
1252
1253 drop(server);
1254 }
1255
1256 #[test]
1263 fn test_load_with_login_persists_login_flow_token_without_explicit_refresh() {
1264 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1267
1268 let PkceTestServerHarness {
1269 server,
1270 client,
1271 discovery: _,
1272 redirect_listener,
1273 } = runtime.block_on(PkceTestServerHarness::new());
1274
1275 let client_id = client.client_id;
1276 let issuer = server.issuer().to_string();
1277
1278 figment::Jail::expect_with(move |jail| {
1279 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1282
1283 let directory = jail.directory();
1284 let settings_file_name = "settings.toml";
1285 let settings_file_path = directory.join(settings_file_name);
1286
1287 let secrets_file_name = "secrets.toml";
1288 let secrets_file_path = directory.join(secrets_file_name);
1289
1290 let settings_file_contents = format!(
1291 r#"
1292default_profile_name = "default"
1293
1294[profiles]
1295[profiles.default]
1296api_url = ""
1297auth_server_name = "default"
1298credentials_name = "default"
1299
1300[auth_servers]
1301[auth_servers.default]
1302client_id = "{client_id}"
1303issuer = "{issuer}"
1304"#
1305 );
1306
1307 let secrets_file_contents = r#"
1308[credentials]
1309[credentials.default]
1310[credentials.default.token_payload]
1311access_token = ""
1312"#;
1313
1314 jail.create_file(settings_file_name, &settings_file_contents)
1315 .expect("should create test settings.toml");
1316
1317 jail.set_env(
1318 SETTINGS_PATH_VAR,
1319 settings_file_path
1320 .to_str()
1321 .expect("settings file path should be a string"),
1322 );
1323
1324 jail.create_file(secrets_file_name, secrets_file_contents)
1325 .expect("should create test secrets.toml");
1326
1327 jail.set_env(
1328 SECRETS_PATH_VAR,
1329 secrets_file_path
1330 .to_str()
1331 .expect("secrets file path should be a string"),
1332 );
1333
1334 runtime.block_on(async {
1335 let cancel_token = CancellationToken::new();
1336
1337 let configuration = ClientConfiguration::load_with_login_with_redirect(
1340 cancel_token,
1341 None,
1342 RedirectBinding::Bound(redirect_listener),
1343 )
1344 .await
1345 .expect("should perform a login flow");
1346
1347 let oauth_session = configuration
1348 .oauth_session()
1349 .await
1350 .expect("should get oauth session");
1351 let token = oauth_session.validate().expect("token should be valid");
1352
1353 let token_payload = expect_token_payload(
1354 Secrets::load_from_path(&secrets_file_path)
1355 .expect("should load secrets")
1356 .credentials
1357 .remove("default")
1358 .expect("should get default credentials"),
1359 );
1360
1361 assert_eq!(
1362 token_payload.access_token,
1363 Some(token),
1364 "the access token from the login flow should be persisted without an \
1365 explicit follow-up refresh"
1366 );
1367 assert!(
1368 token_payload.refresh_token.is_some(),
1369 "the refresh token from the login flow should be persisted without an \
1370 explicit follow-up refresh"
1371 );
1372 });
1373
1374 Ok(())
1375 });
1376
1377 drop(server);
1378 }
1379
1380 #[test]
1388 fn test_load_with_login_persists_rotated_refresh_token_on_refresh() {
1389 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1390
1391 let mock_server = runtime.block_on(MockServer::start_async());
1392
1393 let new_access_token = Claims::new_valid().to_encoded();
1394 let rotated_refresh_token = "rotated_refresh_token".to_string();
1395
1396 let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1397 when.method(GET).path("/.well-known/openid-configuration");
1398 then.status(200)
1399 .json_body_obj(&oidc::Discovery::new_for_test(
1400 mock_server.base_url().parse().unwrap(),
1401 ));
1402 }));
1403
1404 let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1405 when.method(POST).path("/v1/token");
1406 then.status(200).json_body_obj(&RefreshTokenResponse {
1407 access_token: SecretAccessToken::from(new_access_token.clone()),
1408 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1409 });
1410 }));
1411
1412 let client_id = "client_id";
1413 let issuer = mock_server.base_url();
1414 let initial_refresh_token = "initial_refresh_token";
1415 let expired_access_token = Claims::new_expired().to_encoded();
1416
1417 figment::Jail::expect_with(|jail| {
1418 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1419
1420 let directory = jail.directory();
1421 let settings_file_name = "settings.toml";
1422 let settings_file_path = directory.join(settings_file_name);
1423
1424 let secrets_file_name = "secrets.toml";
1425 let secrets_file_path = directory.join(secrets_file_name);
1426
1427 let settings_file_contents = format!(
1428 r#"
1429default_profile_name = "default"
1430
1431[profiles]
1432[profiles.default]
1433api_url = ""
1434auth_server_name = "default"
1435credentials_name = "default"
1436
1437[auth_servers]
1438[auth_servers.default]
1439client_id = "{client_id}"
1440issuer = "{issuer}"
1441"#
1442 );
1443
1444 let secrets_file_contents = format!(
1445 r#"
1446[credentials]
1447[credentials.default]
1448[credentials.default.token_payload]
1449access_token = "{expired_access_token}"
1450refresh_token = "{initial_refresh_token}"
1451"#
1452 );
1453
1454 jail.create_file(settings_file_name, &settings_file_contents)
1455 .expect("should create test settings.toml");
1456 jail.set_env(
1457 SETTINGS_PATH_VAR,
1458 settings_file_path
1459 .to_str()
1460 .expect("settings file path should be a string"),
1461 );
1462
1463 jail.create_file(secrets_file_name, &secrets_file_contents)
1464 .expect("should create test secrets.toml");
1465 jail.set_env(
1466 SECRETS_PATH_VAR,
1467 secrets_file_path
1468 .to_str()
1469 .expect("secrets file path should be a string"),
1470 );
1471
1472 runtime.block_on(async {
1473 let cancel_token = CancellationToken::new();
1474
1475 let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1478 .await
1479 .expect("should refresh using the stored refresh token");
1480
1481 oidc_mock.assert_async().await;
1482 issuer_mock.assert_async().await;
1483
1484 let oauth_session = configuration
1485 .oauth_session()
1486 .await
1487 .expect("should get oauth session");
1488
1489 assert_eq!(
1490 oauth_session.access_token().cloned().ok(),
1491 Some(SecretAccessToken::from(new_access_token.clone())),
1492 "in-memory access token should be the freshly refreshed one"
1493 );
1494
1495 match oauth_session.payload() {
1496 OAuthGrant::RefreshToken(payload) => {
1497 assert_eq!(
1498 payload.refresh_token,
1499 SecretRefreshToken::from(rotated_refresh_token.clone()),
1500 "in-memory refresh token should be updated to the rotated value"
1501 );
1502 }
1503 other => panic!("expected a RefreshToken grant, got {other:?}"),
1504 }
1505
1506 let token_payload = expect_token_payload(
1507 Secrets::load_from_path(&secrets_file_path)
1508 .expect("should load secrets")
1509 .credentials
1510 .remove("default")
1511 .expect("should get default credentials"),
1512 );
1513
1514 assert_eq!(
1515 token_payload.access_token,
1516 Some(SecretAccessToken::from(new_access_token.clone())),
1517 "new access token should be persisted to the secrets file"
1518 );
1519 assert_eq!(
1520 token_payload.refresh_token,
1521 Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1522 "rotated refresh token should be persisted to the secrets file, otherwise \
1523 the next process to load this profile will retry the stale, \
1524 already-consumed refresh token and be forced back into a login flow"
1525 );
1526 });
1527
1528 Ok(())
1529 });
1530 }
1531
1532 #[test]
1544 fn test_refresh_persists_to_credentials_name_not_profile_name() {
1545 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1546
1547 let mock_server = runtime.block_on(MockServer::start_async());
1548
1549 let new_access_token = Claims::new_valid().to_encoded();
1550 let rotated_refresh_token = "rotated_refresh_token".to_string();
1551
1552 let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1553 when.method(GET).path("/.well-known/openid-configuration");
1554 then.status(200)
1555 .json_body_obj(&oidc::Discovery::new_for_test(
1556 mock_server.base_url().parse().unwrap(),
1557 ));
1558 }));
1559
1560 let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
1561 when.method(POST).path("/v1/token");
1562 then.status(200).json_body_obj(&RefreshTokenResponse {
1563 access_token: SecretAccessToken::from(new_access_token.clone()),
1564 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1565 });
1566 }));
1567
1568 let client_id = "client_id";
1569 let issuer = mock_server.base_url();
1570 let initial_refresh_token = "initial_refresh_token";
1571 let expired_access_token = Claims::new_expired().to_encoded();
1572
1573 let profile_name = "funnel";
1575 let credentials_name = "shared";
1576
1577 figment::Jail::expect_with(|jail| {
1578 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1579
1580 let directory = jail.directory();
1581 let settings_file_name = "settings.toml";
1582 let settings_file_path = directory.join(settings_file_name);
1583
1584 let secrets_file_name = "secrets.toml";
1585 let secrets_file_path = directory.join(secrets_file_name);
1586
1587 let settings_file_contents = format!(
1588 r#"
1589default_profile_name = "{profile_name}"
1590
1591[profiles]
1592[profiles.{profile_name}]
1593api_url = ""
1594auth_server_name = "default"
1595credentials_name = "{credentials_name}"
1596
1597[auth_servers]
1598[auth_servers.default]
1599client_id = "{client_id}"
1600issuer = "{issuer}"
1601"#
1602 );
1603
1604 let secrets_file_contents = format!(
1608 r#"
1609[credentials]
1610[credentials.{credentials_name}]
1611[credentials.{credentials_name}.token_payload]
1612access_token = "{expired_access_token}"
1613refresh_token = "{initial_refresh_token}"
1614
1615[credentials.{profile_name}]
1616[credentials.{profile_name}.token_payload]
1617access_token = "decoy_access_token"
1618refresh_token = "decoy_refresh_token"
1619"#
1620 );
1621
1622 jail.create_file(settings_file_name, &settings_file_contents)
1623 .expect("should create test settings.toml");
1624 jail.set_env(
1625 SETTINGS_PATH_VAR,
1626 settings_file_path
1627 .to_str()
1628 .expect("settings file path should be a string"),
1629 );
1630
1631 jail.create_file(secrets_file_name, &secrets_file_contents)
1632 .expect("should create test secrets.toml");
1633 jail.set_env(
1634 SECRETS_PATH_VAR,
1635 secrets_file_path
1636 .to_str()
1637 .expect("secrets file path should be a string"),
1638 );
1639
1640 runtime.block_on(async {
1641 let configuration = ClientConfiguration::load_profile(profile_name.to_string())
1642 .expect("should load the profile");
1643
1644 assert_eq!(configuration.profile(), profile_name);
1645 assert_eq!(configuration.credentials_name(), credentials_name);
1646
1647 let access_token = configuration
1649 .get_bearer_access_token()
1650 .await
1651 .expect("should refresh the expired access token");
1652
1653 oidc_mock.assert_async().await;
1654 issuer_mock.assert_async().await;
1655
1656 assert_eq!(
1657 access_token,
1658 SecretAccessToken::from(new_access_token.clone())
1659 );
1660
1661 let mut credentials = Secrets::load_from_path(&secrets_file_path)
1662 .expect("should load secrets")
1663 .credentials;
1664
1665 let token_payload = expect_token_payload(
1666 credentials
1667 .remove(credentials_name)
1668 .expect("should get the credential the profile points at"),
1669 );
1670
1671 assert_eq!(
1672 token_payload.access_token,
1673 Some(SecretAccessToken::from(new_access_token.clone())),
1674 "the refreshed access token should be persisted under `credentials_name`, \
1675 which is where the next load reads it from"
1676 );
1677 assert_eq!(
1678 token_payload.refresh_token,
1679 Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
1680 "the rotated refresh token should be persisted under `credentials_name`"
1681 );
1682
1683 let decoy_payload = expect_token_payload(
1684 credentials
1685 .remove(profile_name)
1686 .expect("decoy credential should still exist"),
1687 );
1688
1689 assert_eq!(
1690 decoy_payload.access_token,
1691 Some(SecretAccessToken::from("decoy_access_token".to_string())),
1692 "the credential named after the profile is not the one in use and \
1693 should be left untouched"
1694 );
1695 });
1696
1697 Ok(())
1698 });
1699 }
1700
1701 fn externally_managed_config_files(access_token: &str) -> (&'static str, String) {
1704 let settings = r#"
1705default_profile_name = "default"
1706
1707[profiles]
1708[profiles.default]
1709api_url = ""
1710auth_server_name = "default"
1711credentials_name = "default"
1712
1713[auth_servers]
1714[auth_servers.default]
1715client_id = ""
1716issuer = ""
1717"#;
1718
1719 let (program, flag) = super::external_command::shell();
1720 let args = format!(r#"["{flag}", "echo {access_token}"]"#);
1721
1722 let secrets = format!(
1723 r"
1724[credentials]
1725[credentials.default]
1726[credentials.default.externally_managed]
1727command = {program:?}
1728args = {args}
1729"
1730 );
1731
1732 (settings, secrets)
1733 }
1734
1735 fn jail_config_files(
1737 jail: &mut figment::Jail,
1738 settings: &str,
1739 secrets: &str,
1740 ) -> std::path::PathBuf {
1741 let directory = jail.directory().to_path_buf();
1742 jail.create_file("settings.toml", settings)
1743 .expect("should create test settings.toml");
1744 jail.create_file("secrets.toml", secrets)
1745 .expect("should create test secrets.toml");
1746
1747 jail.set_env(
1748 SETTINGS_PATH_VAR,
1749 directory
1750 .join("settings.toml")
1751 .to_str()
1752 .expect("settings path should be a string"),
1753 );
1754 let secrets_path = directory.join("secrets.toml");
1755 jail.set_env(
1756 SECRETS_PATH_VAR,
1757 secrets_path
1758 .to_str()
1759 .expect("secrets path should be a string"),
1760 );
1761
1762 secrets_path
1763 }
1764
1765 #[test]
1768 fn test_externally_managed_credential_runs_the_configured_program() {
1769 let access_token = Claims::new_valid().to_encoded();
1770 let (settings, secrets) = externally_managed_config_files(&access_token);
1771
1772 figment::Jail::expect_with(|jail| {
1773 jail_config_files(jail, settings, &secrets);
1774
1775 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1776 runtime.block_on(async {
1777 let config = ClientConfiguration::load_default().expect("should load config");
1778
1779 assert_eq!(
1780 config
1781 .get_bearer_access_token()
1782 .await
1783 .expect("should get an access token from the external program"),
1784 SecretAccessToken::from(access_token.clone())
1785 );
1786 });
1787
1788 Ok(())
1789 });
1790 }
1791
1792 #[test]
1795 fn test_externally_managed_credential_is_never_persisted() {
1796 let access_token = Claims::new_valid().to_encoded();
1797 let (settings, secrets) = externally_managed_config_files(&access_token);
1798
1799 figment::Jail::expect_with(|jail| {
1800 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1801 let secrets_path = jail_config_files(jail, settings, &secrets);
1802 let before =
1803 std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
1804
1805 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1806 runtime.block_on(async {
1807 let config = ClientConfiguration::load_default().expect("should load config");
1808 config
1809 .refresh()
1810 .await
1811 .expect("should refresh via the external program");
1812 });
1813
1814 let after =
1815 std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
1816 assert_eq!(
1817 before, after,
1818 "an externally managed credential's tokens should never be written to disk"
1819 );
1820
1821 Ok(())
1822 });
1823 }
1824
1825 #[test]
1829 fn test_externally_managed_credential_skips_the_login_flow() {
1830 let access_token = Claims::new_valid().to_encoded();
1831 let (settings, secrets) = externally_managed_config_files(&access_token);
1832
1833 figment::Jail::expect_with(|jail| {
1834 jail_config_files(jail, settings, &secrets);
1835
1836 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1837 runtime.block_on(async {
1838 let config = ClientConfiguration::load_with_login(CancellationToken::new(), None)
1839 .await
1840 .expect("should load without an interactive login");
1841
1842 assert_eq!(
1843 config
1844 .get_bearer_access_token()
1845 .await
1846 .expect("should get an access token from the external program"),
1847 SecretAccessToken::from(access_token.clone())
1848 );
1849 });
1850
1851 Ok(())
1852 });
1853 }
1854
1855 #[cfg(unix)]
1858 #[test]
1859 fn test_externally_managed_credential_reuses_a_valid_token() {
1860 let access_token = Claims::new_valid().to_encoded();
1861 let (settings, _) = externally_managed_config_files(&access_token);
1862
1863 figment::Jail::expect_with(|jail| {
1864 let counter = jail.directory().join("invocations");
1865 let secrets = format!(
1867 r#"
1868[credentials]
1869[credentials.default]
1870[credentials.default.externally_managed]
1871command = "/bin/sh"
1872args = ["-c", "echo run >> {counter} && printf '%s' '{access_token}'"]
1873"#,
1874 counter = counter.display(),
1875 );
1876 jail_config_files(jail, settings, &secrets);
1877
1878 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1879 runtime.block_on(async {
1880 let config = ClientConfiguration::load_default().expect("should load config");
1881 for _ in 0..5_u8 {
1882 config
1883 .get_bearer_access_token()
1884 .await
1885 .expect("should get an access token");
1886 }
1887 });
1888
1889 let invocations = std::fs::read_to_string(&counter)
1890 .expect("the program should have run at least once")
1891 .lines()
1892 .count();
1893 assert_eq!(
1894 invocations, 1,
1895 "a valid token should be reused across requests, not re-fetched"
1896 );
1897
1898 Ok(())
1899 });
1900 }
1901
1902 #[cfg(unix)]
1908 #[test]
1909 fn test_externally_managed_credential_does_not_loop_on_invalid_tokens() {
1910 let (settings, _) = externally_managed_config_files("unused");
1911 let expired = Claims::new_expired().to_encoded();
1912
1913 for (label, token) in [("expired", expired.as_str()), ("opaque", "not-a-jwt")] {
1915 figment::Jail::expect_with(|jail| {
1916 let counter = jail.directory().join("invocations");
1917 let secrets = format!(
1919 r#"
1920[credentials]
1921[credentials.default]
1922[credentials.default.externally_managed]
1923command = "/bin/sh"
1924args = ["-c", "echo run >> {counter} && printf '%s' '{token}'"]
1925"#,
1926 counter = counter.display(),
1927 );
1928 jail_config_files(jail, settings, &secrets);
1929
1930 let requests = 3_usize;
1931 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1932 runtime.block_on(async {
1933 let config = ClientConfiguration::load_default().expect("should load config");
1934
1935 tokio::time::timeout(std::time::Duration::from_secs(30), async {
1937 for _ in 0..requests {
1938 assert_eq!(
1939 config
1940 .get_bearer_access_token()
1941 .await
1942 .expect(
1943 "an unvalidatable token is still returned to the caller"
1944 )
1945 .secret(),
1946 token,
1947 "the {label} token from the program should be returned as-is"
1948 );
1949 }
1950 })
1951 .await
1952 .expect("requesting a token should not loop on the external program");
1953 });
1954
1955 let invocations = std::fs::read_to_string(&counter)
1956 .expect("the program should have run at least once")
1957 .lines()
1958 .count();
1959 assert_eq!(
1960 invocations, requests,
1961 "a program returning {label} tokens should run once per request, not \
1962 repeatedly until it produces a valid one"
1963 );
1964
1965 Ok(())
1966 });
1967 }
1968 }
1969
1970 #[test]
1973 fn test_externally_managed_credential_takes_precedence_over_stored_tokens() {
1974 let access_token = Claims::new_valid().to_encoded();
1975 let (settings, secrets) = externally_managed_config_files(&access_token);
1976 let secrets = format!(
1977 "{secrets}\n[credentials.default.token_payload]\naccess_token = \"{}\"\n",
1978 Claims::new_valid().to_encoded()
1979 );
1980
1981 figment::Jail::expect_with(|jail| {
1982 jail_config_files(jail, settings, &secrets);
1983
1984 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
1985 runtime.block_on(async {
1986 let config = ClientConfiguration::load_default().expect("should load config");
1987
1988 assert_eq!(
1989 config
1990 .get_bearer_access_token()
1991 .await
1992 .expect("should get an access token from the external program"),
1993 SecretAccessToken::from(access_token.clone()),
1994 "the external program should win over the stored access token"
1995 );
1996 });
1997
1998 Ok(())
1999 });
2000 }
2001
2002 const TEST_CLIENT_ID: &str = "a-client-id";
2004 const TEST_CLIENT_SECRET: &str = "a-client-secret";
2005
2006 fn expected_basic_auth(client_id: &str, client_secret: &str) -> String {
2007 use base64::Engine as _;
2008
2009 let encoded = base64::engine::general_purpose::STANDARD
2010 .encode(format!("{client_id}:{client_secret}"));
2011 format!("Basic {encoded}")
2012 }
2013
2014 fn client_credentials_fixture_files(issuer: &str) -> (String, String) {
2016 let settings = format!(
2017 r#"
2018default_profile_name = "default"
2019
2020[profiles]
2021[profiles.default]
2022api_url = ""
2023auth_server_name = "default"
2024credentials_name = "default"
2025
2026[auth_servers]
2027[auth_servers.default]
2028client_id = "ignored"
2029issuer = "{issuer}"
2030"#
2031 );
2032
2033 let secrets = format!(
2034 r#"
2035[credentials]
2036[credentials.default.client_credentials]
2037client_id = "{TEST_CLIENT_ID}"
2038client_secret = "{TEST_CLIENT_SECRET}"
2039"#
2040 );
2041
2042 (settings, secrets)
2043 }
2044
2045 fn mock_client_credentials_issuer<'server>(
2046 runtime: &tokio::runtime::Runtime,
2047 mock_server: &'server MockServer,
2048 access_token: &str,
2049 ) -> (httpmock::Mock<'server>, httpmock::Mock<'server>) {
2050 let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
2051 when.method(GET).path("/.well-known/openid-configuration");
2052 then.status(200)
2053 .json_body_obj(&oidc::Discovery::new_for_test(
2054 mock_server.base_url().parse().unwrap(),
2055 ));
2056 }));
2057
2058 let authorization = expected_basic_auth(TEST_CLIENT_ID, TEST_CLIENT_SECRET);
2059 let access_token = access_token.to_string();
2060 let token_mock = runtime.block_on(mock_server.mock_async(|when, then| {
2061 when.method(POST)
2062 .path("/v1/token")
2063 .header("authorization", authorization)
2064 .body_includes("grant_type=client_credentials");
2065 then.status(200).json_body_obj(&ClientCredentialsResponse {
2066 access_token: SecretAccessToken::from(access_token),
2067 });
2068 }));
2069
2070 (oidc_mock, token_mock)
2071 }
2072
2073 #[test]
2075 fn test_client_credentials_credential_authenticates_as_its_own_client_id() {
2076 let access_token = Claims::new_valid().to_encoded();
2077
2078 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2079 let mock_server = runtime.block_on(MockServer::start_async());
2080 let (oidc_mock, token_mock) =
2081 mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2082
2083 let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2084
2085 figment::Jail::expect_with(|jail| {
2086 jail_config_files(jail, &settings, &secrets);
2087
2088 runtime.block_on(async {
2089 let config = ClientConfiguration::load_default().expect("should load config");
2090
2091 assert_eq!(
2092 config
2093 .get_bearer_access_token()
2094 .await
2095 .expect("should exchange the client secret for an access token"),
2096 SecretAccessToken::from(access_token.clone()),
2097 );
2098 });
2099
2100 oidc_mock.assert();
2101 token_mock.assert();
2102
2103 Ok(())
2104 });
2105 }
2106
2107 #[test]
2108 fn test_client_credentials_credential_is_never_persisted() {
2109 let access_token = Claims::new_valid().to_encoded();
2110
2111 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2112 let mock_server = runtime.block_on(MockServer::start_async());
2113 let (_oidc_mock, _token_mock) =
2114 mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2115
2116 let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2117
2118 figment::Jail::expect_with(|jail| {
2119 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
2120 let secrets_path = jail_config_files(jail, &settings, &secrets);
2121 let before =
2122 std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
2123
2124 runtime.block_on(async {
2125 let config = ClientConfiguration::load_default().expect("should load config");
2126 config
2127 .refresh()
2128 .await
2129 .expect("should exchange the client secret for an access token");
2130 });
2131
2132 let after =
2133 std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
2134 assert_eq!(
2135 before, after,
2136 "a client credentials grant has nothing to persist: no refresh token, and an \
2137 access token the secret can mint again at any time"
2138 );
2139
2140 Ok(())
2141 });
2142 }
2143
2144 #[test]
2145 fn test_client_credentials_credential_skips_the_login_flow() {
2146 let access_token = Claims::new_valid().to_encoded();
2147
2148 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
2149 let mock_server = runtime.block_on(MockServer::start_async());
2150 let (_oidc_mock, token_mock) =
2151 mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
2152
2153 let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
2154
2155 figment::Jail::expect_with(|jail| {
2156 jail_config_files(jail, &settings, &secrets);
2157
2158 runtime.block_on(async {
2159 let config = ClientConfiguration::load_with_login(CancellationToken::new(), None)
2160 .await
2161 .expect("should load without an interactive login");
2162
2163 assert_eq!(
2164 config
2165 .get_bearer_access_token()
2166 .await
2167 .expect("should exchange the client secret for an access token"),
2168 SecretAccessToken::from(access_token.clone()),
2169 );
2170 });
2171
2172 token_mock.assert();
2173
2174 Ok(())
2175 });
2176 }
2177}