Skip to main content

qcs_api_client_common/configuration/
tokens.rs

1//! Models and utilities for managing `OAuth2` sessions.
2use 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/// A single type containing an access token and an associated refresh token.
31#[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    /// The token used to refresh the access token.
45    pub refresh_token: SecretRefreshToken,
46}
47
48impl RefreshToken {
49    /// Create a new [`RefreshToken`] with the given refresh token.
50    #[must_use]
51    pub const fn new(refresh_token: SecretRefreshToken) -> Self {
52        Self { refresh_token }
53    }
54
55    /// Request and return a new access token from the given authorization server using this refresh token.
56    /// Updates the refresh token in-place if the authorization server returns a new one.
57    ///
58    /// # Errors
59    ///
60    /// See [`TokenError`]
61    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        // `error_for_status()` discards the response body, which is where OAuth2 servers put the
77        // actual reason a refresh was rejected (e.g. `invalid_grant`). Log it before converting to
78        // an opaque error, since callers otherwise have no way to tell "the refresh token is
79        // expired/revoked" apart from "a network blip happened" - both currently look identical
80        // and silently fall back to an interactive login.
81        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/// A pair of Client ID and Client Secret, used to request an OAuth Client Credentials Grant
113#[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    /// The client ID
127    pub client_id: String,
128    /// The client secret.
129    pub client_secret: ClientSecret,
130}
131
132impl ClientCredentials {
133    #[must_use]
134    /// Construct a new [`ClientCredentials`]
135    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    /// Get the client ID.
143    #[must_use]
144    pub fn client_id(&self) -> &str {
145        &self.client_id
146    }
147
148    /// Get the client secret.
149    #[must_use]
150    pub const fn client_secret(&self) -> &ClientSecret {
151        &self.client_secret
152    }
153
154    /// Request and return an access token from the given auth server using this set of client credentials.
155    ///
156    /// # Errors
157    ///
158    /// See [`TokenError`]
159    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)]
194/// The Access (Bearer) and refresh (if available) tokens from a PKCE login.
195pub struct PkceFlow {
196    /// The access token.
197    pub access_token: SecretAccessToken,
198    /// The refresh token, if available.
199    pub refresh_token: Option<RefreshToken>,
200}
201
202/// Errors that can occur when attempting to perform a PKCE login flow.
203#[derive(Debug, thiserror::Error)]
204pub enum PkceFlowError {
205    /// Error that occurred while performing the PKCE login flow.
206    #[error(transparent)]
207    PkceLogin(#[from] PkceLoginError),
208    /// Error that occurred while fetching the discovery document from the `OAuth2` issuer.
209    #[error(transparent)]
210    Discovery(#[from] DiscoveryError),
211    /// Error that occurred while making http requests.
212    #[error(transparent)]
213    Request(#[from] qcs_dependencies_client::reqwest::Error),
214}
215
216impl PkceFlow {
217    /// Starts a new PKCE login flow to acquire a new set of tokens.
218    ///
219    /// # Errors
220    ///
221    /// See [`PkceFlowError`]
222    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    /// Returns the access token if it is valid, otherwise requests a new access token using the refresh token if available.
251    ///
252    /// # Errors
253    ///
254    /// See [`TokenError`]
255    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))]
287/// Specifies the [OAuth2 grant type](https://oauth.net/2/grant-types/) to use, along with the data
288/// needed to request said grant type.
289pub enum OAuthGrant {
290    /// Credentials that can be used to use with the [Refresh Token grant type](https://oauth.net/2/grant-types/refresh-token/).
291    RefreshToken(RefreshToken),
292    /// Payload that can be used to use the [Client Credentials grant type](https://oauth.net/2/grant-types/client-credentials/).
293    ClientCredentials(ClientCredentials),
294    /// Defers to a user provided function for access token requests.
295    ExternallyManaged(ExternallyManaged),
296    /// The tokens returned by the PKCE login that are an [Authorization Code grant type](https://oauth.net/2/pkce/).
297    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    /// Request a new access token from the given issuer using this grant type and payload.
326    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/// Manages the `OAuth2` authorization process and token lifecycle for accessing the QCS API.
354///
355/// This struct encapsulates the necessary information to request an access token
356/// from an authorization server, including the `OAuth2` grant type and any associated
357/// credentials or payload data.
358///
359/// # Fields
360///
361/// * `payload` - The `OAuth2` grant type and associated data that will be used to request an access token.
362/// * `access_token` - The access token currently in use, if any. If no token has been provided or requested yet, this will be `None`.
363/// * `auth_server` - The authorization server responsible for issuing tokens.
364#[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    /// The grant type to use to request an access token.
377    payload: OAuthGrant,
378    /// The access token that is currently in use. None if no token has been requested yet.
379    access_token: Option<SecretAccessToken>,
380    /// The [`AuthServer`] that issues the tokens.
381    auth_server: AuthServer,
382}
383
384impl OAuthSession {
385    /// Initialize a new set of [`Credentials`] using a [`GrantPayload`].
386    ///
387    /// Optionally include an `access_token`, if not included, then one can be requested
388    /// with [`Self::request_access_token`].
389    #[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    /// Initialize a new set of [`Credentials`] using an [`ExternallyManaged`].
403    ///
404    /// Optionally include an `access_token`, if not included, then one can be requested
405    /// with [`Self::request_access_token`].
406    #[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    /// Initialize a new set of [`Credentials`] using a [`RefreshToken`].
420    ///
421    /// Optionally include an `access_token`, if not included, then one can be requested
422    /// with [`Self::request_access_token`].
423    #[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    /// Initialize a new set of [`Credentials`] using [`ClientCredentials`].
433    ///
434    /// Optionally include an `access_token`, if not included, then one can be requested
435    /// with [`Self::request_access_token`].
436    #[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    /// Initialize a new set of [`Credentials`] using [`PkceFlow`].
450    ///
451    /// Optionally include an `access_token`, if not included, then one can be requested
452    /// with [`Self::request_access_token`].
453    #[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    /// Get the current access token.
463    ///
464    /// This is an unvalidated copy of the access token. Meaning it can become stale, or may
465    /// even be already be stale. See [`Self::validate`] and [`Self::request_access_token`].
466    ///
467    /// # Errors
468    ///
469    /// - [`TokenError::NoAccessToken`] if there is no access token
470    pub fn access_token(&self) -> Result<&SecretAccessToken, TokenError> {
471        self.access_token.as_ref().ok_or(TokenError::NoAccessToken)
472    }
473
474    /// Get the payload used to request an access token.
475    #[must_use]
476    pub const fn payload(&self) -> &OAuthGrant {
477        &self.payload
478    }
479
480    /// Request and return an updated access token using these credentials.
481    ///
482    /// # Errors
483    ///
484    /// See [`TokenError`]
485    #[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    /// The [`AuthServer`] that issues the tokens.
492    #[must_use]
493    pub const fn auth_server(&self) -> &AuthServer {
494        &self.auth_server
495    }
496
497    /// Validate the access token, returning it if it is valid, or an error describing why it is
498    /// invalid.
499    ///
500    /// # Errors
501    ///
502    /// - [`TokenError::NoAccessToken`] if an access token has not been requested.
503    /// - [`TokenError::InvalidAccessToken`] if the access token is invalid.
504    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
511/// Validates the access token's format and `exp` claim, but no other claims or
512/// signature. We do this only to determine if the token is expired and needs refreshing,
513/// there is no way to securely validate the token's signature on the client side.
514pub(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
544/// Persists `oauth_session`'s tokens to the secrets file backing `source`, if any.
545///
546/// This is a no-op if `source` is not file-backed ([`ConfigSource::Default`] or
547/// [`ConfigSource::Builder`]), or if the secrets file is read-only (see [`Secrets::is_read_only`]).
548///
549/// Every code path that obtains a new or refreshed [`OAuthSession`] (whether through the
550/// [`TokenDispatcher`], or through [`ClientConfiguration::load_with_login`]'s manual refresh and
551/// PKCE login branches) should call this so that a rotated refresh token isn't silently dropped.
552/// Otherwise, the next process to load the profile will retry a stale, already-consumed refresh
553/// token and be forced back into an interactive login.
554///
555/// # Errors
556///
557/// See [`WriteError`]
558pub(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    // Persist the fresh refresh token if the grant carries one, so that a rotated
576    // refresh token isn't lost on the next load. Both the PKCE and refresh-token
577    // grants can hold a refresh token that the auth server may have rotated.
578    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    // Nothing to persist without an access token; this shouldn't happen for a session that was
585    // just successfully refreshed or logged in, but there's nothing useful to write otherwise.
586    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/// A wrapper for [`OAuthSession`] that provides thread-safe access to the inner tokens.
602#[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    /// Executes a user-provided closure on a reference to the `Tokens` instance managed by the
630    /// dispatcher.
631    ///
632    /// This function locks the mutex, safely exposing the protected `Tokens` instance to the provided closure `f`.
633    /// It is designed to allow safe and controlled access to the `Tokens` instance for reading its state.
634    ///
635    /// # Parameters
636    /// - `f`: A closure that takes a reference to `Tokens` and returns a value of type `O`. The closure is called
637    ///   with the `Tokens` instance as an argument once the mutex is successfully locked.
638    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    /// Get a copy of the current access token.
647    #[must_use]
648    pub async fn tokens(&self) -> OAuthSession {
649        self.use_tokens(Clone::clone).await
650    }
651
652    /// Refreshes the tokens. Readers will be blocked until the refresh is complete.
653    ///
654    /// # Errors
655    ///
656    /// See [`TokenError`]
657    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    /// Validate the access token, returning it if it is valid, or an error describing why it is
667    /// invalid.
668    ///
669    /// # Errors
670    ///
671    /// - [`TokenError::NoAccessToken`] if there is no access token
672    /// - [`TokenError::InvalidAccessToken`] if the access token is invalid
673    pub async fn validate(&self) -> Result<SecretAccessToken, TokenError> {
674        self.use_tokens(OAuthSession::validate).await
675    }
676
677    /// If tokens are already being refreshed, wait and return the updated tokens. Otherwise, run
678    /// ``refresh_fn``.
679    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        // Always clean up the refreshing lock, even if write failed
705        *self.refreshing.lock().await = false;
706        self.notify_refreshed.notify_waiters();
707
708        // If write failed, return error with the valid oauth_session
709        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    /// Refreshes the tokens. Readers will be blocked until the refresh is complete. Returns a copy
720    /// of the updated [`Credentials`]
721    ///
722    /// # Errors
723    ///
724    /// See [`TokenError`]
725    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
735/// A function that asynchronously refreshes a token.
736pub type RefreshFunction = Box<dyn (Fn(AuthServer) -> RefreshResult) + Send + Sync>;
737
738/// A struct that manages access tokens by utilizing a user-provided refresh function.
739///
740/// The [`ExternallyManaged`] struct allows users to define custom logic for
741/// fetching or refreshing access tokens.
742#[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    /// Creates a new [`ExternallyManaged`] instance from a [`RefreshFunction`].
758    ///
759    /// Consider using [`ExternallyManaged::from_async`], and [`ExternallyManaged::from_sync`], if
760    /// they better fit your use case.
761    ///
762    /// # Arguments
763    ///
764    /// * `refresh_function` - A function or closure that asynchronously refreshes a token.
765    ///
766    /// # Example
767    ///
768    /// ```
769    /// use qcs_api_client_common::configuration::{settings::AuthServer, tokens::ExternallyManaged, TokenError};
770    /// use std::future::Future;
771    /// use std::pin::Pin;
772    /// use std::boxed::Box;
773    /// use std::error::Error;
774    ///
775    /// async fn example_refresh_function(_auth_server: AuthServer) -> Result<String, Box<dyn Error
776    /// + Send + Sync>> {
777    ///     Ok("new_token_value".to_string())
778    /// }
779    /// let token_manager = ExternallyManaged::new(|auth_server| Box::pin(example_refresh_function(auth_server)));
780    /// ```
781    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    /// Constructs a new [`ExternallyManaged`] instance using an async function or closure.
790    ///
791    /// This method simplifies the creation of the [`ExternallyManaged`] instance by handling
792    /// the boxing and pinning of the future internally.
793    ///
794    /// # Arguments
795    ///
796    /// * `refresh_function` - An async function or closure that returns a [`Future`] which, when awaited,
797    ///   produces a [`Result<String, TokenError>`].
798    ///
799    /// # Example
800    ///
801    /// ```
802    /// use qcs_api_client_common::configuration::{settings::AuthServer, tokens::ExternallyManaged, TokenError};
803    /// use tokio::runtime::Runtime;
804    /// use std::error::Error;
805    ///
806    /// async fn example_refresh_function(_auth_server: AuthServer) -> Result<String, Box<dyn Error
807    /// + Send + Sync>> {
808    ///     Ok("new_token_value".to_string())
809    /// }
810    ///
811    /// let token_manager = ExternallyManaged::from_async(example_refresh_function);
812    ///
813    /// let rt = Runtime::new().unwrap();
814    /// rt.block_on(async {
815    ///     match token_manager.request_access_token(&AuthServer::default()).await {
816    ///         Ok(token) => println!("Token: {token:?}"),
817    ///         Err(e) => println!("Failed to refresh token: {:?}", e),
818    ///     }
819    /// });
820    /// ```
821    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    /// Constructs a new [`ExternallyManaged`] instance using a synchronous function.
836    ///
837    /// The synchronous function is wrapped in an async block to fit the expected signature.
838    ///
839    /// # Arguments
840    ///
841    /// * `refresh_function` - A synchronous function that returns a [`Result<String, TokenError>`].
842    ///
843    /// # Example
844    ///
845    /// ```
846    /// use qcs_api_client_common::configuration::{settings::AuthServer, tokens::ExternallyManaged, TokenError};
847    /// use tokio::runtime::Runtime;
848    /// use std::error::Error;
849    ///
850    /// fn example_sync_refresh_function(_auth_server: AuthServer) -> Result<String, Box<dyn Error
851    /// + Send + Sync>> {
852    ///     Ok("sync_token_value".to_string())
853    /// }
854    ///
855    /// let token_manager = ExternallyManaged::from_sync(example_sync_refresh_function);
856    ///
857    /// let rt = Runtime::new().unwrap();
858    /// rt.block_on(async {
859    ///     match token_manager.request_access_token(&AuthServer::default()).await {
860    ///         Ok(token) => println!("Token: {token:?}"),
861    ///         Err(e) => println!("Failed to refresh token: {:?}", e),
862    ///     }
863    /// });
864    /// ```
865    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    /// Request an updated access token using the provided refresh function.
882    ///
883    /// # Errors
884    ///
885    /// Errors are propagated from the refresh function.
886    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/// Get and refresh access tokens
946#[async_trait::async_trait]
947pub trait TokenRefresher: Clone + std::fmt::Debug + Send {
948    /// The type to be returned in the event of a error during getting or
949    /// refreshing an access token
950    type Error;
951
952    /// Get and validate the current access token, refreshing it if it doesn't exist or is invalid.
953    async fn validated_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
954
955    /// Get the current access token, if any
956    async fn get_access_token(&self) -> Result<Option<SecretAccessToken>, Self::Error>;
957
958    /// Get a fresh access token
959    async fn refresh_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
960
961    /// Get the base URL for requests
962    #[cfg(feature = "tracing")]
963    fn base_url(&self) -> &str;
964
965    /// Get the tracing configuration
966    #[cfg(feature = "tracing-config")]
967    fn tracing_configuration(&self) -> Option<&TracingConfiguration>;
968
969    /// Returns whether the given URL should be traced. Following
970    /// [`TracingConfiguration::is_enabled`], this defaults to `true`.
971    #[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                // Token refresh succeeded but persistence failed. Extract and return the access token from the error.
1002                #[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
1028/// Get a default http client.
1029pub(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    /// When the auth server rejects a refresh token request (e.g. the refresh token was revoked
1137    /// or has expired), the failure should still surface as a normal error - not panic - even
1138    /// though the response body is read for logging before the error is returned.
1139    #[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            // Ignore any existing environment variables.
1222            jail.clear_env();
1223
1224            // Create a temporary secrets file
1225            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                // Set up the mock token endpoint
1251                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                // Create tokens and dispatcher
1263                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                // Test with QCS_SECRETS_READ_ONLY set first
1271                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                // Verify the file was not updated if QCS_SECRETS_READ_ONLY is set truthy
1294                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                // Verify the file was updated
1304                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    /// When the auth server rotates the refresh token, a [`OAuthGrant::RefreshToken`] grant should
1343    /// persist the new refresh token to the secrets file (not just the access token).
1344    #[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            // The rotated refresh token (and the new access token) should be persisted.
1416            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}