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