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, 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, 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(&auth_server.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 PkceFlow {
196 pub access_token: SecretAccessToken,
198 pub refresh_token: Option<RefreshToken>,
200}
201
202#[derive(Debug, thiserror::Error)]
204pub enum PkceFlowError {
205 #[error(transparent)]
207 PkceLogin(#[from] PkceLoginError),
208 #[error(transparent)]
210 Discovery(#[from] DiscoveryError),
211 #[error(transparent)]
213 Request(#[from] qcs_dependencies_client::reqwest::Error),
214}
215
216impl PkceFlow {
217 pub async fn new_login_flow(
223 cancel_token: CancellationToken,
224 auth_server: &AuthServer,
225 ) -> Result<Self, PkceFlowError> {
226 let issuer = auth_server.issuer.clone();
227
228 let client = default_http_client()?;
229 let discovery = oidc::fetch_discovery(&client, &issuer).await?;
230
231 let response = pkce_login(
232 cancel_token,
233 PkceLoginRequest {
234 client_id: auth_server.client_id.clone(),
235 redirect_port: None,
236 discovery,
237 scopes: auth_server.scopes.clone(),
238 },
239 )
240 .await?;
241
242 Ok(Self {
243 access_token: SecretAccessToken::from(response.access_token().secret().clone()),
244 refresh_token: response
245 .refresh_token()
246 .map(|rt| RefreshToken::new(SecretRefreshToken::from(rt.secret().clone()))),
247 })
248 }
249
250 pub async fn request_access_token(
256 &mut self,
257 auth_server: &AuthServer,
258 ) -> Result<SecretAccessToken, TokenError> {
259 if insecure_validate_token_exp(&self.access_token).is_ok() {
260 return Ok(self.access_token.clone());
261 }
262
263 if let Some(refresh_token) = &mut self.refresh_token {
264 let access_token = refresh_token.request_access_token(auth_server).await?;
265 self.access_token.clone_from(&access_token);
266 return Ok(access_token);
267 }
268
269 Err(TokenError::NoRefreshToken)
270 }
271}
272
273impl From<PkceFlow> for Credential {
274 fn from(value: PkceFlow) -> Self {
275 let mut token_payload = TokenPayload::default();
276 token_payload.access_token = Some(value.access_token);
277 token_payload.refresh_token = value.refresh_token.map(|rt| rt.refresh_token);
278
279 Self {
280 token_payload: Some(token_payload),
281 }
282 }
283}
284
285#[derive(Clone)]
286#[cfg_attr(feature = "python", derive(pyo3::FromPyObject, pyo3::IntoPyObject))]
287pub enum OAuthGrant {
290 RefreshToken(RefreshToken),
292 ClientCredentials(ClientCredentials),
294 ExternallyManaged(ExternallyManaged),
296 PkceFlow(PkceFlow),
298}
299
300impl From<ExternallyManaged> for OAuthGrant {
301 fn from(v: ExternallyManaged) -> Self {
302 Self::ExternallyManaged(v)
303 }
304}
305
306impl From<ClientCredentials> for OAuthGrant {
307 fn from(v: ClientCredentials) -> Self {
308 Self::ClientCredentials(v)
309 }
310}
311
312impl From<RefreshToken> for OAuthGrant {
313 fn from(v: RefreshToken) -> Self {
314 Self::RefreshToken(v)
315 }
316}
317
318impl From<PkceFlow> for OAuthGrant {
319 fn from(v: PkceFlow) -> Self {
320 Self::PkceFlow(v)
321 }
322}
323
324impl OAuthGrant {
325 async fn request_access_token(
327 &mut self,
328 auth_server: &AuthServer,
329 ) -> Result<SecretAccessToken, TokenError> {
330 match self {
331 Self::RefreshToken(tokens) => tokens.request_access_token(auth_server).await,
332 Self::ClientCredentials(tokens) => tokens.request_access_token(auth_server).await,
333 Self::ExternallyManaged(tokens) => tokens
334 .request_access_token(auth_server)
335 .await
336 .map_err(|e| TokenError::ExternallyManaged(e.to_string())),
337 Self::PkceFlow(tokens) => tokens.request_access_token(auth_server).await,
338 }
339 }
340}
341
342impl std::fmt::Debug for OAuthGrant {
343 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344 match self {
345 Self::RefreshToken(_) => f.write_str("RefreshToken"),
346 Self::ClientCredentials(_) => f.write_str("ClientCredentials"),
347 Self::ExternallyManaged(_) => f.write_str("ExternallyManaged"),
348 Self::PkceFlow(_) => f.write_str("PkceTokens"),
349 }
350 }
351}
352
353#[derive(Clone)]
365#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
366#[cfg_attr(
367 feature = "python",
368 pyo3::pyclass(
369 module = "qcs_api_client_common._qcs_api_client_common.configuration",
370 frozen,
371 get_all,
372 from_py_object
373 )
374)]
375pub struct OAuthSession {
376 payload: OAuthGrant,
378 access_token: Option<SecretAccessToken>,
380 auth_server: AuthServer,
382}
383
384impl OAuthSession {
385 #[must_use]
390 pub const fn new(
391 payload: OAuthGrant,
392 auth_server: AuthServer,
393 access_token: Option<SecretAccessToken>,
394 ) -> Self {
395 Self {
396 payload,
397 access_token,
398 auth_server,
399 }
400 }
401
402 #[must_use]
407 pub const fn from_externally_managed(
408 tokens: ExternallyManaged,
409 auth_server: AuthServer,
410 access_token: Option<SecretAccessToken>,
411 ) -> Self {
412 Self::new(
413 OAuthGrant::ExternallyManaged(tokens),
414 auth_server,
415 access_token,
416 )
417 }
418
419 #[must_use]
424 pub const fn from_refresh_token(
425 tokens: RefreshToken,
426 auth_server: AuthServer,
427 access_token: Option<SecretAccessToken>,
428 ) -> Self {
429 Self::new(OAuthGrant::RefreshToken(tokens), auth_server, access_token)
430 }
431
432 #[must_use]
437 pub const fn from_client_credentials(
438 tokens: ClientCredentials,
439 auth_server: AuthServer,
440 access_token: Option<SecretAccessToken>,
441 ) -> Self {
442 Self::new(
443 OAuthGrant::ClientCredentials(tokens),
444 auth_server,
445 access_token,
446 )
447 }
448
449 #[must_use]
454 pub const fn from_pkce_flow(
455 flow: PkceFlow,
456 auth_server: AuthServer,
457 access_token: Option<SecretAccessToken>,
458 ) -> Self {
459 Self::new(OAuthGrant::PkceFlow(flow), auth_server, access_token)
460 }
461
462 pub fn access_token(&self) -> Result<&SecretAccessToken, TokenError> {
471 self.access_token.as_ref().ok_or(TokenError::NoAccessToken)
472 }
473
474 #[must_use]
476 pub const fn payload(&self) -> &OAuthGrant {
477 &self.payload
478 }
479
480 #[allow(clippy::missing_panics_doc)]
486 pub async fn request_access_token(&mut self) -> Result<&SecretAccessToken, TokenError> {
487 let access_token = self.payload.request_access_token(&self.auth_server).await?;
488 Ok(self.access_token.insert(access_token))
489 }
490
491 #[must_use]
493 pub const fn auth_server(&self) -> &AuthServer {
494 &self.auth_server
495 }
496
497 pub fn validate(&self) -> Result<SecretAccessToken, TokenError> {
505 let access_token = self.access_token()?;
506 insecure_validate_token_exp(access_token)?;
507 Ok(access_token.clone())
508 }
509}
510
511pub(crate) fn insecure_validate_token_exp(
515 access_token: &SecretAccessToken,
516) -> Result<(), TokenError> {
517 let placeholder_key = DecodingKey::from_secret(&[]);
518 let mut validation = Validation::new(Algorithm::RS256);
519 validation.validate_exp = true;
520 validation.leeway = 60;
521 validation.validate_aud = false;
522 validation.insecure_disable_signature_validation();
523
524 jsonwebtoken::decode::<toml::Value>(access_token.secret(), &placeholder_key, &validation)
525 .map(|_| ())
526 .map_err(TokenError::InvalidAccessToken)
527}
528
529impl std::fmt::Debug for OAuthSession {
530 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531 let token_populated = if self.access_token.is_some() {
532 Some(())
533 } else {
534 None
535 };
536 f.debug_struct("OAuthSession")
537 .field("payload", &self.payload)
538 .field("access_token", &token_populated)
539 .field("auth_server", &self.auth_server)
540 .finish()
541 }
542}
543
544pub(crate) async fn persist_oauth_session(
559 oauth_session: &OAuthSession,
560 source: &ConfigSource,
561 credentials_name: &str,
562) -> Result<(), WriteError> {
563 let ConfigSource::File {
564 settings_path: _,
565 secrets_path,
566 } = source
567 else {
568 return Ok(());
569 };
570
571 if Secrets::is_read_only(secrets_path).await? {
572 return Ok(());
573 }
574
575 let refresh_token = match &oauth_session.payload {
579 OAuthGrant::PkceFlow(payload) => payload.refresh_token.as_ref().map(|rt| &rt.refresh_token),
580 OAuthGrant::RefreshToken(payload) => Some(&payload.refresh_token),
581 OAuthGrant::ExternallyManaged(_) | OAuthGrant::ClientCredentials(_) => None,
582 };
583
584 let Ok(access_token) = oauth_session.access_token() else {
587 return Ok(());
588 };
589
590 let now = OffsetDateTime::now_utc();
591 Secrets::write_tokens(
592 secrets_path,
593 credentials_name,
594 refresh_token,
595 access_token,
596 now,
597 )
598 .await
599}
600
601#[derive(Clone, Debug)]
603#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
604#[cfg_attr(
605 feature = "python",
606 pyo3::pyclass(
607 module = "qcs_api_client_common._qcs_api_client_common.configuration",
608 frozen,
609 from_py_object
610 )
611)]
612pub struct TokenDispatcher {
613 lock: Arc<RwLock<OAuthSession>>,
614 refreshing: Arc<Mutex<bool>>,
615 notify_refreshed: Arc<Notify>,
616}
617
618impl From<OAuthSession> for TokenDispatcher {
619 fn from(value: OAuthSession) -> Self {
620 Self {
621 lock: Arc::new(RwLock::new(value)),
622 refreshing: Arc::new(Mutex::new(false)),
623 notify_refreshed: Arc::new(Notify::new()),
624 }
625 }
626}
627
628impl TokenDispatcher {
629 pub async fn use_tokens<F, O>(&self, f: F) -> O
639 where
640 F: FnOnce(&OAuthSession) -> O + Send,
641 {
642 let tokens = self.lock.read().await;
643 f(&tokens)
644 }
645
646 #[must_use]
648 pub async fn tokens(&self) -> OAuthSession {
649 self.use_tokens(Clone::clone).await
650 }
651
652 pub async fn refresh(
658 &self,
659 source: &ConfigSource,
660 credentials_name: &str,
661 ) -> Result<OAuthSession, TokenError> {
662 self.managed_refresh(Self::perform_refresh, source, credentials_name)
663 .await
664 }
665
666 pub async fn validate(&self) -> Result<SecretAccessToken, TokenError> {
674 self.use_tokens(OAuthSession::validate).await
675 }
676
677 async fn managed_refresh<F, Fut>(
680 &self,
681 refresh_fn: F,
682 source: &ConfigSource,
683 credentials_name: &str,
684 ) -> Result<OAuthSession, TokenError>
685 where
686 F: FnOnce(Arc<RwLock<OAuthSession>>) -> Fut + Send,
687 Fut: Future<Output = Result<OAuthSession, TokenError>> + Send,
688 {
689 let mut is_refreshing = self.refreshing.lock().await;
690
691 if *is_refreshing {
692 drop(is_refreshing);
693 self.notify_refreshed.notified().await;
694 return Ok(self.tokens().await);
695 }
696
697 *is_refreshing = true;
698 drop(is_refreshing);
699
700 let oauth_session = refresh_fn(self.lock.clone()).await?;
701
702 let write_result = persist_oauth_session(&oauth_session, source, credentials_name).await;
703
704 *self.refreshing.lock().await = false;
706 self.notify_refreshed.notify_waiters();
707
708 if let Err(error) = write_result {
710 return Err(TokenError::Write {
711 error,
712 oauth_session: Box::new(oauth_session),
713 });
714 }
715
716 Ok(oauth_session)
717 }
718
719 async fn perform_refresh(lock: Arc<RwLock<OAuthSession>>) -> Result<OAuthSession, TokenError> {
726 let mut credentials = lock.write().await;
727 credentials.request_access_token().await?;
728 Ok(credentials.clone())
729 }
730}
731
732pub(crate) type RefreshResult =
733 Pin<Box<dyn Future<Output = Result<String, Box<dyn std::error::Error + Send + Sync>>> + Send>>;
734
735pub type RefreshFunction = Box<dyn (Fn(AuthServer) -> RefreshResult) + Send + Sync>;
737
738#[derive(Clone)]
743#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
744#[cfg_attr(
745 feature = "python",
746 pyo3::pyclass(
747 module = "qcs_api_client_common._qcs_api_client_common.configuration",
748 frozen,
749 from_py_object
750 )
751)]
752pub struct ExternallyManaged {
753 refresh_function: Arc<RefreshFunction>,
754}
755
756impl ExternallyManaged {
757 pub fn new(
782 refresh_function: impl Fn(AuthServer) -> RefreshResult + Send + Sync + 'static,
783 ) -> Self {
784 Self {
785 refresh_function: Arc::new(Box::new(refresh_function)),
786 }
787 }
788
789 pub fn from_async<F, Fut>(refresh_function: F) -> Self
822 where
823 F: Fn(AuthServer) -> Fut + Send + Sync + 'static,
824 Fut: Future<Output = Result<String, Box<dyn std::error::Error + Send + Sync>>>
825 + Send
826 + 'static,
827 {
828 Self {
829 refresh_function: Arc::new(Box::new(move |auth_server| {
830 Box::pin(refresh_function(auth_server))
831 })),
832 }
833 }
834
835 pub fn from_sync(
866 refresh_function: impl Fn(
867 AuthServer,
868 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>>
869 + Send
870 + Sync
871 + 'static,
872 ) -> Self {
873 Self {
874 refresh_function: Arc::new(Box::new(move |auth_server| {
875 let result = refresh_function(auth_server);
876 Box::pin(async move { result })
877 })),
878 }
879 }
880
881 pub async fn request_access_token(
887 &self,
888 auth_server: &AuthServer,
889 ) -> Result<SecretAccessToken, Box<dyn std::error::Error + Send + Sync>> {
890 (self.refresh_function)(auth_server.clone())
891 .await
892 .map(SecretAccessToken::from)
893 }
894}
895
896impl std::fmt::Debug for ExternallyManaged {
897 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
898 f.debug_struct("ExternallyManaged")
899 .field(
900 "refresh_function",
901 &"Fn() -> Pin<Box<dyn Future<Output = Result<String, TokenError>> + Send>>",
902 )
903 .finish()
904 }
905}
906
907#[derive(Debug, Serialize, Deserialize)]
908pub(super) struct TokenRefreshRequest<'a> {
909 grant_type: &'static str,
910 client_id: &'a str,
911 refresh_token: &'a str,
912}
913
914impl<'a> TokenRefreshRequest<'a> {
915 pub(super) const fn new(client_id: &'a str, refresh_token: &'a str) -> Self {
916 Self {
917 grant_type: "refresh_token",
918 client_id,
919 refresh_token,
920 }
921 }
922}
923
924#[derive(Debug, Serialize, Deserialize)]
925pub(super) struct ClientCredentialsRequest {
926 grant_type: &'static str,
927 scope: Option<&'static str>,
928}
929
930impl ClientCredentialsRequest {
931 pub(super) const fn new(scope: Option<&'static str>) -> Self {
932 Self {
933 grant_type: "client_credentials",
934 scope,
935 }
936 }
937}
938
939#[derive(Deserialize, Debug, Serialize)]
940pub(super) struct RefreshTokenResponse {
941 pub(super) refresh_token: Option<SecretRefreshToken>,
942 pub(super) access_token: SecretAccessToken,
943}
944
945#[async_trait::async_trait]
947pub trait TokenRefresher: Clone + std::fmt::Debug + Send {
948 type Error;
951
952 async fn validated_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
954
955 async fn get_access_token(&self) -> Result<Option<SecretAccessToken>, Self::Error>;
957
958 async fn refresh_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
960
961 #[cfg(feature = "tracing")]
963 fn base_url(&self) -> &str;
964
965 #[cfg(feature = "tracing-config")]
967 fn tracing_configuration(&self) -> Option<&TracingConfiguration>;
968
969 #[cfg(feature = "tracing")]
972 #[allow(clippy::needless_return)]
973 fn should_trace(&self, url: &UrlPatternMatchInput) -> bool {
974 #[cfg(not(feature = "tracing-config"))]
975 {
976 let _ = url;
977 return true;
978 }
979
980 #[cfg(feature = "tracing-config")]
981 self.tracing_configuration()
982 .is_none_or(|config| config.is_enabled(url))
983 }
984}
985
986#[async_trait::async_trait]
987impl TokenRefresher for ClientConfiguration {
988 type Error = TokenError;
989
990 async fn validated_access_token(&self) -> Result<SecretAccessToken, Self::Error> {
991 self.get_bearer_access_token().await
992 }
993
994 async fn refresh_access_token(&self) -> Result<SecretAccessToken, Self::Error> {
995 match self.refresh().await {
996 Ok(session) => Ok(session.access_token()?.clone()),
997 Err(TokenError::Write {
998 error,
999 oauth_session,
1000 }) => {
1001 #[cfg(feature = "tracing")]
1003 tracing::warn!(
1004 "Token refresh succeeded but failed to persist: {}. Returning access token from error.",
1005 error
1006 );
1007 Ok(oauth_session.access_token()?.clone())
1008 }
1009 Err(e) => Err(e),
1010 }
1011 }
1012
1013 async fn get_access_token(&self) -> Result<Option<SecretAccessToken>, Self::Error> {
1014 Ok(Some(self.oauth_session().await?.access_token()?.clone()))
1015 }
1016
1017 #[cfg(feature = "tracing")]
1018 fn base_url(&self) -> &str {
1019 &self.grpc_api_url
1020 }
1021
1022 #[cfg(feature = "tracing-config")]
1023 fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
1024 self.tracing_configuration.as_ref()
1025 }
1026}
1027
1028pub(super) fn default_http_client()
1030-> Result<qcs_dependencies_client::reqwest::Client, qcs_dependencies_client::reqwest::Error> {
1031 qcs_dependencies_client::reqwest::Client::builder()
1032 .timeout(std::time::Duration::from_secs(10))
1033 .build()
1034}
1035
1036#[cfg(test)]
1037mod test {
1038 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
1039
1040 use std::time::Duration;
1041
1042 use super::*;
1043 use httpmock::prelude::*;
1044 use rstest::rstest;
1045 use time::format_description::well_known::Rfc3339;
1046 use tokio::time::Instant;
1047 use toml_edit::DocumentMut;
1048
1049 #[tokio::test]
1050 async fn test_tokens_blocked_during_refresh() {
1051 let mock_server = MockServer::start_async().await;
1052
1053 let oidc_mock = mock_server
1054 .mock_async(|when, then| {
1055 when.method(GET).path("/.well-known/openid-configuration");
1056 then.status(200)
1057 .json_body_obj(&oidc::Discovery::new_for_test(
1058 mock_server.base_url().parse().unwrap(),
1059 ));
1060 })
1061 .await;
1062
1063 let issuer_mock = mock_server
1064 .mock_async(|when, then| {
1065 when.method(POST).path("/v1/token");
1066
1067 then.status(200)
1068 .delay(Duration::from_secs(3))
1069 .json_body_obj(&RefreshTokenResponse {
1070 access_token: SecretAccessToken::from("new_access"),
1071 refresh_token: Some(SecretRefreshToken::from("new_refresh")),
1072 });
1073 })
1074 .await;
1075
1076 let original_tokens = OAuthSession::from_refresh_token(
1077 RefreshToken::new(SecretRefreshToken::from("refresh")),
1078 AuthServer {
1079 client_id: "client_id".to_string(),
1080 issuer: mock_server.base_url(),
1081 scopes: None,
1082 },
1083 None,
1084 );
1085 let dispatcher: TokenDispatcher = original_tokens.clone().into();
1086 let dispatcher_clone1 = dispatcher.clone();
1087 let dispatcher_clone2 = dispatcher.clone();
1088
1089 let refresh_duration = Duration::from_secs(3);
1090
1091 let start_write = Instant::now();
1092 let write_future = tokio::spawn(async move {
1093 dispatcher_clone1
1094 .refresh(&ConfigSource::Default, "")
1095 .await
1096 .unwrap()
1097 });
1098
1099 let start_read = Instant::now();
1100 let read_future = tokio::spawn(async move { dispatcher_clone2.tokens().await });
1101
1102 let _ = write_future.await.unwrap();
1103 let read_result = read_future.await.unwrap();
1104
1105 let write_duration = start_write.elapsed();
1106 let read_duration = start_read.elapsed();
1107
1108 oidc_mock.assert_async().await;
1109 issuer_mock.assert_async().await;
1110
1111 assert!(
1112 write_duration >= refresh_duration,
1113 "Write operation did not take enough time"
1114 );
1115 assert!(
1116 read_duration >= refresh_duration,
1117 "Read operation was not blocked by the write operation"
1118 );
1119 assert_eq!(
1120 read_result.access_token.unwrap(),
1121 SecretAccessToken::from("new_access")
1122 );
1123 if let OAuthGrant::RefreshToken(payload) = read_result.payload {
1124 assert_eq!(
1125 payload.refresh_token,
1126 SecretRefreshToken::from("new_refresh")
1127 );
1128 } else {
1129 panic!(
1130 "Expected RefreshToken payload, got {:?}",
1131 read_result.payload
1132 );
1133 }
1134 }
1135
1136 #[tokio::test]
1140 async fn test_refresh_token_request_rejected_by_auth_server() {
1141 let mock_server = MockServer::start_async().await;
1142
1143 let oidc_mock = mock_server
1144 .mock_async(|when, then| {
1145 when.method(GET).path("/.well-known/openid-configuration");
1146 then.status(200)
1147 .json_body_obj(&oidc::Discovery::new_for_test(
1148 mock_server.base_url().parse().unwrap(),
1149 ));
1150 })
1151 .await;
1152
1153 let issuer_mock = mock_server
1154 .mock_async(|when, then| {
1155 when.method(POST).path("/v1/token");
1156 then.status(400).json_body_obj(&serde_json::json!({
1157 "error": "invalid_grant",
1158 "error_description": "Unknown or invalid refresh token.",
1159 }));
1160 })
1161 .await;
1162
1163 let mut refresh_token = RefreshToken::new(SecretRefreshToken::from("revoked_refresh"));
1164 let auth_server = AuthServer {
1165 client_id: "client_id".to_string(),
1166 issuer: mock_server.base_url(),
1167 scopes: None,
1168 };
1169
1170 let result = refresh_token.request_access_token(&auth_server).await;
1171
1172 oidc_mock.assert_async().await;
1173 issuer_mock.assert_async().await;
1174
1175 assert!(
1176 result.is_err(),
1177 "a rejected refresh token request should be an error, got {result:?}"
1178 );
1179 }
1180
1181 #[rstest]
1182 fn test_qcs_secrets_readonly(
1183 #[values(
1184 (Some("TRUE"), true),
1185 (Some("tRue"), true),
1186 (Some("true"), true),
1187 (Some("YES"), true),
1188 (Some("yEs"), true),
1189 (Some("yes"), true),
1190 (Some("1"), true),
1191 (Some("2"), false),
1192 (Some("other"), false),
1193 (Some(""), false),
1194 (None, false),
1195 )]
1196 read_only_values: (Option<&str>, bool),
1197 #[values(true, false)] read_only_perm: bool,
1198 ) {
1199 let (maybe_read_only_env, env_is_read_only) = read_only_values;
1200 let expected_update = !env_is_read_only && !read_only_perm;
1201 figment::Jail::expect_with(|jail| {
1202 let profile_name = "test";
1203 let initial_access_token = "initial_access_token";
1204 let initial_refresh_token = "initial_refresh_token";
1205
1206 let initial_secrets_file_contents = format!(
1207 r#"
1208[credentials]
1209[credentials.{profile_name}]
1210[credentials.{profile_name}.token_payload]
1211access_token = "{initial_access_token}"
1212expires_in = 3600
1213id_token = "id_token"
1214refresh_token = "{initial_refresh_token}"
1215scope = "offline_access openid profile email"
1216token_type = "Bearer"
1217updated_at = "2024-01-01T00:00:00Z"
1218"#
1219 );
1220
1221 jail.clear_env();
1223
1224 let secrets_path = "secrets.toml";
1226 jail.create_file(secrets_path, initial_secrets_file_contents.as_str())
1227 .expect("should create test secrets.toml");
1228
1229 if read_only_perm {
1230 let mut permissions = std::fs::metadata(secrets_path)
1231 .expect("Should be able to get file metadata")
1232 .permissions();
1233 permissions.set_readonly(true);
1234 std::fs::set_permissions(secrets_path, permissions)
1235 .expect("Should be able to set file permissions");
1236 }
1237
1238 let rt = tokio::runtime::Runtime::new().unwrap();
1239 rt.block_on(async {
1240 let mock_server = MockServer::start_async().await;
1241
1242 let oidc_mock = mock_server
1243 .mock_async(|when, then| {
1244 when.method(GET).path("/.well-known/openid-configuration");
1245 then.status(200)
1246 .json_body_obj(&oidc::Discovery::new_for_test(mock_server.base_url().parse().unwrap()));
1247 })
1248 .await;
1249
1250 let new_access_token = SecretAccessToken::from("new_access_token");
1252 let issuer_mock = mock_server
1253 .mock_async(|when, then| {
1254 when.method(POST).path("/v1/token");
1255 then.status(200).json_body_obj(&RefreshTokenResponse {
1256 access_token: new_access_token.clone(),
1257 refresh_token: Some(SecretRefreshToken::from(initial_refresh_token)),
1258 });
1259 })
1260 .await;
1261
1262 let original_tokens = OAuthSession::from_refresh_token(
1264 RefreshToken::new(SecretRefreshToken::from(initial_refresh_token)),
1265 AuthServer { client_id: "client_id".to_string(), issuer: mock_server.base_url(), scopes: None },
1266 Some(SecretAccessToken::from(initial_refresh_token)),
1267 );
1268 let dispatcher: TokenDispatcher = original_tokens.into();
1269
1270 jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
1272 jail.set_env("QCS_PROFILE_NAME", "test");
1273 if let Some(read_only_env) = maybe_read_only_env {
1274 jail.set_env("QCS_SECRETS_READ_ONLY", read_only_env);
1275 }
1276
1277 let before_refresh = OffsetDateTime::now_utc();
1278
1279 dispatcher
1280 .refresh(
1281 &ConfigSource::File {
1282 settings_path: "".into(),
1283 secrets_path: "secrets.toml".into(),
1284 },
1285 profile_name,
1286 )
1287 .await
1288 .unwrap();
1289
1290 oidc_mock.assert_async().await;
1291 issuer_mock.assert_async().await;
1292
1293 let content = std::fs::read_to_string("secrets.toml").unwrap();
1295 if !expected_update {
1296 assert!(
1297 content.eq(initial_secrets_file_contents.as_str()),
1298 "File should not be updated when QCS_SECRETS_READ_ONLY is set or file permissions are read-only"
1299 );
1300 return;
1301 }
1302
1303 let mut toml = std::fs::read_to_string(secrets_path)
1305 .unwrap()
1306 .parse::<DocumentMut>()
1307 .unwrap();
1308
1309 let token_payload = toml
1310 .get_mut("credentials")
1311 .and_then(|credentials| {
1312 credentials.get_mut(profile_name)?.get_mut("token_payload")
1313 })
1314 .expect("Should be able to get token_payload table");
1315
1316 let access_token = token_payload.get("access_token").unwrap().as_str().map(str::to_string).map(SecretAccessToken::from);
1317
1318 assert_eq!(
1319 access_token,
1320 Some(new_access_token)
1321 );
1322
1323 assert!(
1324 OffsetDateTime::parse(
1325 token_payload.get("updated_at").unwrap().as_str().unwrap(),
1326 &Rfc3339
1327 )
1328 .unwrap()
1329 > before_refresh
1330 );
1331
1332 let content = std::fs::read_to_string("secrets.toml").unwrap();
1333 assert!(
1334 content.contains("new_access_token"),
1335 "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"
1336 );
1337 });
1338 Ok(())
1339 });
1340 }
1341
1342 #[test]
1345 fn test_refresh_token_grant_persists_rotated_refresh_token() {
1346 let initial_refresh_token = "initial_refresh_token";
1347 let rotated_refresh_token = "rotated_refresh_token";
1348 let new_access_token = "new_access_token";
1349
1350 figment::Jail::expect_with(|jail| {
1351 jail.clear_env();
1352
1353 let secrets_path = "secrets.toml";
1354 let initial_secrets_file_contents = format!(
1355 r#"
1356[credentials]
1357[credentials.test]
1358[credentials.test.token_payload]
1359access_token = "initial_access_token"
1360refresh_token = "{initial_refresh_token}"
1361updated_at = "2024-01-01T00:00:00Z"
1362"#
1363 );
1364 jail.create_file(secrets_path, &initial_secrets_file_contents)
1365 .expect("should create test secrets.toml");
1366
1367 let rt = tokio::runtime::Runtime::new().unwrap();
1368 rt.block_on(async {
1369 let mock_server = MockServer::start_async().await;
1370 let oidc_mock = mock_server
1371 .mock_async(|when, then| {
1372 when.method(GET).path("/.well-known/openid-configuration");
1373 then.status(200)
1374 .json_body_obj(&oidc::Discovery::new_for_test(
1375 mock_server.base_url().parse().unwrap(),
1376 ));
1377 })
1378 .await;
1379 let issuer_mock = mock_server
1380 .mock_async(|when, then| {
1381 when.method(POST).path("/v1/token");
1382 then.status(200).json_body_obj(&RefreshTokenResponse {
1383 access_token: SecretAccessToken::from(new_access_token),
1384 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token)),
1385 });
1386 })
1387 .await;
1388
1389 let dispatcher: TokenDispatcher = OAuthSession::from_refresh_token(
1390 RefreshToken::new(SecretRefreshToken::from(initial_refresh_token)),
1391 AuthServer {
1392 client_id: "client_id".to_string(),
1393 issuer: mock_server.base_url(),
1394 scopes: None,
1395 },
1396 Some(SecretAccessToken::from("initial_access_token")),
1397 )
1398 .into();
1399
1400 dispatcher
1401 .refresh(
1402 &ConfigSource::File {
1403 settings_path: "".into(),
1404 secrets_path: secrets_path.into(),
1405 },
1406 "test",
1407 )
1408 .await
1409 .expect("refresh should succeed");
1410
1411 oidc_mock.assert_async().await;
1412 issuer_mock.assert_async().await;
1413 });
1414
1415 let payload = Secrets::load_from_path(&secrets_path.into())
1417 .expect("should load secrets")
1418 .credentials
1419 .remove("test")
1420 .expect("should have test credentials")
1421 .token_payload
1422 .expect("should have token payload");
1423 assert_eq!(
1424 payload.refresh_token.unwrap(),
1425 SecretRefreshToken::from(rotated_refresh_token),
1426 "rotated refresh token should be persisted to the secrets file"
1427 );
1428 assert_eq!(
1429 payload.access_token.unwrap(),
1430 SecretAccessToken::from(new_access_token),
1431 "new access token should be persisted to the secrets file"
1432 );
1433
1434 Ok(())
1435 });
1436 }
1437
1438 #[test]
1439 fn test_auth_session_debug_fmt() {
1440 let session = OAuthSession {
1441 payload: OAuthGrant::ClientCredentials(ClientCredentials::new(
1442 "hidden_id",
1443 "hidden_secret",
1444 )),
1445 access_token: Some(SecretAccessToken::from("token")),
1446 auth_server: AuthServer {
1447 client_id: "some_id".into(),
1448 issuer: "some_url".into(),
1449 scopes: None,
1450 },
1451 };
1452
1453 assert_eq!(
1454 "OAuthSession { payload: ClientCredentials, access_token: Some(()), auth_server: AuthServer { client_id: \"some_id\", issuer: \"some_url\", scopes: None } }",
1455 &format!("{session:?}")
1456 );
1457 }
1458}