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(eq, get_all, set_all, module = "qcs_api_client_common.configuration")
36)]
37pub struct RefreshToken {
38 pub refresh_token: SecretRefreshToken,
40}
41
42impl RefreshToken {
43 #[must_use]
45 pub const fn new(refresh_token: SecretRefreshToken) -> Self {
46 Self { refresh_token }
47 }
48
49 pub async fn request_access_token(
56 &mut self,
57 auth_server: &AuthServer,
58 ) -> Result<SecretAccessToken, TokenError> {
59 if self.refresh_token.is_empty() {
60 return Err(TokenError::NoRefreshToken);
61 }
62
63 let client = default_http_client()?;
64 let token_url = oidc::fetch_discovery(&client, &auth_server.issuer)
65 .await?
66 .token_endpoint;
67 let data = TokenRefreshRequest::new(&auth_server.client_id, self.refresh_token.secret());
68 let resp = client.post(token_url).form(&data).send().await?;
69
70 if let Err(error) = resp.error_for_status_ref() {
76 #[cfg(feature = "tracing")]
77 {
78 let status = resp.status();
79 let body = resp.text().await.unwrap_or_default();
80 tracing::warn!(
81 %status,
82 %body,
83 "the auth server rejected the refresh token request"
84 );
85 }
86 return Err(error.into());
87 }
88
89 let RefreshTokenResponse {
90 access_token,
91 refresh_token,
92 } = resp.json().await?;
93
94 if let Some(refresh_token) = refresh_token {
95 self.refresh_token = refresh_token;
96 }
97 Ok(access_token)
98 }
99}
100
101#[derive(Deserialize, Debug, Serialize)]
102pub(super) struct ClientCredentialsResponse {
103 pub(super) access_token: SecretAccessToken,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
108#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
109#[cfg_attr(
110 feature = "python",
111 pyo3::pyclass(eq, get_all, frozen, module = "qcs_api_client_common.configuration")
112)]
113pub struct ClientCredentials {
114 pub client_id: String,
116 pub client_secret: ClientSecret,
118}
119
120impl ClientCredentials {
121 #[must_use]
122 pub fn new(client_id: impl Into<String>, client_secret: impl Into<ClientSecret>) -> Self {
124 Self {
125 client_id: client_id.into(),
126 client_secret: client_secret.into(),
127 }
128 }
129
130 #[must_use]
132 pub fn client_id(&self) -> &str {
133 &self.client_id
134 }
135
136 #[must_use]
138 pub const fn client_secret(&self) -> &ClientSecret {
139 &self.client_secret
140 }
141
142 pub async fn request_access_token(
148 &self,
149 auth_server: &AuthServer,
150 ) -> Result<SecretAccessToken, TokenError> {
151 let request = ClientCredentialsRequest::new(None);
152 let client = default_http_client()?;
153
154 let url = oidc::fetch_discovery(&client, &auth_server.issuer)
155 .await?
156 .token_endpoint;
157 let ready_to_send = client
158 .post(url)
159 .basic_auth(&auth_server.client_id, Some(&self.client_secret.secret()))
160 .form(&request);
161 let response = ready_to_send.send().await?;
162
163 response.error_for_status_ref()?;
164
165 let ClientCredentialsResponse { access_token } = response.json().await?;
166 Ok(access_token)
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
171#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
172#[cfg_attr(
173 feature = "python",
174 pyo3::pyclass(eq, get_all, frozen, module = "qcs_api_client_common.configuration")
175)]
176pub struct PkceFlow {
178 pub access_token: SecretAccessToken,
180 pub refresh_token: Option<RefreshToken>,
182}
183
184#[derive(Debug, thiserror::Error)]
186pub enum PkceFlowError {
187 #[error(transparent)]
189 PkceLogin(#[from] PkceLoginError),
190 #[error(transparent)]
192 Discovery(#[from] DiscoveryError),
193 #[error(transparent)]
195 Request(#[from] qcs_dependencies_client::reqwest::Error),
196}
197
198impl PkceFlow {
199 pub async fn new_login_flow(
205 cancel_token: CancellationToken,
206 auth_server: &AuthServer,
207 ) -> Result<Self, PkceFlowError> {
208 let issuer = auth_server.issuer.clone();
209
210 let client = default_http_client()?;
211 let discovery = oidc::fetch_discovery(&client, &issuer).await?;
212
213 let response = pkce_login(
214 cancel_token,
215 PkceLoginRequest {
216 client_id: auth_server.client_id.clone(),
217 redirect_port: None,
218 discovery,
219 scopes: auth_server.scopes.clone(),
220 },
221 )
222 .await?;
223
224 Ok(Self {
225 access_token: SecretAccessToken::from(response.access_token().secret().clone()),
226 refresh_token: response
227 .refresh_token()
228 .map(|rt| RefreshToken::new(SecretRefreshToken::from(rt.secret().clone()))),
229 })
230 }
231
232 pub async fn request_access_token(
238 &mut self,
239 auth_server: &AuthServer,
240 ) -> Result<SecretAccessToken, TokenError> {
241 if insecure_validate_token_exp(&self.access_token).is_ok() {
242 return Ok(self.access_token.clone());
243 }
244
245 if let Some(refresh_token) = &mut self.refresh_token {
246 let access_token = refresh_token.request_access_token(auth_server).await?;
247 self.access_token.clone_from(&access_token);
248 return Ok(access_token);
249 }
250
251 Err(TokenError::NoRefreshToken)
252 }
253}
254
255impl From<PkceFlow> for Credential {
256 fn from(value: PkceFlow) -> Self {
257 let mut token_payload = TokenPayload::default();
258 token_payload.access_token = Some(value.access_token);
259 token_payload.refresh_token = value.refresh_token.map(|rt| rt.refresh_token);
260
261 Self {
262 token_payload: Some(token_payload),
263 }
264 }
265}
266
267#[derive(Clone)]
268#[cfg_attr(feature = "python", derive(pyo3::FromPyObject, pyo3::IntoPyObject))]
269pub enum OAuthGrant {
272 RefreshToken(RefreshToken),
274 ClientCredentials(ClientCredentials),
276 ExternallyManaged(ExternallyManaged),
278 PkceFlow(PkceFlow),
280}
281
282impl From<ExternallyManaged> for OAuthGrant {
283 fn from(v: ExternallyManaged) -> Self {
284 Self::ExternallyManaged(v)
285 }
286}
287
288impl From<ClientCredentials> for OAuthGrant {
289 fn from(v: ClientCredentials) -> Self {
290 Self::ClientCredentials(v)
291 }
292}
293
294impl From<RefreshToken> for OAuthGrant {
295 fn from(v: RefreshToken) -> Self {
296 Self::RefreshToken(v)
297 }
298}
299
300impl From<PkceFlow> for OAuthGrant {
301 fn from(v: PkceFlow) -> Self {
302 Self::PkceFlow(v)
303 }
304}
305
306impl OAuthGrant {
307 async fn request_access_token(
309 &mut self,
310 auth_server: &AuthServer,
311 ) -> Result<SecretAccessToken, TokenError> {
312 match self {
313 Self::RefreshToken(tokens) => tokens.request_access_token(auth_server).await,
314 Self::ClientCredentials(tokens) => tokens.request_access_token(auth_server).await,
315 Self::ExternallyManaged(tokens) => tokens
316 .request_access_token(auth_server)
317 .await
318 .map_err(|e| TokenError::ExternallyManaged(e.to_string())),
319 Self::PkceFlow(tokens) => tokens.request_access_token(auth_server).await,
320 }
321 }
322}
323
324impl std::fmt::Debug for OAuthGrant {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 match self {
327 Self::RefreshToken(_) => f.write_str("RefreshToken"),
328 Self::ClientCredentials(_) => f.write_str("ClientCredentials"),
329 Self::ExternallyManaged(_) => f.write_str("ExternallyManaged"),
330 Self::PkceFlow(_) => f.write_str("PkceTokens"),
331 }
332 }
333}
334
335#[derive(Clone)]
347#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
348#[cfg_attr(
349 feature = "python",
350 pyo3::pyclass(module = "qcs_api_client_common.configuration", frozen, get_all)
351)]
352pub struct OAuthSession {
353 payload: OAuthGrant,
355 access_token: Option<SecretAccessToken>,
357 auth_server: AuthServer,
359}
360
361impl OAuthSession {
362 #[must_use]
367 pub const fn new(
368 payload: OAuthGrant,
369 auth_server: AuthServer,
370 access_token: Option<SecretAccessToken>,
371 ) -> Self {
372 Self {
373 payload,
374 access_token,
375 auth_server,
376 }
377 }
378
379 #[must_use]
384 pub const fn from_externally_managed(
385 tokens: ExternallyManaged,
386 auth_server: AuthServer,
387 access_token: Option<SecretAccessToken>,
388 ) -> Self {
389 Self::new(
390 OAuthGrant::ExternallyManaged(tokens),
391 auth_server,
392 access_token,
393 )
394 }
395
396 #[must_use]
401 pub const fn from_refresh_token(
402 tokens: RefreshToken,
403 auth_server: AuthServer,
404 access_token: Option<SecretAccessToken>,
405 ) -> Self {
406 Self::new(OAuthGrant::RefreshToken(tokens), auth_server, access_token)
407 }
408
409 #[must_use]
414 pub const fn from_client_credentials(
415 tokens: ClientCredentials,
416 auth_server: AuthServer,
417 access_token: Option<SecretAccessToken>,
418 ) -> Self {
419 Self::new(
420 OAuthGrant::ClientCredentials(tokens),
421 auth_server,
422 access_token,
423 )
424 }
425
426 #[must_use]
431 pub const fn from_pkce_flow(
432 flow: PkceFlow,
433 auth_server: AuthServer,
434 access_token: Option<SecretAccessToken>,
435 ) -> Self {
436 Self::new(OAuthGrant::PkceFlow(flow), auth_server, access_token)
437 }
438
439 pub fn access_token(&self) -> Result<&SecretAccessToken, TokenError> {
448 self.access_token.as_ref().ok_or(TokenError::NoAccessToken)
449 }
450
451 #[must_use]
453 pub const fn payload(&self) -> &OAuthGrant {
454 &self.payload
455 }
456
457 #[allow(clippy::missing_panics_doc)]
463 pub async fn request_access_token(&mut self) -> Result<&SecretAccessToken, TokenError> {
464 let access_token = self.payload.request_access_token(&self.auth_server).await?;
465 Ok(self.access_token.insert(access_token))
466 }
467
468 #[must_use]
470 pub const fn auth_server(&self) -> &AuthServer {
471 &self.auth_server
472 }
473
474 pub fn validate(&self) -> Result<SecretAccessToken, TokenError> {
482 let access_token = self.access_token()?;
483 insecure_validate_token_exp(access_token)?;
484 Ok(access_token.clone())
485 }
486}
487
488pub(crate) fn insecure_validate_token_exp(
492 access_token: &SecretAccessToken,
493) -> Result<(), TokenError> {
494 let placeholder_key = DecodingKey::from_secret(&[]);
495 let mut validation = Validation::new(Algorithm::RS256);
496 validation.validate_exp = true;
497 validation.leeway = 60;
498 validation.validate_aud = false;
499 validation.insecure_disable_signature_validation();
500
501 jsonwebtoken::decode::<toml::Value>(access_token.secret(), &placeholder_key, &validation)
502 .map(|_| ())
503 .map_err(TokenError::InvalidAccessToken)
504}
505
506impl std::fmt::Debug for OAuthSession {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 let token_populated = if self.access_token.is_some() {
509 Some(())
510 } else {
511 None
512 };
513 f.debug_struct("OAuthSession")
514 .field("payload", &self.payload)
515 .field("access_token", &token_populated)
516 .field("auth_server", &self.auth_server)
517 .finish()
518 }
519}
520
521pub(crate) async fn persist_oauth_session(
536 oauth_session: &OAuthSession,
537 source: &ConfigSource,
538 credentials_name: &str,
539) -> Result<(), WriteError> {
540 let ConfigSource::File {
541 settings_path: _,
542 secrets_path,
543 } = source
544 else {
545 return Ok(());
546 };
547
548 if Secrets::is_read_only(secrets_path).await? {
549 return Ok(());
550 }
551
552 let refresh_token = match &oauth_session.payload {
556 OAuthGrant::PkceFlow(payload) => payload.refresh_token.as_ref().map(|rt| &rt.refresh_token),
557 OAuthGrant::RefreshToken(payload) => Some(&payload.refresh_token),
558 OAuthGrant::ExternallyManaged(_) | OAuthGrant::ClientCredentials(_) => None,
559 };
560
561 let Ok(access_token) = oauth_session.access_token() else {
564 return Ok(());
565 };
566
567 let now = OffsetDateTime::now_utc();
568 Secrets::write_tokens(
569 secrets_path,
570 credentials_name,
571 refresh_token,
572 access_token,
573 now,
574 )
575 .await
576}
577
578#[derive(Clone, Debug)]
580#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
581#[cfg_attr(
582 feature = "python",
583 pyo3::pyclass(module = "qcs_api_client_common.configuration", frozen)
584)]
585pub struct TokenDispatcher {
586 lock: Arc<RwLock<OAuthSession>>,
587 refreshing: Arc<Mutex<bool>>,
588 notify_refreshed: Arc<Notify>,
589}
590
591impl From<OAuthSession> for TokenDispatcher {
592 fn from(value: OAuthSession) -> Self {
593 Self {
594 lock: Arc::new(RwLock::new(value)),
595 refreshing: Arc::new(Mutex::new(false)),
596 notify_refreshed: Arc::new(Notify::new()),
597 }
598 }
599}
600
601impl TokenDispatcher {
602 pub async fn use_tokens<F, O>(&self, f: F) -> O
612 where
613 F: FnOnce(&OAuthSession) -> O + Send,
614 {
615 let tokens = self.lock.read().await;
616 f(&tokens)
617 }
618
619 #[must_use]
621 pub async fn tokens(&self) -> OAuthSession {
622 self.use_tokens(Clone::clone).await
623 }
624
625 pub async fn refresh(
631 &self,
632 source: &ConfigSource,
633 credentials_name: &str,
634 ) -> Result<OAuthSession, TokenError> {
635 self.managed_refresh(Self::perform_refresh, source, credentials_name)
636 .await
637 }
638
639 pub async fn validate(&self) -> Result<SecretAccessToken, TokenError> {
647 self.use_tokens(OAuthSession::validate).await
648 }
649
650 async fn managed_refresh<F, Fut>(
653 &self,
654 refresh_fn: F,
655 source: &ConfigSource,
656 credentials_name: &str,
657 ) -> Result<OAuthSession, TokenError>
658 where
659 F: FnOnce(Arc<RwLock<OAuthSession>>) -> Fut + Send,
660 Fut: Future<Output = Result<OAuthSession, TokenError>> + Send,
661 {
662 let mut is_refreshing = self.refreshing.lock().await;
663
664 if *is_refreshing {
665 drop(is_refreshing);
666 self.notify_refreshed.notified().await;
667 return Ok(self.tokens().await);
668 }
669
670 *is_refreshing = true;
671 drop(is_refreshing);
672
673 let oauth_session = refresh_fn(self.lock.clone()).await?;
674
675 let write_result = persist_oauth_session(&oauth_session, source, credentials_name).await;
676
677 *self.refreshing.lock().await = false;
679 self.notify_refreshed.notify_waiters();
680
681 if let Err(error) = write_result {
683 return Err(TokenError::Write {
684 error,
685 oauth_session: Box::new(oauth_session),
686 });
687 }
688
689 Ok(oauth_session)
690 }
691
692 async fn perform_refresh(lock: Arc<RwLock<OAuthSession>>) -> Result<OAuthSession, TokenError> {
699 let mut credentials = lock.write().await;
700 credentials.request_access_token().await?;
701 Ok(credentials.clone())
702 }
703}
704
705pub(crate) type RefreshResult =
706 Pin<Box<dyn Future<Output = Result<String, Box<dyn std::error::Error + Send + Sync>>> + Send>>;
707
708pub type RefreshFunction = Box<dyn (Fn(AuthServer) -> RefreshResult) + Send + Sync>;
710
711#[derive(Clone)]
716#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
717#[cfg_attr(
718 feature = "python",
719 pyo3::pyclass(module = "qcs_api_client_common.configuration", frozen)
720)]
721pub struct ExternallyManaged {
722 refresh_function: Arc<RefreshFunction>,
723}
724
725impl ExternallyManaged {
726 pub fn new(
751 refresh_function: impl Fn(AuthServer) -> RefreshResult + Send + Sync + 'static,
752 ) -> Self {
753 Self {
754 refresh_function: Arc::new(Box::new(refresh_function)),
755 }
756 }
757
758 pub fn from_async<F, Fut>(refresh_function: F) -> Self
791 where
792 F: Fn(AuthServer) -> Fut + Send + Sync + 'static,
793 Fut: Future<Output = Result<String, Box<dyn std::error::Error + Send + Sync>>>
794 + Send
795 + 'static,
796 {
797 Self {
798 refresh_function: Arc::new(Box::new(move |auth_server| {
799 Box::pin(refresh_function(auth_server))
800 })),
801 }
802 }
803
804 pub fn from_sync(
835 refresh_function: impl Fn(
836 AuthServer,
837 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>>
838 + Send
839 + Sync
840 + 'static,
841 ) -> Self {
842 Self {
843 refresh_function: Arc::new(Box::new(move |auth_server| {
844 let result = refresh_function(auth_server);
845 Box::pin(async move { result })
846 })),
847 }
848 }
849
850 pub async fn request_access_token(
856 &self,
857 auth_server: &AuthServer,
858 ) -> Result<SecretAccessToken, Box<dyn std::error::Error + Send + Sync>> {
859 (self.refresh_function)(auth_server.clone())
860 .await
861 .map(SecretAccessToken::from)
862 }
863}
864
865impl std::fmt::Debug for ExternallyManaged {
866 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
867 f.debug_struct("ExternallyManaged")
868 .field(
869 "refresh_function",
870 &"Fn() -> Pin<Box<dyn Future<Output = Result<String, TokenError>> + Send>>",
871 )
872 .finish()
873 }
874}
875
876#[derive(Debug, Serialize, Deserialize)]
877pub(super) struct TokenRefreshRequest<'a> {
878 grant_type: &'static str,
879 client_id: &'a str,
880 refresh_token: &'a str,
881}
882
883impl<'a> TokenRefreshRequest<'a> {
884 pub(super) const fn new(client_id: &'a str, refresh_token: &'a str) -> Self {
885 Self {
886 grant_type: "refresh_token",
887 client_id,
888 refresh_token,
889 }
890 }
891}
892
893#[derive(Debug, Serialize, Deserialize)]
894pub(super) struct ClientCredentialsRequest {
895 grant_type: &'static str,
896 scope: Option<&'static str>,
897}
898
899impl ClientCredentialsRequest {
900 pub(super) const fn new(scope: Option<&'static str>) -> Self {
901 Self {
902 grant_type: "client_credentials",
903 scope,
904 }
905 }
906}
907
908#[derive(Deserialize, Debug, Serialize)]
909pub(super) struct RefreshTokenResponse {
910 pub(super) refresh_token: Option<SecretRefreshToken>,
911 pub(super) access_token: SecretAccessToken,
912}
913
914#[async_trait::async_trait]
916pub trait TokenRefresher: Clone + std::fmt::Debug + Send {
917 type Error;
920
921 async fn validated_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
923
924 async fn get_access_token(&self) -> Result<Option<SecretAccessToken>, Self::Error>;
926
927 async fn refresh_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
929
930 #[cfg(feature = "tracing")]
932 fn base_url(&self) -> &str;
933
934 #[cfg(feature = "tracing-config")]
936 fn tracing_configuration(&self) -> Option<&TracingConfiguration>;
937
938 #[cfg(feature = "tracing")]
941 #[allow(clippy::needless_return)]
942 fn should_trace(&self, url: &UrlPatternMatchInput) -> bool {
943 #[cfg(not(feature = "tracing-config"))]
944 {
945 let _ = url;
946 return true;
947 }
948
949 #[cfg(feature = "tracing-config")]
950 self.tracing_configuration()
951 .is_none_or(|config| config.is_enabled(url))
952 }
953}
954
955#[async_trait::async_trait]
956impl TokenRefresher for ClientConfiguration {
957 type Error = TokenError;
958
959 async fn validated_access_token(&self) -> Result<SecretAccessToken, Self::Error> {
960 self.get_bearer_access_token().await
961 }
962
963 async fn refresh_access_token(&self) -> Result<SecretAccessToken, Self::Error> {
964 match self.refresh().await {
965 Ok(session) => Ok(session.access_token()?.clone()),
966 Err(TokenError::Write {
967 error,
968 oauth_session,
969 }) => {
970 #[cfg(feature = "tracing")]
972 tracing::warn!(
973 "Token refresh succeeded but failed to persist: {}. Returning access token from error.",
974 error
975 );
976 Ok(oauth_session.access_token()?.clone())
977 }
978 Err(e) => Err(e),
979 }
980 }
981
982 async fn get_access_token(&self) -> Result<Option<SecretAccessToken>, Self::Error> {
983 Ok(Some(self.oauth_session().await?.access_token()?.clone()))
984 }
985
986 #[cfg(feature = "tracing")]
987 fn base_url(&self) -> &str {
988 &self.grpc_api_url
989 }
990
991 #[cfg(feature = "tracing-config")]
992 fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
993 self.tracing_configuration.as_ref()
994 }
995}
996
997pub(super) fn default_http_client()
999-> Result<qcs_dependencies_client::reqwest::Client, qcs_dependencies_client::reqwest::Error> {
1000 qcs_dependencies_client::reqwest::Client::builder()
1001 .timeout(std::time::Duration::from_secs(10))
1002 .build()
1003}
1004
1005#[cfg(test)]
1006mod test {
1007 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
1008
1009 use std::time::Duration;
1010
1011 use super::*;
1012 use httpmock::prelude::*;
1013 use rstest::rstest;
1014 use time::format_description::well_known::Rfc3339;
1015 use tokio::time::Instant;
1016 use toml_edit::DocumentMut;
1017
1018 #[tokio::test]
1019 async fn test_tokens_blocked_during_refresh() {
1020 let mock_server = MockServer::start_async().await;
1021
1022 let oidc_mock = mock_server
1023 .mock_async(|when, then| {
1024 when.method(GET).path("/.well-known/openid-configuration");
1025 then.status(200)
1026 .json_body_obj(&oidc::Discovery::new_for_test(
1027 mock_server.base_url().parse().unwrap(),
1028 ));
1029 })
1030 .await;
1031
1032 let issuer_mock = mock_server
1033 .mock_async(|when, then| {
1034 when.method(POST).path("/v1/token");
1035
1036 then.status(200)
1037 .delay(Duration::from_secs(3))
1038 .json_body_obj(&RefreshTokenResponse {
1039 access_token: SecretAccessToken::from("new_access"),
1040 refresh_token: Some(SecretRefreshToken::from("new_refresh")),
1041 });
1042 })
1043 .await;
1044
1045 let original_tokens = OAuthSession::from_refresh_token(
1046 RefreshToken::new(SecretRefreshToken::from("refresh")),
1047 AuthServer {
1048 client_id: "client_id".to_string(),
1049 issuer: mock_server.base_url(),
1050 scopes: None,
1051 },
1052 None,
1053 );
1054 let dispatcher: TokenDispatcher = original_tokens.clone().into();
1055 let dispatcher_clone1 = dispatcher.clone();
1056 let dispatcher_clone2 = dispatcher.clone();
1057
1058 let refresh_duration = Duration::from_secs(3);
1059
1060 let start_write = Instant::now();
1061 let write_future = tokio::spawn(async move {
1062 dispatcher_clone1
1063 .refresh(&ConfigSource::Default, "")
1064 .await
1065 .unwrap()
1066 });
1067
1068 let start_read = Instant::now();
1069 let read_future = tokio::spawn(async move { dispatcher_clone2.tokens().await });
1070
1071 let _ = write_future.await.unwrap();
1072 let read_result = read_future.await.unwrap();
1073
1074 let write_duration = start_write.elapsed();
1075 let read_duration = start_read.elapsed();
1076
1077 oidc_mock.assert_async().await;
1078 issuer_mock.assert_async().await;
1079
1080 assert!(
1081 write_duration >= refresh_duration,
1082 "Write operation did not take enough time"
1083 );
1084 assert!(
1085 read_duration >= refresh_duration,
1086 "Read operation was not blocked by the write operation"
1087 );
1088 assert_eq!(
1089 read_result.access_token.unwrap(),
1090 SecretAccessToken::from("new_access")
1091 );
1092 if let OAuthGrant::RefreshToken(payload) = read_result.payload {
1093 assert_eq!(
1094 payload.refresh_token,
1095 SecretRefreshToken::from("new_refresh")
1096 );
1097 } else {
1098 panic!(
1099 "Expected RefreshToken payload, got {:?}",
1100 read_result.payload
1101 );
1102 }
1103 }
1104
1105 #[tokio::test]
1109 async fn test_refresh_token_request_rejected_by_auth_server() {
1110 let mock_server = MockServer::start_async().await;
1111
1112 let oidc_mock = mock_server
1113 .mock_async(|when, then| {
1114 when.method(GET).path("/.well-known/openid-configuration");
1115 then.status(200)
1116 .json_body_obj(&oidc::Discovery::new_for_test(
1117 mock_server.base_url().parse().unwrap(),
1118 ));
1119 })
1120 .await;
1121
1122 let issuer_mock = mock_server
1123 .mock_async(|when, then| {
1124 when.method(POST).path("/v1/token");
1125 then.status(400).json_body_obj(&serde_json::json!({
1126 "error": "invalid_grant",
1127 "error_description": "Unknown or invalid refresh token.",
1128 }));
1129 })
1130 .await;
1131
1132 let mut refresh_token = RefreshToken::new(SecretRefreshToken::from("revoked_refresh"));
1133 let auth_server = AuthServer {
1134 client_id: "client_id".to_string(),
1135 issuer: mock_server.base_url(),
1136 scopes: None,
1137 };
1138
1139 let result = refresh_token.request_access_token(&auth_server).await;
1140
1141 oidc_mock.assert_async().await;
1142 issuer_mock.assert_async().await;
1143
1144 assert!(
1145 result.is_err(),
1146 "a rejected refresh token request should be an error, got {result:?}"
1147 );
1148 }
1149
1150 #[rstest]
1151 fn test_qcs_secrets_readonly(
1152 #[values(
1153 (Some("TRUE"), true),
1154 (Some("tRue"), true),
1155 (Some("true"), true),
1156 (Some("YES"), true),
1157 (Some("yEs"), true),
1158 (Some("yes"), true),
1159 (Some("1"), true),
1160 (Some("2"), false),
1161 (Some("other"), false),
1162 (Some(""), false),
1163 (None, false),
1164 )]
1165 read_only_values: (Option<&str>, bool),
1166 #[values(true, false)] read_only_perm: bool,
1167 ) {
1168 let (maybe_read_only_env, env_is_read_only) = read_only_values;
1169 let expected_update = !env_is_read_only && !read_only_perm;
1170 figment::Jail::expect_with(|jail| {
1171 let profile_name = "test";
1172 let initial_access_token = "initial_access_token";
1173 let initial_refresh_token = "initial_refresh_token";
1174
1175 let initial_secrets_file_contents = format!(
1176 r#"
1177[credentials]
1178[credentials.{profile_name}]
1179[credentials.{profile_name}.token_payload]
1180access_token = "{initial_access_token}"
1181expires_in = 3600
1182id_token = "id_token"
1183refresh_token = "{initial_refresh_token}"
1184scope = "offline_access openid profile email"
1185token_type = "Bearer"
1186updated_at = "2024-01-01T00:00:00Z"
1187"#
1188 );
1189
1190 jail.clear_env();
1192
1193 let secrets_path = "secrets.toml";
1195 jail.create_file(secrets_path, initial_secrets_file_contents.as_str())
1196 .expect("should create test secrets.toml");
1197
1198 if read_only_perm {
1199 let mut permissions = std::fs::metadata(secrets_path)
1200 .expect("Should be able to get file metadata")
1201 .permissions();
1202 permissions.set_readonly(true);
1203 std::fs::set_permissions(secrets_path, permissions)
1204 .expect("Should be able to set file permissions");
1205 }
1206
1207 let rt = tokio::runtime::Runtime::new().unwrap();
1208 rt.block_on(async {
1209 let mock_server = MockServer::start_async().await;
1210
1211 let oidc_mock = mock_server
1212 .mock_async(|when, then| {
1213 when.method(GET).path("/.well-known/openid-configuration");
1214 then.status(200)
1215 .json_body_obj(&oidc::Discovery::new_for_test(mock_server.base_url().parse().unwrap()));
1216 })
1217 .await;
1218
1219 let new_access_token = SecretAccessToken::from("new_access_token");
1221 let issuer_mock = mock_server
1222 .mock_async(|when, then| {
1223 when.method(POST).path("/v1/token");
1224 then.status(200).json_body_obj(&RefreshTokenResponse {
1225 access_token: new_access_token.clone(),
1226 refresh_token: Some(SecretRefreshToken::from(initial_refresh_token)),
1227 });
1228 })
1229 .await;
1230
1231 let original_tokens = OAuthSession::from_refresh_token(
1233 RefreshToken::new(SecretRefreshToken::from(initial_refresh_token)),
1234 AuthServer { client_id: "client_id".to_string(), issuer: mock_server.base_url(), scopes: None },
1235 Some(SecretAccessToken::from(initial_refresh_token)),
1236 );
1237 let dispatcher: TokenDispatcher = original_tokens.into();
1238
1239 jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
1241 jail.set_env("QCS_PROFILE_NAME", "test");
1242 if let Some(read_only_env) = maybe_read_only_env {
1243 jail.set_env("QCS_SECRETS_READ_ONLY", read_only_env);
1244 }
1245
1246 let before_refresh = OffsetDateTime::now_utc();
1247
1248 dispatcher
1249 .refresh(
1250 &ConfigSource::File {
1251 settings_path: "".into(),
1252 secrets_path: "secrets.toml".into(),
1253 },
1254 profile_name,
1255 )
1256 .await
1257 .unwrap();
1258
1259 oidc_mock.assert_async().await;
1260 issuer_mock.assert_async().await;
1261
1262 let content = std::fs::read_to_string("secrets.toml").unwrap();
1264 if !expected_update {
1265 assert!(
1266 content.eq(initial_secrets_file_contents.as_str()),
1267 "File should not be updated when QCS_SECRETS_READ_ONLY is set or file permissions are read-only"
1268 );
1269 return;
1270 }
1271
1272 let mut toml = std::fs::read_to_string(secrets_path)
1274 .unwrap()
1275 .parse::<DocumentMut>()
1276 .unwrap();
1277
1278 let token_payload = toml
1279 .get_mut("credentials")
1280 .and_then(|credentials| {
1281 credentials.get_mut(profile_name)?.get_mut("token_payload")
1282 })
1283 .expect("Should be able to get token_payload table");
1284
1285 let access_token = token_payload.get("access_token").unwrap().as_str().map(str::to_string).map(SecretAccessToken::from);
1286
1287 assert_eq!(
1288 access_token,
1289 Some(new_access_token)
1290 );
1291
1292 assert!(
1293 OffsetDateTime::parse(
1294 token_payload.get("updated_at").unwrap().as_str().unwrap(),
1295 &Rfc3339
1296 )
1297 .unwrap()
1298 > before_refresh
1299 );
1300
1301 let content = std::fs::read_to_string("secrets.toml").unwrap();
1302 assert!(
1303 content.contains("new_access_token"),
1304 "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"
1305 );
1306 });
1307 Ok(())
1308 });
1309 }
1310
1311 #[test]
1314 fn test_refresh_token_grant_persists_rotated_refresh_token() {
1315 let initial_refresh_token = "initial_refresh_token";
1316 let rotated_refresh_token = "rotated_refresh_token";
1317 let new_access_token = "new_access_token";
1318
1319 figment::Jail::expect_with(|jail| {
1320 jail.clear_env();
1321
1322 let secrets_path = "secrets.toml";
1323 let initial_secrets_file_contents = format!(
1324 r#"
1325[credentials]
1326[credentials.test]
1327[credentials.test.token_payload]
1328access_token = "initial_access_token"
1329refresh_token = "{initial_refresh_token}"
1330updated_at = "2024-01-01T00:00:00Z"
1331"#
1332 );
1333 jail.create_file(secrets_path, &initial_secrets_file_contents)
1334 .expect("should create test secrets.toml");
1335
1336 let rt = tokio::runtime::Runtime::new().unwrap();
1337 rt.block_on(async {
1338 let mock_server = MockServer::start_async().await;
1339 let oidc_mock = mock_server
1340 .mock_async(|when, then| {
1341 when.method(GET).path("/.well-known/openid-configuration");
1342 then.status(200)
1343 .json_body_obj(&oidc::Discovery::new_for_test(
1344 mock_server.base_url().parse().unwrap(),
1345 ));
1346 })
1347 .await;
1348 let issuer_mock = mock_server
1349 .mock_async(|when, then| {
1350 when.method(POST).path("/v1/token");
1351 then.status(200).json_body_obj(&RefreshTokenResponse {
1352 access_token: SecretAccessToken::from(new_access_token),
1353 refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token)),
1354 });
1355 })
1356 .await;
1357
1358 let dispatcher: TokenDispatcher = OAuthSession::from_refresh_token(
1359 RefreshToken::new(SecretRefreshToken::from(initial_refresh_token)),
1360 AuthServer {
1361 client_id: "client_id".to_string(),
1362 issuer: mock_server.base_url(),
1363 scopes: None,
1364 },
1365 Some(SecretAccessToken::from("initial_access_token")),
1366 )
1367 .into();
1368
1369 dispatcher
1370 .refresh(
1371 &ConfigSource::File {
1372 settings_path: "".into(),
1373 secrets_path: secrets_path.into(),
1374 },
1375 "test",
1376 )
1377 .await
1378 .expect("refresh should succeed");
1379
1380 oidc_mock.assert_async().await;
1381 issuer_mock.assert_async().await;
1382 });
1383
1384 let payload = Secrets::load_from_path(&secrets_path.into())
1386 .expect("should load secrets")
1387 .credentials
1388 .remove("test")
1389 .expect("should have test credentials")
1390 .token_payload
1391 .expect("should have token payload");
1392 assert_eq!(
1393 payload.refresh_token.unwrap(),
1394 SecretRefreshToken::from(rotated_refresh_token),
1395 "rotated refresh token should be persisted to the secrets file"
1396 );
1397 assert_eq!(
1398 payload.access_token.unwrap(),
1399 SecretAccessToken::from(new_access_token),
1400 "new access token should be persisted to the secrets file"
1401 );
1402
1403 Ok(())
1404 });
1405 }
1406
1407 #[test]
1408 fn test_auth_session_debug_fmt() {
1409 let session = OAuthSession {
1410 payload: OAuthGrant::ClientCredentials(ClientCredentials::new(
1411 "hidden_id",
1412 "hidden_secret",
1413 )),
1414 access_token: Some(SecretAccessToken::from("token")),
1415 auth_server: AuthServer {
1416 client_id: "some_id".into(),
1417 issuer: "some_url".into(),
1418 scopes: None,
1419 },
1420 };
1421
1422 assert_eq!(
1423 "OAuthSession { payload: ClientCredentials, access_token: Some(()), auth_server: AuthServer { client_id: \"some_id\", issuer: \"some_url\", scopes: None } }",
1424 &format!("{session:?}")
1425 );
1426 }
1427}