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