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