1use crate::configuration::{secrets::SecretAccessToken, tokens::insecure_validate_token_exp};
30#[cfg(feature = "tracing-config")]
31use crate::tracing_configuration::TracingConfiguration;
32use derive_builder::Builder;
33use std::{env, path::PathBuf};
34use tokio_util::sync::CancellationToken;
35
36#[cfg(feature = "stubs")]
37use pyo3_stub_gen::derive::gen_stub_pyclass;
38
39use self::{
40 secrets::{Credential, Secrets, TokenPayload},
41 settings::Settings,
42};
43
44pub(crate) mod error;
45mod oidc;
46mod pkce;
47mod secret_string;
48pub mod secrets;
49pub mod settings;
50pub mod tokens;
51
52pub use error::{LoadError, TokenError};
53#[cfg(feature = "python")]
54pub(crate) mod py;
55
56use settings::AuthServer;
57use tokens::{OAuthGrant, OAuthSession, PkceFlow, RefreshToken, TokenDispatcher};
58
59pub const DEFAULT_PROFILE_NAME: &str = "default";
61pub const PROFILE_NAME_VAR: &str = "QCS_PROFILE_NAME";
63fn env_or_default_profile_name() -> String {
64 env::var(PROFILE_NAME_VAR).unwrap_or_else(|_| DEFAULT_PROFILE_NAME.to_string())
65}
66
67pub const DEFAULT_API_URL: &str = "https://api.qcs.rigetti.com";
69pub const API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_API_URL";
71fn env_or_default_api_url() -> String {
72 env::var(API_URL_VAR).unwrap_or_else(|_| DEFAULT_API_URL.to_string())
73}
74
75pub const DEFAULT_GRPC_API_URL: &str = "https://grpc.qcs.rigetti.com";
77pub const GRPC_API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_GRPC_URL";
79fn env_or_default_grpc_url() -> String {
80 env::var(GRPC_API_URL_VAR).unwrap_or_else(|_| DEFAULT_GRPC_API_URL.to_string())
81}
82
83pub const DEFAULT_QVM_URL: &str = "http://127.0.0.1:5000";
85pub const QVM_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QVM_URL";
87fn env_or_default_qvm_url() -> String {
88 env::var(QVM_URL_VAR).unwrap_or_else(|_| DEFAULT_QVM_URL.to_string())
89}
90
91pub const DEFAULT_QUILC_URL: &str = "tcp://127.0.0.1:5555";
93pub const QUILC_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QUILC_URL";
95fn env_or_default_quilc_url() -> String {
96 env::var(QUILC_URL_VAR).unwrap_or_else(|_| DEFAULT_QUILC_URL.to_string())
97}
98
99#[derive(Clone, Debug, Builder)]
112#[cfg_attr(
113 not(feature = "stubs"),
114 builder_struct_attr(optipy::strip_pyo3(only_stubs)),
115 optipy::strip_pyo3(only_stubs)
116)]
117#[cfg_attr(
118 not(feature = "python"),
119 builder_struct_attr(optipy::strip_pyo3),
120 optipy::strip_pyo3
121)]
122#[cfg_attr(
123 feature = "stubs",
124 builder_struct_attr(gen_stub_pyclass),
125 gen_stub_pyclass
126)]
127#[cfg_attr(
128 feature = "python",
129 builder_struct_attr(pyo3::pyclass(module = "qcs_api_client_common.configuration")),
130 pyo3::pyclass(module = "qcs_api_client_common.configuration")
131)]
132pub struct ClientConfiguration {
133 #[builder(private, default = "env_or_default_profile_name()")]
134 #[builder_field_attr(gen_stub(skip))]
135 profile: String,
136
137 #[doc = "The URL for the QCS REST API."]
138 #[builder(default = "env_or_default_api_url()")]
139 #[builder_field_attr(pyo3(get, set))]
140 #[pyo3(get)]
141 api_url: String,
142
143 #[doc = "The URL for the QCS gRPC API."]
144 #[builder(default = "env_or_default_grpc_url()")]
145 #[builder_field_attr(pyo3(get, set))]
146 #[pyo3(get)]
147 grpc_api_url: String,
148
149 #[doc = "The URL of the quilc server."]
150 #[builder(default = "env_or_default_quilc_url()")]
151 #[builder_field_attr(pyo3(get, set))]
152 #[pyo3(get)]
153 quilc_url: String,
154
155 #[doc = "The URL of the QVM server."]
156 #[builder(default = "env_or_default_qvm_url()")]
157 #[builder_field_attr(pyo3(get, set))]
158 #[pyo3(get)]
159 qvm_url: String,
160
161 #[builder(default, setter(custom))]
166 #[builder_field_attr(pyo3(get))]
167 pub(crate) oauth_session: Option<TokenDispatcher>,
168
169 #[builder(private, default = "ConfigSource::Builder")]
170 #[builder_field_attr(gen_stub(skip))]
171 source: ConfigSource,
172
173 #[cfg(feature = "tracing-config")]
175 #[builder(default)]
176 #[builder_field_attr(gen_stub(skip))]
177 tracing_configuration: Option<TracingConfiguration>,
178}
179
180impl ClientConfigurationBuilder {
181 pub fn oauth_session(&mut self, oauth_session: Option<OAuthSession>) -> &mut Self {
186 self.oauth_session = Some(oauth_session.map(Into::into));
187 self
188 }
189}
190
191struct ConfigurationContext {
193 builder: ClientConfigurationBuilder,
194 auth_server: AuthServer,
195 credential: Option<Credential>,
196}
197
198impl ConfigurationContext {
199 fn from_profile(profile_name: Option<String>) -> Result<Self, LoadError> {
200 #[cfg(feature = "tracing-config")]
201 match profile_name.as_ref() {
202 None => tracing::debug!("loading default QCS profile"),
203 Some(profile) => tracing::debug!("loading QCS profile {profile}"),
204 }
205 let settings = Settings::load()?;
206 let secrets = Secrets::load()?;
207 Self::from_sources(settings, secrets, profile_name)
208 }
209
210 fn from_sources(
211 settings: Settings,
212 mut secrets: Secrets,
213 profile_name: Option<String>,
214 ) -> Result<Self, LoadError> {
215 let Settings {
216 default_profile_name,
217 mut profiles,
218 mut auth_servers,
219 file_path: settings_path,
220 } = settings;
221 let profile_name = profile_name
222 .or_else(|| env::var(PROFILE_NAME_VAR).ok())
223 .unwrap_or(default_profile_name);
224 let profile = profiles
225 .remove(&profile_name)
226 .ok_or(LoadError::ProfileNotFound(profile_name.clone()))?;
227 let auth_server = auth_servers
228 .remove(&profile.auth_server_name)
229 .ok_or_else(|| LoadError::AuthServerNotFound(profile.auth_server_name.clone()))?;
230
231 let secrets_path = secrets.file_path;
232 let credential = secrets.credentials.remove(&profile.credentials_name);
233
234 let api_url = env::var(API_URL_VAR)
235 .unwrap_or(profile.api_url)
236 .trim_end_matches('/')
237 .to_string();
238 let quilc_url = env::var(QUILC_URL_VAR).unwrap_or(profile.applications.pyquil.quilc_url);
239 let qvm_url = env::var(QVM_URL_VAR).unwrap_or(profile.applications.pyquil.qvm_url);
240 let grpc_api_url = env::var(GRPC_API_URL_VAR)
241 .unwrap_or(profile.grpc_api_url)
242 .trim_end_matches('/')
243 .to_string();
244
245 #[cfg(feature = "tracing-config")]
246 let tracing_configuration =
247 TracingConfiguration::from_env().map_err(LoadError::TracingFilterParseError)?;
248
249 let source = match (settings_path, secrets_path) {
250 (Some(settings_path), Some(secrets_path)) => ConfigSource::File {
251 settings_path,
252 secrets_path,
253 },
254 _ => ConfigSource::Default,
255 };
256
257 let mut builder = ClientConfiguration::builder();
258 builder
259 .profile(profile_name)
260 .source(source)
261 .api_url(api_url)
262 .quilc_url(quilc_url)
263 .qvm_url(qvm_url)
264 .grpc_api_url(grpc_api_url);
265
266 #[cfg(feature = "tracing-config")]
267 {
268 builder.tracing_configuration(tracing_configuration);
269 }
270
271 Ok(Self {
272 builder,
273 auth_server,
274 credential,
275 })
276 }
277}
278
279fn credential_to_oauth_session(
280 credential: Option<Credential>,
281 auth_server: AuthServer,
282) -> Option<OAuthSession> {
283 match credential {
284 Some(Credential {
285 token_payload:
286 Some(TokenPayload {
287 access_token,
288 refresh_token,
289 ..
290 }),
291 }) => Some(OAuthSession::new(
292 OAuthGrant::RefreshToken(RefreshToken::new(refresh_token.unwrap_or_default())),
293 auth_server,
294 access_token,
295 )),
296 _ => None,
297 }
298}
299
300impl ClientConfiguration {
301 #[cfg(test)]
302 fn new(
303 settings: Settings,
304 secrets: Secrets,
305 profile_name: Option<String>,
306 ) -> Result<Self, LoadError> {
307 let ConfigurationContext {
308 mut builder,
309 auth_server,
310 credential,
311 } = ConfigurationContext::from_sources(settings, secrets, profile_name)?;
312 let oauth_session = credential_to_oauth_session(credential, auth_server);
313 Ok(builder.oauth_session(oauth_session).build()?)
314 }
315
316 pub fn load_default() -> Result<Self, LoadError> {
322 let base_config = Self::load(None)?;
323 Ok(base_config)
324 }
325
326 pub fn load_profile(profile_name: String) -> Result<Self, LoadError> {
333 Self::load(Some(profile_name))
334 }
335
336 pub async fn load_with_login(
345 cancel_token: CancellationToken,
346 profile_name: Option<String>,
347 ) -> Result<Self, LoadError> {
348 let ConfigurationContext {
349 mut builder,
350 auth_server,
351 credential,
352 } = ConfigurationContext::from_profile(profile_name)?;
353
354 if let Some(Credential {
356 token_payload:
357 Some(TokenPayload {
358 access_token,
359 refresh_token,
360 ..
361 }),
362 }) = credential
363 {
364 if let Some(access_token) = access_token {
366 if insecure_validate_token_exp(&access_token).is_ok() {
367 let refresh_token = refresh_token.unwrap_or_default();
368
369 let oauth_session = OAuthSession::new(
370 OAuthGrant::RefreshToken(RefreshToken::new(refresh_token)),
371 auth_server,
372 Some(access_token),
373 );
374 return Ok(builder.oauth_session(Some(oauth_session)).build()?);
375 }
376 }
377
378 if let Some(refresh_token) = refresh_token {
380 if !refresh_token.is_empty() {
381 let mut refresh_token = RefreshToken::new(refresh_token);
382
383 if let Ok(access_token) = refresh_token.request_access_token(&auth_server).await
385 {
386 let oauth_session = OAuthSession::new(
387 OAuthGrant::RefreshToken(refresh_token),
388 auth_server,
389 Some(access_token),
390 );
391
392 return Ok(builder.oauth_session(Some(oauth_session)).build()?);
393 }
394 }
395 }
396 }
397
398 let pkce_flow = PkceFlow::new_login_flow(cancel_token, &auth_server).await?;
400 let access_token = pkce_flow.access_token.clone();
401 let oauth_session =
402 OAuthSession::from_pkce_flow(pkce_flow, auth_server, Some(access_token));
403
404 Ok(builder.oauth_session(Some(oauth_session)).build()?)
405 }
406
407 fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
415 let ConfigurationContext {
416 mut builder,
417 auth_server,
418 credential,
419 } = ConfigurationContext::from_profile(profile_name)?;
420 let oauth_session = credential_to_oauth_session(credential, auth_server);
421 Ok(builder.oauth_session(oauth_session).build()?)
422 }
423
424 #[must_use]
426 pub fn builder() -> ClientConfigurationBuilder {
427 ClientConfigurationBuilder::default()
428 }
429
430 #[must_use]
432 pub fn profile(&self) -> &str {
433 &self.profile
434 }
435
436 #[must_use]
438 pub fn api_url(&self) -> &str {
439 &self.api_url
440 }
441
442 #[must_use]
444 pub fn grpc_api_url(&self) -> &str {
445 &self.grpc_api_url
446 }
447
448 #[must_use]
450 pub fn quilc_url(&self) -> &str {
451 &self.quilc_url
452 }
453
454 #[must_use]
456 pub fn qvm_url(&self) -> &str {
457 &self.qvm_url
458 }
459
460 #[cfg(feature = "tracing-config")]
462 #[must_use]
463 pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
464 self.tracing_configuration.as_ref()
465 }
466
467 #[must_use]
469 pub const fn source(&self) -> &ConfigSource {
470 &self.source
471 }
472
473 pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
481 Ok(self
482 .oauth_session
483 .as_ref()
484 .ok_or(TokenError::NoRefreshToken)?
485 .tokens()
486 .await)
487 }
488
489 pub async fn get_bearer_access_token(&self) -> Result<SecretAccessToken, TokenError> {
495 let dispatcher = self
496 .oauth_session
497 .as_ref()
498 .ok_or_else(|| TokenError::NoCredentials)?;
499 match dispatcher.validate().await {
500 Ok(tokens) => Ok(tokens),
501 #[allow(unused_variables)]
502 Err(e) => {
503 #[cfg(feature = "tracing-config")]
504 tracing::debug!("Refreshing access token because current one is invalid: {e}");
505 dispatcher
506 .refresh(self.source(), self.profile())
507 .await
508 .map(|e| e.access_token().cloned())?
509 }
510 }
511 }
512
513 pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
519 self.oauth_session
520 .as_ref()
521 .ok_or(TokenError::NoRefreshToken)?
522 .refresh(self.source(), self.profile())
523 .await
524 }
525}
526
527#[derive(Clone, Debug)]
529pub enum ConfigSource {
530 Builder,
532 File {
534 settings_path: PathBuf,
536 secrets_path: PathBuf,
538 },
539 Default,
541}
542
543fn expand_path_from_env_or_default(
544 env_var_name: &str,
545 default: &str,
546) -> Result<PathBuf, LoadError> {
547 match env::var(env_var_name) {
548 Ok(path) => {
549 let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
550 let path_buf: PathBuf = expanded_path.as_ref().into();
551 if !path_buf.exists() {
552 return Err(LoadError::Path {
553 path: path_buf,
554 message: format!("The given path does not exist: {path}"),
555 });
556 }
557 Ok(path_buf)
558 }
559 Err(env::VarError::NotPresent) => {
560 let expanded_path = shellexpand::tilde_with_context(default, || {
561 env::home_dir().map(|path| path.display().to_string())
562 });
563 let path_buf: PathBuf = expanded_path.as_ref().into();
564 if !path_buf.exists() {
565 return Err(LoadError::Path {
566 path: path_buf,
567 message: format!(
568 "Could not find a QCS configuration at the default path: {default}"
569 ),
570 });
571 }
572 Ok(path_buf)
573 }
574 Err(other_error) => Err(LoadError::EnvVar {
575 variable_name: env_var_name.to_string(),
576 message: other_error.to_string(),
577 }),
578 }
579}
580
581#[cfg(test)]
582mod test {
583 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
584
585 use jsonwebtoken::{encode, EncodingKey, Header};
586 use serde::Serialize;
587 use time::{Duration, OffsetDateTime};
588 use tokio_util::sync::CancellationToken;
589
590 use crate::configuration::{
591 expand_path_from_env_or_default,
592 pkce::tests::PkceTestServerHarness,
593 secrets::{
594 SecretAccessToken, SecretRefreshToken, Secrets, SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR,
595 },
596 settings::{Settings, SETTINGS_PATH_VAR},
597 tokens::TokenRefresher,
598 AuthServer, ClientConfiguration, OAuthSession, RefreshToken, API_URL_VAR,
599 DEFAULT_QUILC_URL, GRPC_API_URL_VAR, QUILC_URL_VAR, QVM_URL_VAR,
600 };
601
602 use super::{settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials};
603
604 #[test]
605 fn expands_env_var() {
606 figment::Jail::expect_with(|jail| {
607 let dir = jail.create_dir("~/blah/blah/")?;
608 jail.create_file(dir.join("file.toml"), "")?;
609 jail.set_env("SOME_PATH", "blah/blah");
610 jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
611 let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
612 assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
613
614 Ok(())
615 });
616 }
617
618 #[test]
619 fn uses_env_var_overrides() {
620 figment::Jail::expect_with(|jail| {
621 let quilc_url = "tcp://quilc:5555";
622 let qvm_url = "http://qvm:5000";
623 let grpc_url = "http://grpc:80";
624 let api_url = "http://api:80";
625
626 jail.set_env(QUILC_URL_VAR, quilc_url);
627 jail.set_env(QVM_URL_VAR, qvm_url);
628 jail.set_env(API_URL_VAR, api_url);
629 jail.set_env(GRPC_API_URL_VAR, grpc_url);
630
631 let config = ClientConfiguration::new(
632 Settings::default(),
633 Secrets::default(),
634 Some("default".to_string()),
635 )
636 .expect("Should be able to build default config.");
637
638 assert_eq!(config.quilc_url, quilc_url);
639 assert_eq!(config.qvm_url, qvm_url);
640 assert_eq!(config.grpc_api_url, grpc_url);
641
642 Ok(())
643 });
644 }
645
646 #[tokio::test]
647 async fn test_default_uses_env_var_overrides() {
648 figment::Jail::expect_with(|jail| {
649 let quilc_url = "quilc_url";
650 let qvm_url = "qvm_url";
651 let grpc_url = "grpc_url";
652 let api_url = "api_url";
653
654 jail.set_env(QUILC_URL_VAR, quilc_url);
655 jail.set_env(QVM_URL_VAR, qvm_url);
656 jail.set_env(GRPC_API_URL_VAR, grpc_url);
657 jail.set_env(API_URL_VAR, api_url);
658
659 let config = ClientConfiguration::load_default().unwrap();
660 assert_eq!(config.quilc_url, quilc_url);
661 assert_eq!(config.qvm_url, qvm_url);
662 assert_eq!(config.grpc_api_url, grpc_url);
663 assert_eq!(config.api_url, api_url);
664
665 Ok(())
666 });
667 }
668
669 #[test]
670 fn test_default_loads_settings_with_partial_profile_applications() {
671 figment::Jail::expect_with(|jail| {
672 let directory = jail.directory();
673 let settings_file_name = "settings.toml";
674 let settings_file_path = directory.join(settings_file_name);
675
676 let quilc_url_env_var = "env-var://quilc.url/after";
677
678 let settings_file_contents = r#"
679default_profile_name = "default"
680
681[profiles]
682[profiles.default]
683api_url = ""
684auth_server_name = "default"
685credentials_name = "default"
686applications = {}
687
688[auth_servers]
689[auth_servers.default]
690client_id = ""
691issuer = ""
692"#;
693 jail.create_file(settings_file_name, settings_file_contents)
694 .expect("should create test settings.toml");
695
696 jail.set_env(
697 "QCS_SETTINGS_FILE_PATH",
698 settings_file_path
699 .to_str()
700 .expect("settings file path should be a string"),
701 );
702
703 let config = ClientConfiguration::load_default().unwrap();
705 assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
706
707 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
708
709 let config = ClientConfiguration::load_default().unwrap();
711 assert_eq!(config.quilc_url, quilc_url_env_var);
712
713 Ok(())
714 });
715 }
716
717 #[test]
718 fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
719 figment::Jail::expect_with(|jail| {
720 let directory = jail.directory();
721 let settings_file_name = "settings.toml";
722 let settings_file_path = directory.join(settings_file_name);
723
724 let quilc_url_settings_toml = "settings-toml://quilc.url";
725 let quilc_url_env_var = "env-var://quilc.url/after";
726
727 let settings_file_contents = format!(
728 r#"
729default_profile_name = "default"
730
731[profiles]
732[profiles.default]
733api_url = ""
734auth_server_name = "default"
735credentials_name = "default"
736applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
737
738[auth_servers]
739[auth_servers.default]
740client_id = ""
741issuer = ""
742"#
743 );
744
745 jail.create_file(settings_file_name, &settings_file_contents)
746 .expect("should create test settings.toml");
747
748 jail.set_env(
749 "QCS_SETTINGS_FILE_PATH",
750 settings_file_path
751 .to_str()
752 .expect("settings file path should be a string"),
753 );
754
755 let config = ClientConfiguration::load_default().unwrap();
757 assert_eq!(config.quilc_url, quilc_url_settings_toml);
758
759 jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
760
761 let config = ClientConfiguration::load_default().unwrap();
763 assert_eq!(config.quilc_url, quilc_url_env_var);
764
765 Ok(())
766 });
767 }
768
769 #[tokio::test]
770 async fn test_hydrate_access_token_on_load() {
771 let mut config = ClientConfiguration::builder().build().unwrap();
772 let access_token = "test_access_token";
773 figment::Jail::expect_with(|jail| {
774 let directory = jail.directory();
775 let settings_file_name = "settings.toml";
776 let settings_file_path = directory.join(settings_file_name);
777 let secrets_file_name = "secrets.toml";
778 let secrets_file_path = directory.join(secrets_file_name);
779
780 let settings_file_contents = r#"
781default_profile_name = "default"
782
783[profiles]
784[profiles.default]
785api_url = ""
786auth_server_name = "default"
787credentials_name = "default"
788
789[auth_servers]
790[auth_servers.default]
791client_id = ""
792issuer = ""
793"#;
794
795 let secrets_file_contents = format!(
796 r#"
797[credentials]
798[credentials.default]
799[credentials.default.token_payload]
800access_token = "{access_token}"
801expires_in = 3600
802id_token = "id_token"
803refresh_token = "refresh_token"
804scope = "offline_access openid profile email"
805token_type = "Bearer"
806"#
807 );
808
809 jail.create_file(settings_file_name, settings_file_contents)
810 .expect("should create test settings.toml");
811 jail.create_file(secrets_file_name, &secrets_file_contents)
812 .expect("should create test settings.toml");
813
814 jail.set_env(
815 "QCS_SETTINGS_FILE_PATH",
816 settings_file_path
817 .to_str()
818 .expect("settings file path should be a string"),
819 );
820 jail.set_env(
821 "QCS_SECRETS_FILE_PATH",
822 secrets_file_path
823 .to_str()
824 .expect("secrets file path should be a string"),
825 );
826
827 config = ClientConfiguration::load_default().unwrap();
828 Ok(())
829 });
830 assert_eq!(
831 config.get_access_token().await.unwrap().unwrap(),
832 SecretAccessToken::from(access_token)
833 );
834 }
835
836 #[derive(Clone, Debug, Serialize)]
837 struct Claims {
838 exp: i64,
839 iss: String,
840 sub: String,
841 }
842
843 impl Default for Claims {
844 fn default() -> Self {
845 Self {
846 exp: 0,
847 iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
848 sub: "qcs@rigetti.com".to_string(),
849 }
850 }
851 }
852
853 impl Claims {
854 fn new_valid() -> Self {
855 Self {
856 exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
857 ..Self::default()
858 }
859 }
860
861 fn new_expired() -> Self {
862 Self {
863 exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
864 ..Self::default()
865 }
866 }
867
868 fn to_encoded(&self) -> String {
869 encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
870 }
871
872 fn to_access_token(&self) -> SecretAccessToken {
873 SecretAccessToken::from(self.to_encoded())
874 }
875 }
876
877 #[test]
878 fn test_valid_token() {
879 let valid_token = Claims::new_valid().to_access_token();
880 let tokens = OAuthSession::from_refresh_token(
881 RefreshToken::new(SecretRefreshToken::from("unused")),
882 AuthServer::default(),
883 Some(valid_token.clone()),
884 );
885 assert_eq!(
886 tokens
887 .validate()
888 .expect("Token should not fail validation."),
889 valid_token
890 );
891 }
892
893 #[test]
894 fn test_expired_token() {
895 let invalid_token = Claims::new_expired().to_access_token();
896 let tokens = OAuthSession::from_refresh_token(
897 RefreshToken::new(SecretRefreshToken::from("unused")),
898 AuthServer::default(),
899 Some(invalid_token),
900 );
901 assert!(tokens.validate().is_err());
902 }
903
904 #[test]
905 fn test_client_credentials_without_access_token() {
906 let tokens = OAuthSession::from_client_credentials(
907 ClientCredentials::new("client_id", "client_secret"),
908 AuthServer::default(),
909 None,
910 );
911 assert!(tokens.validate().is_err());
912 }
913
914 #[tokio::test]
915 async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
916 let access_token = Claims::new_valid().to_encoded();
917 let mut config = ClientConfiguration::builder().build().unwrap();
918 figment::Jail::expect_with(|jail| {
919 let directory = jail.directory();
920 let settings_file_name = "settings.toml";
921 let settings_file_path = directory.join(settings_file_name);
922 let secrets_file_name = "secrets.toml";
923 let secrets_file_path = directory.join(secrets_file_name);
924
925 let settings_file_contents = r#"
926default_profile_name = "default"
927
928[profiles]
929[profiles.default]
930api_url = ""
931auth_server_name = "default"
932credentials_name = "default"
933
934[auth_servers]
935[auth_servers.default]
936client_id = ""
937issuer = ""
938"#;
939
940 let secrets_file_contents = format!(
942 r#"
943[credentials]
944[credentials.default]
945[credentials.default.token_payload]
946access_token = "{access_token}"
947expires_in = 3600
948id_token = "id_token"
949scope = "offline_access openid profile email"
950token_type = "Bearer"
951"#
952 );
953
954 jail.create_file(settings_file_name, settings_file_contents)
955 .expect("should create test settings.toml");
956 jail.create_file(secrets_file_name, &secrets_file_contents)
957 .expect("should create test secrets.toml");
958
959 jail.set_env(
960 "QCS_SETTINGS_FILE_PATH",
961 settings_file_path
962 .to_str()
963 .expect("settings file path should be a string"),
964 );
965 jail.set_env(
966 "QCS_SECRETS_FILE_PATH",
967 secrets_file_path
968 .to_str()
969 .expect("secrets file path should be a string"),
970 );
971
972 config = ClientConfiguration::load_default().unwrap();
973 Ok(())
974 });
975
976 assert_eq!(
977 config.get_bearer_access_token().await.unwrap(),
978 SecretAccessToken::from(access_token)
979 );
980 }
981
982 #[test]
984 #[serial_test::serial(oauth2_test_server)]
985 fn test_pkce_flow_persists_token() {
986 let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
989
990 let PkceTestServerHarness {
991 server,
992 client,
993 discovery: _,
994 redirect_port: _,
995 } = runtime.block_on(PkceTestServerHarness::new());
996
997 let client_id = client.client_id;
998 let issuer = server.issuer().to_string();
999
1000 figment::Jail::expect_with(|jail| {
1001 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
1004
1005 let directory = jail.directory();
1006 let settings_file_name = "settings.toml";
1007 let settings_file_path = directory.join(settings_file_name);
1008
1009 let secrets_file_name = "secrets.toml";
1010 let secrets_file_path = directory.join(secrets_file_name);
1011
1012 let settings_file_contents = format!(
1013 r#"
1014default_profile_name = "default"
1015
1016[profiles]
1017[profiles.default]
1018api_url = ""
1019auth_server_name = "default"
1020credentials_name = "default"
1021
1022[auth_servers]
1023[auth_servers.default]
1024client_id = "{client_id}"
1025issuer = "{issuer}"
1026"#
1027 );
1028
1029 let secrets_file_contents = r#"
1030[credentials]
1031[credentials.default]
1032[credentials.default.token_payload]
1033access_token = ""
1034"#;
1035
1036 jail.create_file(settings_file_name, &settings_file_contents)
1037 .expect("should create test settings.toml");
1038
1039 jail.set_env(
1040 SETTINGS_PATH_VAR,
1041 settings_file_path
1042 .to_str()
1043 .expect("settings file path should be a string"),
1044 );
1045
1046 jail.create_file(secrets_file_name, secrets_file_contents)
1047 .expect("should create test secrets.toml");
1048
1049 jail.set_env(
1050 SECRETS_PATH_VAR,
1051 secrets_file_path
1052 .to_str()
1053 .expect("secrets file path should be a string"),
1054 );
1055
1056 runtime.block_on(async {
1058 let cancel_token = CancellationToken::new();
1059 let configuration = ClientConfiguration::load_with_login(cancel_token, None)
1061 .await
1062 .expect("should load configuration");
1063 let oauth_session = configuration.refresh().await.expect("should refresh");
1064 let token = oauth_session.validate().expect("token should be valid");
1065
1066 let configuration =
1068 ClientConfiguration::load_default().expect("should load configuration");
1069
1070 let oauth_session = configuration
1071 .oauth_session()
1072 .await
1073 .expect("should get oauth session");
1074
1075 let token_payload = Secrets::load_from_path(&secrets_file_path)
1076 .expect("should load secrets")
1077 .credentials
1078 .remove("default")
1079 .expect("should get default credentials")
1080 .token_payload
1081 .expect("should get token payload");
1082
1083 assert_eq!(
1084 token,
1085 oauth_session.validate().expect("should contain token"),
1086 "session: {oauth_session:?}, token_payload: {token_payload:?}",
1087 );
1088 assert_eq!(
1089 token_payload.access_token,
1090 Some(token),
1091 "session: {oauth_session:?}, token_payload: {token_payload:?}"
1092 );
1093 assert_ne!(
1094 token_payload.refresh_token, None,
1095 "session: {oauth_session:?}, token_payload: {token_payload:?}"
1096 );
1097 });
1098
1099 Ok(())
1100 });
1101
1102 drop(server);
1103 }
1104}