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