1use std::{pin::Pin, sync::Arc};
3
4use futures::Future;
5use jsonwebtoken::{Algorithm, DecodingKey, Validation};
6use oauth2::TokenResponse;
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use tokio::sync::{Mutex, Notify, RwLock};
10use tokio_util::sync::CancellationToken;
11
12#[cfg(feature = "stubs")]
13use pyo3_stub_gen::derive::gen_stub_pyclass;
14
15use super::{
16 ClientConfiguration, ConfigSource, TokenError, oidc, secrets::Secrets, settings::AuthServer,
17};
18use crate::configuration::{
19 error::{DiscoveryError, WriteError},
20 pkce::{PkceLoginError, PkceLoginRequest, RedirectBinding, pkce_login},
21 secrets::{Credential, SecretAccessToken, SecretRefreshToken, TokenPayload},
22};
23#[cfg(feature = "tracing-config")]
24use crate::tracing_configuration::TracingConfiguration;
25#[cfg(feature = "tracing")]
26use urlpattern::UrlPatternMatchInput;
27
28pub use super::secret_string::ClientSecret;
29
30#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
33#[cfg_attr(
34 feature = "python",
35 pyo3::pyclass(
36 eq,
37 get_all,
38 set_all,
39 module = "qcs_api_client_common._qcs_api_client_common.configuration",
40 from_py_object
41 )
42)]
43pub struct RefreshToken {
44 pub refresh_token: SecretRefreshToken,
46}
47
48impl RefreshToken {
49 #[must_use]
51 pub const fn new(refresh_token: SecretRefreshToken) -> Self {
52 Self { refresh_token }
53 }
54
55 pub async fn request_access_token(
62 &mut self,
63 auth_server: &AuthServer,
64 ) -> Result<SecretAccessToken, TokenError> {
65 if self.refresh_token.is_empty() {
66 return Err(TokenError::NoRefreshToken);
67 }
68
69 let client = default_http_client()?;
70 let token_url = oidc::fetch_discovery(&client, &auth_server.issuer)
71 .await?
72 .token_endpoint;
73 let data = TokenRefreshRequest::new(&auth_server.client_id, self.refresh_token.secret());
74 let resp = client.post(token_url).form(&data).send().await?;
75
76 if let Err(error) = resp.error_for_status_ref() {
82 #[cfg(feature = "tracing")]
83 {
84 let status = resp.status();
85 let body = resp.text().await.unwrap_or_default();
86 tracing::warn!(
87 %status,
88 %body,
89 "the auth server rejected the refresh token request"
90 );
91 }
92 return Err(error.into());
93 }
94
95 let RefreshTokenResponse {
96 access_token,
97 refresh_token,
98 } = resp.json().await?;
99
100 if let Some(refresh_token) = refresh_token {
101 self.refresh_token = refresh_token;
102 }
103 Ok(access_token)
104 }
105}
106
107#[derive(Deserialize, Debug, Serialize)]
108pub(super) struct ClientCredentialsResponse {
109 pub(super) access_token: SecretAccessToken,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
115#[cfg_attr(
116 feature = "python",
117 pyo3::pyclass(
118 eq,
119 get_all,
120 frozen,
121 module = "qcs_api_client_common._qcs_api_client_common.configuration",
122 from_py_object
123 )
124)]
125pub struct ClientCredentials {
126 pub client_id: String,
128 pub client_secret: ClientSecret,
130}
131
132impl ClientCredentials {
133 #[must_use]
134 pub fn new(client_id: impl Into<String>, client_secret: impl Into<ClientSecret>) -> Self {
136 Self {
137 client_id: client_id.into(),
138 client_secret: client_secret.into(),
139 }
140 }
141
142 #[must_use]
144 pub fn client_id(&self) -> &str {
145 &self.client_id
146 }
147
148 #[must_use]
150 pub const fn client_secret(&self) -> &ClientSecret {
151 &self.client_secret
152 }
153
154 pub async fn request_access_token(
160 &self,
161 auth_server: &AuthServer,
162 ) -> Result<SecretAccessToken, TokenError> {
163 let request = ClientCredentialsRequest::new(None);
164 let client = default_http_client()?;
165
166 let url = oidc::fetch_discovery(&client, &auth_server.issuer)
167 .await?
168 .token_endpoint;
169 let ready_to_send = client
170 .post(url)
171 .basic_auth(&self.client_id, Some(&self.client_secret.secret()))
172 .form(&request);
173 let response = ready_to_send.send().await?;
174
175 response.error_for_status_ref()?;
176
177 let ClientCredentialsResponse { access_token } = response.json().await?;
178 Ok(access_token)
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
183#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
184#[cfg_attr(
185 feature = "python",
186 pyo3::pyclass(
187 eq,
188 get_all,
189 frozen,
190 module = "qcs_api_client_common._qcs_api_client_common.configuration",
191 from_py_object
192 )
193)]
194pub struct AuthTokens {
199 pub access_token: SecretAccessToken,
201 pub refresh_token: Option<RefreshToken>,
203}
204
205#[derive(Debug, thiserror::Error)]
207pub enum LoginError {
208 #[error(transparent)]
210 Pkce(#[from] PkceLoginError),
211 #[error(transparent)]
213 Discovery(#[from] DiscoveryError),
214 #[error(transparent)]
216 Request(#[from] qcs_dependencies_client::reqwest::Error),
217}
218
219impl AuthTokens {
220 pub async fn interactive_login(
226 cancel_token: CancellationToken,
227 auth_server: &AuthServer,
228 ) -> Result<Self, LoginError> {
229 Self::interactive_login_with_redirect(cancel_token, auth_server, RedirectBinding::default())
230 .await
231 }
232
233 pub(crate) async fn interactive_login_with_redirect(
235 cancel_token: CancellationToken,
236 auth_server: &AuthServer,
237 redirect: RedirectBinding,
238 ) -> Result<Self, LoginError> {
239 let issuer = auth_server.issuer.clone();
240
241 let client = default_http_client()?;
242 let discovery = oidc::fetch_discovery(&client, &issuer).await?;
243
244 let response = pkce_login(
245 cancel_token,
246 PkceLoginRequest {
247 client_id: auth_server.client_id.clone(),
248 redirect,
249 discovery,
250 scopes: auth_server.scopes.clone(),
251 },
252 )
253 .await?;
254
255 Ok(Self {
256 access_token: SecretAccessToken::from(response.access_token().secret().clone()),
257 refresh_token: response
258 .refresh_token()
259 .map(|rt| RefreshToken::new(SecretRefreshToken::from(rt.secret().clone()))),
260 })
261 }
262
263 pub async fn request_access_token(
269 &mut self,
270 auth_server: &AuthServer,
271 ) -> Result<SecretAccessToken, TokenError> {
272 if insecure_validate_token_exp(&self.access_token).is_ok() {
273 return Ok(self.access_token.clone());
274 }
275
276 if let Some(refresh_token) = &mut self.refresh_token {
277 let access_token = refresh_token.request_access_token(auth_server).await?;
278 self.access_token.clone_from(&access_token);
279 return Ok(access_token);
280 }
281
282 Err(TokenError::NoRefreshToken)
283 }
284}
285
286impl From<AuthTokens> for Credential {
287 fn from(value: AuthTokens) -> Self {
288 let mut token_payload = TokenPayload::default();
289 token_payload.access_token = Some(value.access_token);
290 token_payload.refresh_token = value.refresh_token.map(|rt| rt.refresh_token);
291
292 Self::TokenPayload(token_payload)
293 }
294}
295
296#[derive(Clone)]
297#[cfg_attr(feature = "python", derive(pyo3::FromPyObject, pyo3::IntoPyObject))]
298pub enum OAuthGrant {
301 RefreshToken(RefreshToken),
303 ClientCredentials(ClientCredentials),
305 ExternallyManaged(ExternallyManaged),
307 InteractiveLogin(AuthTokens),
311}
312
313impl From<ExternallyManaged> for OAuthGrant {
314 fn from(v: ExternallyManaged) -> Self {
315 Self::ExternallyManaged(v)
316 }
317}
318
319impl From<ClientCredentials> for OAuthGrant {
320 fn from(v: ClientCredentials) -> Self {
321 Self::ClientCredentials(v)
322 }
323}
324
325impl From<RefreshToken> for OAuthGrant {
326 fn from(v: RefreshToken) -> Self {
327 Self::RefreshToken(v)
328 }
329}
330
331impl From<AuthTokens> for OAuthGrant {
332 fn from(v: AuthTokens) -> Self {
333 Self::InteractiveLogin(v)
334 }
335}
336
337impl OAuthGrant {
338 async fn request_access_token(
340 &mut self,
341 auth_server: &AuthServer,
342 ) -> Result<SecretAccessToken, TokenError> {
343 match self {
344 Self::RefreshToken(tokens) => tokens.request_access_token(auth_server).await,
345 Self::ClientCredentials(tokens) => tokens.request_access_token(auth_server).await,
346 Self::ExternallyManaged(tokens) => tokens
347 .request_access_token(auth_server)
348 .await
349 .map_err(|e| TokenError::ExternallyManaged(e.to_string())),
350 Self::InteractiveLogin(tokens) => tokens.request_access_token(auth_server).await,
351 }
352 }
353}
354
355impl std::fmt::Debug for OAuthGrant {
356 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357 match self {
358 Self::RefreshToken(_) => f.write_str("RefreshToken"),
359 Self::ClientCredentials(_) => f.write_str("ClientCredentials"),
360 Self::ExternallyManaged(_) => f.write_str("ExternallyManaged"),
361 Self::InteractiveLogin(_) => f.write_str("InteractiveLogin"),
362 }
363 }
364}
365
366#[derive(Clone)]
378#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
379#[cfg_attr(
380 feature = "python",
381 pyo3::pyclass(
382 module = "qcs_api_client_common._qcs_api_client_common.configuration",
383 frozen,
384 get_all,
385 from_py_object
386 )
387)]
388pub struct OAuthSession {
389 payload: OAuthGrant,
391 access_token: Option<SecretAccessToken>,
393 auth_server: AuthServer,
395}
396
397impl OAuthSession {
398 #[must_use]
403 pub const fn new(
404 payload: OAuthGrant,
405 auth_server: AuthServer,
406 access_token: Option<SecretAccessToken>,
407 ) -> Self {
408 Self {
409 payload,
410 access_token,
411 auth_server,
412 }
413 }
414
415 #[must_use]
420 pub const fn from_externally_managed(
421 tokens: ExternallyManaged,
422 auth_server: AuthServer,
423 access_token: Option<SecretAccessToken>,
424 ) -> Self {
425 Self::new(
426 OAuthGrant::ExternallyManaged(tokens),
427 auth_server,
428 access_token,
429 )
430 }
431
432 #[must_use]
437 pub const fn from_refresh_token(
438 tokens: RefreshToken,
439 auth_server: AuthServer,
440 access_token: Option<SecretAccessToken>,
441 ) -> Self {
442 Self::new(OAuthGrant::RefreshToken(tokens), auth_server, access_token)
443 }
444
445 #[must_use]
450 pub const fn from_client_credentials(
451 tokens: ClientCredentials,
452 auth_server: AuthServer,
453 access_token: Option<SecretAccessToken>,
454 ) -> Self {
455 Self::new(
456 OAuthGrant::ClientCredentials(tokens),
457 auth_server,
458 access_token,
459 )
460 }
461
462 #[must_use]
467 pub const fn from_interactive_login(
468 tokens: AuthTokens,
469 auth_server: AuthServer,
470 access_token: Option<SecretAccessToken>,
471 ) -> Self {
472 Self::new(
473 OAuthGrant::InteractiveLogin(tokens),
474 auth_server,
475 access_token,
476 )
477 }
478
479 pub fn access_token(&self) -> Result<&SecretAccessToken, TokenError> {
488 self.access_token.as_ref().ok_or(TokenError::NoAccessToken)
489 }
490
491 #[must_use]
493 pub const fn payload(&self) -> &OAuthGrant {
494 &self.payload
495 }
496
497 #[allow(clippy::missing_panics_doc)]
503 pub async fn request_access_token(&mut self) -> Result<&SecretAccessToken, TokenError> {
504 let access_token = self.payload.request_access_token(&self.auth_server).await?;
505 Ok(self.access_token.insert(access_token))
506 }
507
508 #[must_use]
510 pub const fn auth_server(&self) -> &AuthServer {
511 &self.auth_server
512 }
513
514 pub fn validate(&self) -> Result<SecretAccessToken, TokenError> {
522 let access_token = self.access_token()?;
523 insecure_validate_token_exp(access_token)?;
524 Ok(access_token.clone())
525 }
526}
527
528pub(crate) fn insecure_validate_token_exp(
532 access_token: &SecretAccessToken,
533) -> Result<(), TokenError> {
534 let placeholder_key = DecodingKey::from_secret(&[]);
535 let mut validation = Validation::new(Algorithm::RS256);
536 validation.validate_exp = true;
537 validation.leeway = 60;
538 validation.validate_aud = false;
539 validation.insecure_disable_signature_validation();
540
541 jsonwebtoken::decode::<toml::Value>(access_token.secret(), &placeholder_key, &validation)
542 .map(|_| ())
543 .map_err(TokenError::InvalidAccessToken)
544}
545
546impl std::fmt::Debug for OAuthSession {
547 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548 let token_populated = if self.access_token.is_some() {
549 Some(())
550 } else {
551 None
552 };
553 f.debug_struct("OAuthSession")
554 .field("payload", &self.payload)
555 .field("access_token", &token_populated)
556 .field("auth_server", &self.auth_server)
557 .finish()
558 }
559}
560
561pub(crate) async fn persist_oauth_session(
577 oauth_session: &OAuthSession,
578 source: &ConfigSource,
579 credentials_name: &str,
580) -> Result<(), WriteError> {
581 let ConfigSource::File {
582 settings_path: _,
583 secrets_path,
584 } = source
585 else {
586 return Ok(());
587 };
588
589 let refresh_token = match &oauth_session.payload {
593 OAuthGrant::InteractiveLogin(payload) => {
594 payload.refresh_token.as_ref().map(|rt| &rt.refresh_token)
595 }
596 OAuthGrant::RefreshToken(payload) => Some(&payload.refresh_token),
597 OAuthGrant::ExternallyManaged(_) | OAuthGrant::ClientCredentials(_) => return Ok(()),
598 };
599
600 if Secrets::is_read_only(secrets_path).await? {
601 #[cfg(feature = "tracing")]
602 tracing::debug!(
603 "Skipping write of refreshed tokens to read-only secrets file: {:?}",
604 secrets_path
605 );
606 return Ok(());
607 }
608
609 let Ok(access_token) = oauth_session.access_token() else {
612 return Ok(());
613 };
614
615 let now = OffsetDateTime::now_utc();
616 Secrets::write_tokens(
617 secrets_path,
618 credentials_name,
619 refresh_token,
620 access_token,
621 now,
622 )
623 .await
624}
625
626#[derive(Clone, Debug)]
628#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
629#[cfg_attr(
630 feature = "python",
631 pyo3::pyclass(
632 module = "qcs_api_client_common._qcs_api_client_common.configuration",
633 frozen,
634 from_py_object
635 )
636)]
637pub struct TokenDispatcher {
638 lock: Arc<RwLock<OAuthSession>>,
639 refreshing: Arc<Mutex<bool>>,
640 notify_refreshed: Arc<Notify>,
641}
642
643impl From<OAuthSession> for TokenDispatcher {
644 fn from(value: OAuthSession) -> Self {
645 Self {
646 lock: Arc::new(RwLock::new(value)),
647 refreshing: Arc::new(Mutex::new(false)),
648 notify_refreshed: Arc::new(Notify::new()),
649 }
650 }
651}
652
653impl TokenDispatcher {
654 pub async fn use_tokens<F, O>(&self, f: F) -> O
664 where
665 F: FnOnce(&OAuthSession) -> O + Send,
666 {
667 let tokens = self.lock.read().await;
668 f(&tokens)
669 }
670
671 #[must_use]
673 pub async fn tokens(&self) -> OAuthSession {
674 self.use_tokens(Clone::clone).await
675 }
676
677 pub async fn refresh(
683 &self,
684 source: &ConfigSource,
685 credentials_name: &str,
686 ) -> Result<OAuthSession, TokenError> {
687 self.managed_refresh(Self::perform_refresh, source, credentials_name)
688 .await
689 }
690
691 pub async fn validate(&self) -> Result<SecretAccessToken, TokenError> {
699 self.use_tokens(OAuthSession::validate).await
700 }
701
702 async fn managed_refresh<F, Fut>(
705 &self,
706 refresh_fn: F,
707 source: &ConfigSource,
708 credentials_name: &str,
709 ) -> Result<OAuthSession, TokenError>
710 where
711 F: FnOnce(Arc<RwLock<OAuthSession>>) -> Fut + Send,
712 Fut: Future<Output = Result<OAuthSession, TokenError>> + Send,
713 {
714 let mut is_refreshing = self.refreshing.lock().await;
715
716 if *is_refreshing {
717 drop(is_refreshing);
718 self.notify_refreshed.notified().await;
719 return Ok(self.tokens().await);
720 }
721
722 *is_refreshing = true;
723 drop(is_refreshing);
724
725 let oauth_session = refresh_fn(self.lock.clone()).await?;
726
727 let write_result = persist_oauth_session(&oauth_session, source, credentials_name).await;
728
729 *self.refreshing.lock().await = false;
731 self.notify_refreshed.notify_waiters();
732
733 if let Err(error) = write_result {
735 return Err(TokenError::Write {
736 error,
737 oauth_session: Box::new(oauth_session),
738 });
739 }
740
741 Ok(oauth_session)
742 }
743
744 async fn perform_refresh(lock: Arc<RwLock<OAuthSession>>) -> Result<OAuthSession, TokenError> {
751 let mut credentials = lock.write().await;
752 credentials.request_access_token().await?;
753 Ok(credentials.clone())
754 }
755}
756
757pub(crate) type RefreshResult =
758 Pin<Box<dyn Future<Output = Result<String, Box<dyn std::error::Error + Send + Sync>>> + Send>>;
759
760pub type RefreshFunction = Box<dyn (Fn(AuthServer) -> RefreshResult) + Send + Sync>;
762
763#[derive(Clone)]
768#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
769#[cfg_attr(
770 feature = "python",
771 pyo3::pyclass(
772 module = "qcs_api_client_common._qcs_api_client_common.configuration",
773 frozen,
774 from_py_object
775 )
776)]
777pub struct ExternallyManaged {
778 refresh_function: Arc<RefreshFunction>,
779}
780
781impl ExternallyManaged {
782 pub fn new(
807 refresh_function: impl Fn(AuthServer) -> RefreshResult + Send + Sync + 'static,
808 ) -> Self {
809 Self {
810 refresh_function: Arc::new(Box::new(refresh_function)),
811 }
812 }
813
814 pub fn from_async<F, Fut>(refresh_function: F) -> Self
847 where
848 F: Fn(AuthServer) -> Fut + Send + Sync + 'static,
849 Fut: Future<Output = Result<String, Box<dyn std::error::Error + Send + Sync>>>
850 + Send
851 + 'static,
852 {
853 Self {
854 refresh_function: Arc::new(Box::new(move |auth_server| {
855 Box::pin(refresh_function(auth_server))
856 })),
857 }
858 }
859
860 pub fn from_sync(
891 refresh_function: impl Fn(
892 AuthServer,
893 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>>
894 + Send
895 + Sync
896 + 'static,
897 ) -> Self {
898 Self {
899 refresh_function: Arc::new(Box::new(move |auth_server| {
900 let result = refresh_function(auth_server);
901 Box::pin(async move { result })
902 })),
903 }
904 }
905
906 pub async fn request_access_token(
912 &self,
913 auth_server: &AuthServer,
914 ) -> Result<SecretAccessToken, Box<dyn std::error::Error + Send + Sync>> {
915 (self.refresh_function)(auth_server.clone())
916 .await
917 .map(SecretAccessToken::from)
918 }
919}
920
921impl std::fmt::Debug for ExternallyManaged {
922 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
923 f.debug_struct("ExternallyManaged")
924 .field(
925 "refresh_function",
926 &"Fn() -> Pin<Box<dyn Future<Output = Result<String, TokenError>> + Send>>",
927 )
928 .finish()
929 }
930}
931
932#[derive(Debug, Serialize, Deserialize)]
933pub(super) struct TokenRefreshRequest<'a> {
934 grant_type: &'static str,
935 client_id: &'a str,
936 refresh_token: &'a str,
937}
938
939impl<'a> TokenRefreshRequest<'a> {
940 pub(super) const fn new(client_id: &'a str, refresh_token: &'a str) -> Self {
941 Self {
942 grant_type: "refresh_token",
943 client_id,
944 refresh_token,
945 }
946 }
947}
948
949#[derive(Debug, Serialize, Deserialize)]
950pub(super) struct ClientCredentialsRequest {
951 grant_type: &'static str,
952 scope: Option<&'static str>,
953}
954
955impl ClientCredentialsRequest {
956 pub(super) const fn new(scope: Option<&'static str>) -> Self {
957 Self {
958 grant_type: "client_credentials",
959 scope,
960 }
961 }
962}
963
964#[derive(Deserialize, Debug, Serialize)]
965pub(super) struct RefreshTokenResponse {
966 pub(super) refresh_token: Option<SecretRefreshToken>,
967 pub(super) access_token: SecretAccessToken,
968}
969
970#[async_trait::async_trait]
972pub trait TokenRefresher: Clone + std::fmt::Debug + Send {
973 type Error;
976
977 async fn validated_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
979
980 async fn get_access_token(&self) -> Result<Option<SecretAccessToken>, Self::Error>;
982
983 async fn refresh_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
985
986 #[cfg(feature = "tracing")]
988 fn base_url(&self) -> &str;
989
990 #[cfg(feature = "tracing-config")]
992 fn tracing_configuration(&self) -> Option<&TracingConfiguration>;
993
994 #[cfg(feature = "tracing")]
997 #[allow(clippy::needless_return)]
998 fn should_trace(&self, url: &UrlPatternMatchInput) -> bool {
999 #[cfg(not(feature = "tracing-config"))]
1000 {
1001 let _ = url;
1002 return true;
1003 }
1004
1005 #[cfg(feature = "tracing-config")]
1006 self.tracing_configuration()
1007 .is_none_or(|config| config.is_enabled(url))
1008 }
1009}
1010
1011#[async_trait::async_trait]
1012impl TokenRefresher for ClientConfiguration {
1013 type Error = TokenError;
1014
1015 async fn validated_access_token(&self) -> Result<SecretAccessToken, Self::Error> {
1016 self.get_bearer_access_token().await
1017 }
1018
1019 async fn refresh_access_token(&self) -> Result<SecretAccessToken, Self::Error> {
1020 match self.refresh().await {
1021 Ok(session) => Ok(session.access_token()?.clone()),
1022 Err(TokenError::Write {
1023 error: _error,
1024 oauth_session,
1025 }) => {
1026 #[cfg(feature = "tracing")]
1028 tracing::warn!(
1029 "Token refresh succeeded but failed to persist: {_error}. Returning access token from error.",
1030 );
1031 Ok(oauth_session.access_token()?.clone())
1032 }
1033 Err(e) => Err(e),
1034 }
1035 }
1036
1037 async fn get_access_token(&self) -> Result<Option<SecretAccessToken>, Self::Error> {
1038 Ok(Some(self.oauth_session().await?.access_token()?.clone()))
1039 }
1040
1041 #[cfg(feature = "tracing")]
1042 fn base_url(&self) -> &str {
1043 &self.grpc_api_url
1044 }
1045
1046 #[cfg(feature = "tracing-config")]
1047 fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
1048 self.tracing_configuration.as_ref()
1049 }
1050}
1051
1052pub(super) fn default_http_client()
1054-> Result<qcs_dependencies_client::reqwest::Client, qcs_dependencies_client::reqwest::Error> {
1055 qcs_dependencies_client::reqwest::Client::builder()
1056 .timeout(std::time::Duration::from_secs(10))
1057 .build()
1058}
1059
1060#[cfg(test)]
1061mod test {
1062 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
1063
1064 use std::time::Duration;
1065
1066 use super::*;
1067 use httpmock::prelude::*;
1068 use rstest::rstest;
1069 use time::format_description::well_known::Rfc3339;
1070 use tokio::time::Instant;
1071 use toml_edit::DocumentMut;
1072
1073 #[tokio::test]
1074 async fn test_tokens_blocked_during_refresh() {
1075 let mock_server = MockServer::start_async().await;
1076
1077 let oidc_mock = mock_server
1078 .mock_async(|when, then| {
1079 when.method(GET).path("/.well-known/openid-configuration");
1080 then.status(200)
1081 .json_body_obj(&oidc::Discovery::new_for_test(
1082 mock_server.base_url().parse().unwrap(),
1083 ));
1084 })
1085 .await;
1086
1087 let issuer_mock = mock_server
1088 .mock_async(|when, then| {
1089 when.method(POST).path("/v1/token");
1090
1091 then.status(200)
1092 .delay(Duration::from_secs(3))
1093 .json_body_obj(&RefreshTokenResponse {
1094 access_token: SecretAccessToken::from("new_access"),
1095 refresh_token: Some(SecretRefreshToken::from("new_refresh")),
1096 });
1097 })
1098 .await;
1099
1100 let original_tokens = OAuthSession::from_refresh_token(
1101 RefreshToken::new(SecretRefreshToken::from("refresh")),
1102 AuthServer {
1103 client_id: "client_id".to_string(),
1104 issuer: mock_server.base_url(),
1105 scopes: None,
1106 },
1107 None,
1108 );
1109 let dispatcher: TokenDispatcher = original_tokens.clone().into();
1110 let dispatcher_clone1 = dispatcher.clone();
1111 let dispatcher_clone2 = dispatcher.clone();
1112
1113 let refresh_duration = Duration::from_secs(3);
1114
1115 let start_write = Instant::now();
1116 let write_future = tokio::spawn(async move {
1117 dispatcher_clone1
1118 .refresh(&ConfigSource::Default, "")
1119 .await
1120 .unwrap()
1121 });
1122
1123 let start_read = Instant::now();
1124 let read_future = tokio::spawn(async move { dispatcher_clone2.tokens().await });
1125
1126 let _ = write_future.await.unwrap();
1127 let read_result = read_future.await.unwrap();
1128
1129 let write_duration = start_write.elapsed();
1130 let read_duration = start_read.elapsed();
1131
1132 oidc_mock.assert_async().await;
1133 issuer_mock.assert_async().await;
1134
1135 assert!(
1136 write_duration >= refresh_duration,
1137 "Write operation did not take enough time"
1138 );
1139 assert!(
1140 read_duration >= refresh_duration,
1141 "Read operation was not blocked by the write operation"
1142 );
1143 assert_eq!(
1144 read_result.access_token.unwrap(),
1145 SecretAccessToken::from("new_access")
1146 );
1147 if let OAuthGrant::RefreshToken(payload) = read_result.payload {
1148 assert_eq!(
1149 payload.refresh_token,
1150 SecretRefreshToken::from("new_refresh")
1151 );
1152 } else {
1153 panic!(
1154 "Expected RefreshToken payload, got {:?}",
1155 read_result.payload
1156 );
1157 }
1158 }
1159
1160 #[tokio::test]
1164 async fn test_refresh_token_request_rejected_by_auth_server() {
1165 let mock_server = MockServer::start_async().await;
1166
1167 let oidc_mock = mock_server
1168 .mock_async(|when, then| {
1169 when.method(GET).path("/.well-known/openid-configuration");
1170 then.status(200)
1171 .json_body_obj(&oidc::Discovery::new_for_test(
1172 mock_server.base_url().parse().unwrap(),
1173 ));
1174 })
1175 .await;
1176
1177 let issuer_mock = mock_server
1178 .mock_async(|when, then| {
1179 when.method(POST).path("/v1/token");
1180 then.status(400).json_body_obj(&serde_json::json!({
1181 "error": "invalid_grant",
1182 "error_description": "Unknown or invalid refresh token.",
1183 }));
1184 })
1185 .await;
1186
1187 let mut refresh_token = RefreshToken::new(SecretRefreshToken::from("revoked_refresh"));
1188 let auth_server = AuthServer {
1189 client_id: "client_id".to_string(),
1190 issuer: mock_server.base_url(),
1191 scopes: None,
1192 };
1193
1194 let result = refresh_token.request_access_token(&auth_server).await;
1195
1196 oidc_mock.assert_async().await;
1197 issuer_mock.assert_async().await;
1198
1199 assert!(
1200 result.is_err(),
1201 "a rejected refresh token request should be an error, got {result:?}"
1202 );
1203 }
1204
1205 #[rstest]
1206 fn test_qcs_secrets_readonly(
1207 #[values(
1208 (Some("TRUE"), true),
1209 (Some("tRue"), true),
1210 (Some("true"), true),
1211 (Some("YES"), true),
1212 (Some("yEs"), true),
1213 (Some("yes"), true),
1214 (Some("1"), true),
1215 (Some("2"), false),
1216 (Some("other"), false),
1217 (Some(""), false),
1218 (None, false),
1219 )]
1220 read_only_values: (Option<&str>, bool),
1221 #[values(true, false)] read_only_perm: bool,
1222 ) {
1223 let (maybe_read_only_env, env_is_read_only) = read_only_values;
1224 let expected_update = !env_is_read_only && !read_only_perm;
1225 figment::Jail::expect_with(|jail| {
1226 let profile_name = "test";
1227 let initial_access_token = "initial_access_token";
1228 let initial_refresh_token = "initial_refresh_token";
1229
1230 let initial_secrets_file_contents = format!(
1231 r#"
1232[credentials]
1233[credentials.{profile_name}]
1234[credentials.{profile_name}.token_payload]
1235access_token = "{initial_access_token}"
1236expires_in = 3600
1237id_token = "id_token"
1238refresh_token = "{initial_refresh_token}"
1239scope = "offline_access openid profile email"
1240token_type = "Bearer"
1241updated_at = "2024-01-01T00:00:00Z"
1242"#
1243 );
1244
1245 jail.clear_env();
1247
1248 let secrets_path = "secrets.toml";
1250 jail.create_file(secrets_path, initial_secrets_file_contents.as_str())
1251 .expect("should create test secrets.toml");
1252
1253 if read_only_perm {
1254 let mut permissions = std::fs::metadata(secrets_path)
1255 .expect("Should be able to get file metadata")
1256 .permissions();
1257 permissions.set_readonly(true);
1258 std::fs::set_permissions(secrets_path, permissions)
1259 .expect("Should be able to set file permissions");
1260 }
1261
1262 let rt = tokio::runtime::Runtime::new().unwrap();
1263 rt.block_on(async {
1264 let mock_server = MockServer::start_async().await;
1265
1266 let oidc_mock = mock_server
1267 .mock_async(|when, then| {
1268 when.method(GET).path("/.well-known/openid-configuration");
1269 then.status(200)
1270 .json_body_obj(&oidc::Discovery::new_for_test(mock_server.base_url().parse().unwrap()));
1271 })
1272 .await;
1273
1274 let new_access_token = SecretAccessToken::from("new_access_token");
1276 let issuer_mock = mock_server
1277 .mock_async(|when, then| {
1278 when.method(POST).path("/v1/token");
1279 then.status(200).json_body_obj(&RefreshTokenResponse {
1280 access_token: new_access_token.clone(),
1281 refresh_token: Some(SecretRefreshToken::from(initial_refresh_token)),
1282 });
1283 })
1284 .await;
1285
1286 let original_tokens = OAuthSession::from_refresh_token(
1288 RefreshToken::new(SecretRefreshToken::from(initial_refresh_token)),
1289 AuthServer { client_id: "client_id".to_string(), issuer: mock_server.base_url(), scopes: None },
1290 Some(SecretAccessToken::from(initial_refresh_token)),
1291 );
1292 let dispatcher: TokenDispatcher = original_tokens.into();
1293
1294 jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
1296 jail.set_env("QCS_PROFILE_NAME", "test");
1297 if let Some(read_only_env) = maybe_read_only_env {
1298 jail.set_env("QCS_SECRETS_READ_ONLY", read_only_env);
1299 }
1300
1301 let before_refresh = OffsetDateTime::now_utc();
1302
1303 dispatcher
1304 .refresh(
1305 &ConfigSource::File {
1306 settings_path: "".into(),
1307 secrets_path: "secrets.toml".into(),
1308 },
1309 profile_name,
1310 )
1311 .await
1312 .unwrap();
1313
1314 oidc_mock.assert_async().await;
1315 issuer_mock.assert_async().await;
1316
1317 let content = std::fs::read_to_string("secrets.toml").unwrap();
1319 if !expected_update {
1320 assert!(
1321 content.eq(initial_secrets_file_contents.as_str()),
1322 "File should not be updated when QCS_SECRETS_READ_ONLY is set or file permissions are read-only"
1323 );
1324 return;
1325 }
1326
1327 let mut toml = std::fs::read_to_string(secrets_path)
1329 .unwrap()
1330 .parse::<DocumentMut>()
1331 .unwrap();
1332
1333 let token_payload = toml
1334 .get_mut("credentials")
1335 .and_then(|credentials| {
1336 credentials.get_mut(profile_name)?.get_mut("token_payload")
1337 })
1338 .expect("Should be able to get token_payload table");
1339
1340 let access_token = token_payload.get("access_token").unwrap().as_str().map(str::to_string).map(SecretAccessToken::from);
1341
1342 assert_eq!(
1343 access_token,
1344 Some(new_access_token)
1345 );
1346
1347 assert!(
1348 OffsetDateTime::parse(
1349 token_payload.get("updated_at").unwrap().as_str().unwrap(),
1350 &Rfc3339
1351 )
1352 .unwrap()
1353 > before_refresh
1354 );
1355
1356 let content = std::fs::read_to_string("secrets.toml").unwrap();
1357 assert!(
1358 content.contains("new_access_token"),
1359 "File should be updated with new access token when QCS_SECRETS_READ_ONLY is not set or is set but disabled, and file permissions allow writing"
1360 );
1361 });
1362 Ok(())
1363 });
1364 }
1365
1366 #[test]
1369 fn test_refresh_token_grant_persists_rotated_refresh_token() {
1370 let initial_refresh_token = "initial_refresh_token";
1371 let rotated_refresh_token = "rotated_refresh_token";
1372 let new_access_token = "new_access_token";
1373
1374 figment::Jail::expect_with(|jail| {
1375 jail.clear_env();
1376
1377 let secrets_path = "secrets.toml";
1378 let initial_secrets_file_contents = format!(
1379 r#"
1380[credentials]
1381[credentials.test]
1382[credentials.test.token_payload]
1383access_token = "initial_access_token"
1384refresh_token = "{initial_refresh_token}"
1385updated_at = "2024-01-01T00:00:00Z"
1386"#
1387 );
1388 jail.create_file(secrets_path, &initial_secrets_file_contents)
1389 .expect("should create test secrets.toml");
1390
1391 let rt = tokio::runtime::Runtime::new().unwrap();
1392 rt.block_on(async {
1393 let mock_server = MockServer::start_async().await;
1394 let oidc_mock = mock_server
1395 .mock_async(|when, then| {
1396 when.method(GET).path("/.well-known/openid-configuration");
1397 then.status(200)
1398 .json_body_obj(&oidc::Discovery::new_for_test(
1399 mock_server.base_url().parse().unwrap(),
1400 ));
1401 })
1402 .await;
1403 let issuer_mock = mock_server
1404 .mock_async(|when, then| {
1405 when.method(POST).path("/v1/token");
1406 then.status(200).json_body_obj(&RefreshTokenResponse {
1407 access_token: SecretAccessToken::from(new_access_token),
1408 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token)),
1409 });
1410 })
1411 .await;
1412
1413 let dispatcher: TokenDispatcher = OAuthSession::from_refresh_token(
1414 RefreshToken::new(SecretRefreshToken::from(initial_refresh_token)),
1415 AuthServer {
1416 client_id: "client_id".to_string(),
1417 issuer: mock_server.base_url(),
1418 scopes: None,
1419 },
1420 Some(SecretAccessToken::from("initial_access_token")),
1421 )
1422 .into();
1423
1424 dispatcher
1425 .refresh(
1426 &ConfigSource::File {
1427 settings_path: "".into(),
1428 secrets_path: secrets_path.into(),
1429 },
1430 "test",
1431 )
1432 .await
1433 .expect("refresh should succeed");
1434
1435 oidc_mock.assert_async().await;
1436 issuer_mock.assert_async().await;
1437 });
1438
1439 let Credential::TokenPayload(payload) = Secrets::load_from_path(&secrets_path.into())
1441 .expect("should load secrets")
1442 .credentials
1443 .remove("test")
1444 .expect("should have test credentials")
1445 else {
1446 panic!("expected a token payload credential");
1447 };
1448 assert_eq!(
1449 payload.refresh_token.unwrap(),
1450 SecretRefreshToken::from(rotated_refresh_token),
1451 "rotated refresh token should be persisted to the secrets file"
1452 );
1453 assert_eq!(
1454 payload.access_token.unwrap(),
1455 SecretAccessToken::from(new_access_token),
1456 "new access token should be persisted to the secrets file"
1457 );
1458
1459 Ok(())
1460 });
1461 }
1462
1463 #[test]
1464 fn test_auth_session_debug_fmt() {
1465 let session = OAuthSession {
1466 payload: OAuthGrant::ClientCredentials(ClientCredentials::new(
1467 "hidden_id",
1468 "hidden_secret",
1469 )),
1470 access_token: Some(SecretAccessToken::from("token")),
1471 auth_server: AuthServer {
1472 client_id: "some_id".into(),
1473 issuer: "some_url".into(),
1474 scopes: None,
1475 },
1476 };
1477
1478 assert_eq!(
1479 "OAuthSession { payload: ClientCredentials, access_token: Some(()), auth_server: AuthServer { client_id: \"some_id\", issuer: \"some_url\", scopes: None } }",
1480 &format!("{session:?}")
1481 );
1482 }
1483}