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