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(eq, get_all, set_all, module = "qcs_api_client_common.configuration")
36)]
37pub struct RefreshToken {
38    /// The token used to refresh the access token.
39    pub refresh_token: SecretRefreshToken,
40}
41
42impl RefreshToken {
43    /// Create a new [`RefreshToken`] with the given refresh token.
44    #[must_use]
45    pub const fn new(refresh_token: SecretRefreshToken) -> Self {
46        Self { refresh_token }
47    }
48
49    /// Request and return a new access token from the given authorization server using this refresh token.
50    /// Updates the refresh token in-place if the authorization server returns a new one.
51    ///
52    /// # Errors
53    ///
54    /// See [`TokenError`]
55    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        // `error_for_status()` discards the response body, which is where OAuth2 servers put the
71        // actual reason a refresh was rejected (e.g. `invalid_grant`). Log it before converting to
72        // an opaque error, since callers otherwise have no way to tell "the refresh token is
73        // expired/revoked" apart from "a network blip happened" - both currently look identical
74        // and silently fall back to an interactive login.
75        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/// A pair of Client ID and Client Secret, used to request an OAuth Client Credentials Grant
107#[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    /// The client ID
115    pub client_id: String,
116    /// The client secret.
117    pub client_secret: ClientSecret,
118}
119
120impl ClientCredentials {
121    #[must_use]
122    /// Construct a new [`ClientCredentials`]
123    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    /// Get the client ID.
131    #[must_use]
132    pub fn client_id(&self) -> &str {
133        &self.client_id
134    }
135
136    /// Get the client secret.
137    #[must_use]
138    pub const fn client_secret(&self) -> &ClientSecret {
139        &self.client_secret
140    }
141
142    /// Request and return an access token from the given auth server using this set of client credentials.
143    ///
144    /// # Errors
145    ///
146    /// See [`TokenError`]
147    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)]
176/// The Access (Bearer) and refresh (if available) tokens from a PKCE login.
177pub struct PkceFlow {
178    /// The access token.
179    pub access_token: SecretAccessToken,
180    /// The refresh token, if available.
181    pub refresh_token: Option<RefreshToken>,
182}
183
184/// Errors that can occur when attempting to perform a PKCE login flow.
185#[derive(Debug, thiserror::Error)]
186pub enum PkceFlowError {
187    /// Error that occurred while performing the PKCE login flow.
188    #[error(transparent)]
189    PkceLogin(#[from] PkceLoginError),
190    /// Error that occurred while fetching the discovery document from the `OAuth2` issuer.
191    #[error(transparent)]
192    Discovery(#[from] DiscoveryError),
193    /// Error that occurred while making http requests.
194    #[error(transparent)]
195    Request(#[from] qcs_dependencies_client::reqwest::Error),
196}
197
198impl PkceFlow {
199    /// Starts a new PKCE login flow to acquire a new set of tokens.
200    ///
201    /// # Errors
202    ///
203    /// See [`PkceFlowError`]
204    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    /// Returns the access token if it is valid, otherwise requests a new access token using the refresh token if available.
233    ///
234    /// # Errors
235    ///
236    /// See [`TokenError`]
237    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))]
269/// Specifies the [OAuth2 grant type](https://oauth.net/2/grant-types/) to use, along with the data
270/// needed to request said grant type.
271pub enum OAuthGrant {
272    /// Credentials that can be used to use with the [Refresh Token grant type](https://oauth.net/2/grant-types/refresh-token/).
273    RefreshToken(RefreshToken),
274    /// Payload that can be used to use the [Client Credentials grant type](https://oauth.net/2/grant-types/client-credentials/).
275    ClientCredentials(ClientCredentials),
276    /// Defers to a user provided function for access token requests.
277    ExternallyManaged(ExternallyManaged),
278    /// The tokens returned by the PKCE login that are an [Authorization Code grant type](https://oauth.net/2/pkce/).
279    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    /// Request a new access token from the given issuer using this grant type and payload.
308    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/// Manages the `OAuth2` authorization process and token lifecycle for accessing the QCS API.
336///
337/// This struct encapsulates the necessary information to request an access token
338/// from an authorization server, including the `OAuth2` grant type and any associated
339/// credentials or payload data.
340///
341/// # Fields
342///
343/// * `payload` - The `OAuth2` grant type and associated data that will be used to request an access token.
344/// * `access_token` - The access token currently in use, if any. If no token has been provided or requested yet, this will be `None`.
345/// * `auth_server` - The authorization server responsible for issuing tokens.
346#[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    /// The grant type to use to request an access token.
354    payload: OAuthGrant,
355    /// The access token that is currently in use. None if no token has been requested yet.
356    access_token: Option<SecretAccessToken>,
357    /// The [`AuthServer`] that issues the tokens.
358    auth_server: AuthServer,
359}
360
361impl OAuthSession {
362    /// Initialize a new set of [`Credentials`] using a [`GrantPayload`].
363    ///
364    /// Optionally include an `access_token`, if not included, then one can be requested
365    /// with [`Self::request_access_token`].
366    #[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    /// Initialize a new set of [`Credentials`] using an [`ExternallyManaged`].
380    ///
381    /// Optionally include an `access_token`, if not included, then one can be requested
382    /// with [`Self::request_access_token`].
383    #[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    /// Initialize a new set of [`Credentials`] using a [`RefreshToken`].
397    ///
398    /// Optionally include an `access_token`, if not included, then one can be requested
399    /// with [`Self::request_access_token`].
400    #[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    /// Initialize a new set of [`Credentials`] using [`ClientCredentials`].
410    ///
411    /// Optionally include an `access_token`, if not included, then one can be requested
412    /// with [`Self::request_access_token`].
413    #[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    /// Initialize a new set of [`Credentials`] using [`PkceFlow`].
427    ///
428    /// Optionally include an `access_token`, if not included, then one can be requested
429    /// with [`Self::request_access_token`].
430    #[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    /// Get the current access token.
440    ///
441    /// This is an unvalidated copy of the access token. Meaning it can become stale, or may
442    /// even be already be stale. See [`Self::validate`] and [`Self::request_access_token`].
443    ///
444    /// # Errors
445    ///
446    /// - [`TokenError::NoAccessToken`] if there is no access token
447    pub fn access_token(&self) -> Result<&SecretAccessToken, TokenError> {
448        self.access_token.as_ref().ok_or(TokenError::NoAccessToken)
449    }
450
451    /// Get the payload used to request an access token.
452    #[must_use]
453    pub const fn payload(&self) -> &OAuthGrant {
454        &self.payload
455    }
456
457    /// Request and return an updated access token using these credentials.
458    ///
459    /// # Errors
460    ///
461    /// See [`TokenError`]
462    #[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    /// The [`AuthServer`] that issues the tokens.
469    #[must_use]
470    pub const fn auth_server(&self) -> &AuthServer {
471        &self.auth_server
472    }
473
474    /// Validate the access token, returning it if it is valid, or an error describing why it is
475    /// invalid.
476    ///
477    /// # Errors
478    ///
479    /// - [`TokenError::NoAccessToken`] if an access token has not been requested.
480    /// - [`TokenError::InvalidAccessToken`] if the access token is invalid.
481    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
488/// Validates the access token's format and `exp` claim, but no other claims or
489/// signature. We do this only to determine if the token is expired and needs refreshing,
490/// there is no way to securely validate the token's signature on the client side.
491pub(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
521/// Persists `oauth_session`'s tokens to the secrets file backing `source`, if any.
522///
523/// This is a no-op if `source` is not file-backed ([`ConfigSource::Default`] or
524/// [`ConfigSource::Builder`]), or if the secrets file is read-only (see [`Secrets::is_read_only`]).
525///
526/// Every code path that obtains a new or refreshed [`OAuthSession`] (whether through the
527/// [`TokenDispatcher`], or through [`ClientConfiguration::load_with_login`]'s manual refresh and
528/// PKCE login branches) should call this so that a rotated refresh token isn't silently dropped.
529/// Otherwise, the next process to load the profile will retry a stale, already-consumed refresh
530/// token and be forced back into an interactive login.
531///
532/// # Errors
533///
534/// See [`WriteError`]
535pub(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    // Persist the fresh refresh token if the grant carries one, so that a rotated
553    // refresh token isn't lost on the next load. Both the PKCE and refresh-token
554    // grants can hold a refresh token that the auth server may have rotated.
555    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    // Nothing to persist without an access token; this shouldn't happen for a session that was
562    // just successfully refreshed or logged in, but there's nothing useful to write otherwise.
563    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/// A wrapper for [`OAuthSession`] that provides thread-safe access to the inner tokens.
579#[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    /// Executes a user-provided closure on a reference to the `Tokens` instance managed by the
603    /// dispatcher.
604    ///
605    /// This function locks the mutex, safely exposing the protected `Tokens` instance to the provided closure `f`.
606    /// It is designed to allow safe and controlled access to the `Tokens` instance for reading its state.
607    ///
608    /// # Parameters
609    /// - `f`: A closure that takes a reference to `Tokens` and returns a value of type `O`. The closure is called
610    ///   with the `Tokens` instance as an argument once the mutex is successfully locked.
611    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    /// Get a copy of the current access token.
620    #[must_use]
621    pub async fn tokens(&self) -> OAuthSession {
622        self.use_tokens(Clone::clone).await
623    }
624
625    /// Refreshes the tokens. Readers will be blocked until the refresh is complete.
626    ///
627    /// # Errors
628    ///
629    /// See [`TokenError`]
630    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    /// Validate the access token, returning it if it is valid, or an error describing why it is
640    /// invalid.
641    ///
642    /// # Errors
643    ///
644    /// - [`TokenError::NoAccessToken`] if there is no access token
645    /// - [`TokenError::InvalidAccessToken`] if the access token is invalid
646    pub async fn validate(&self) -> Result<SecretAccessToken, TokenError> {
647        self.use_tokens(OAuthSession::validate).await
648    }
649
650    /// If tokens are already being refreshed, wait and return the updated tokens. Otherwise, run
651    /// ``refresh_fn``.
652    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        // Always clean up the refreshing lock, even if write failed
678        *self.refreshing.lock().await = false;
679        self.notify_refreshed.notify_waiters();
680
681        // If write failed, return error with the valid oauth_session
682        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    /// Refreshes the tokens. Readers will be blocked until the refresh is complete. Returns a copy
693    /// of the updated [`Credentials`]
694    ///
695    /// # Errors
696    ///
697    /// See [`TokenError`]
698    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
708/// A function that asynchronously refreshes a token.
709pub type RefreshFunction = Box<dyn (Fn(AuthServer) -> RefreshResult) + Send + Sync>;
710
711/// A struct that manages access tokens by utilizing a user-provided refresh function.
712///
713/// The [`ExternallyManaged`] struct allows users to define custom logic for
714/// fetching or refreshing access tokens.
715#[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    /// Creates a new [`ExternallyManaged`] instance from a [`RefreshFunction`].
727    ///
728    /// Consider using [`ExternallyManaged::from_async`], and [`ExternallyManaged::from_sync`], if
729    /// they better fit your use case.
730    ///
731    /// # Arguments
732    ///
733    /// * `refresh_function` - A function or closure that asynchronously refreshes a token.
734    ///
735    /// # Example
736    ///
737    /// ```
738    /// use qcs_api_client_common::configuration::{settings::AuthServer, tokens::ExternallyManaged, TokenError};
739    /// use std::future::Future;
740    /// use std::pin::Pin;
741    /// use std::boxed::Box;
742    /// use std::error::Error;
743    ///
744    /// async fn example_refresh_function(_auth_server: AuthServer) -> Result<String, Box<dyn Error
745    /// + Send + Sync>> {
746    ///     Ok("new_token_value".to_string())
747    /// }
748    /// let token_manager = ExternallyManaged::new(|auth_server| Box::pin(example_refresh_function(auth_server)));
749    /// ```
750    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    /// Constructs a new [`ExternallyManaged`] instance using an async function or closure.
759    ///
760    /// This method simplifies the creation of the [`ExternallyManaged`] instance by handling
761    /// the boxing and pinning of the future internally.
762    ///
763    /// # Arguments
764    ///
765    /// * `refresh_function` - An async function or closure that returns a [`Future`] which, when awaited,
766    ///   produces a [`Result<String, TokenError>`].
767    ///
768    /// # Example
769    ///
770    /// ```
771    /// use qcs_api_client_common::configuration::{settings::AuthServer, tokens::ExternallyManaged, TokenError};
772    /// use tokio::runtime::Runtime;
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    ///
780    /// let token_manager = ExternallyManaged::from_async(example_refresh_function);
781    ///
782    /// let rt = Runtime::new().unwrap();
783    /// rt.block_on(async {
784    ///     match token_manager.request_access_token(&AuthServer::default()).await {
785    ///         Ok(token) => println!("Token: {token:?}"),
786    ///         Err(e) => println!("Failed to refresh token: {:?}", e),
787    ///     }
788    /// });
789    /// ```
790    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    /// Constructs a new [`ExternallyManaged`] instance using a synchronous function.
805    ///
806    /// The synchronous function is wrapped in an async block to fit the expected signature.
807    ///
808    /// # Arguments
809    ///
810    /// * `refresh_function` - A synchronous function that returns a [`Result<String, TokenError>`].
811    ///
812    /// # Example
813    ///
814    /// ```
815    /// use qcs_api_client_common::configuration::{settings::AuthServer, tokens::ExternallyManaged, TokenError};
816    /// use tokio::runtime::Runtime;
817    /// use std::error::Error;
818    ///
819    /// fn example_sync_refresh_function(_auth_server: AuthServer) -> Result<String, Box<dyn Error
820    /// + Send + Sync>> {
821    ///     Ok("sync_token_value".to_string())
822    /// }
823    ///
824    /// let token_manager = ExternallyManaged::from_sync(example_sync_refresh_function);
825    ///
826    /// let rt = Runtime::new().unwrap();
827    /// rt.block_on(async {
828    ///     match token_manager.request_access_token(&AuthServer::default()).await {
829    ///         Ok(token) => println!("Token: {token:?}"),
830    ///         Err(e) => println!("Failed to refresh token: {:?}", e),
831    ///     }
832    /// });
833    /// ```
834    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    /// Request an updated access token using the provided refresh function.
851    ///
852    /// # Errors
853    ///
854    /// Errors are propagated from the refresh function.
855    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/// Get and refresh access tokens
915#[async_trait::async_trait]
916pub trait TokenRefresher: Clone + std::fmt::Debug + Send {
917    /// The type to be returned in the event of a error during getting or
918    /// refreshing an access token
919    type Error;
920
921    /// Get and validate the current access token, refreshing it if it doesn't exist or is invalid.
922    async fn validated_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
923
924    /// Get the current access token, if any
925    async fn get_access_token(&self) -> Result<Option<SecretAccessToken>, Self::Error>;
926
927    /// Get a fresh access token
928    async fn refresh_access_token(&self) -> Result<SecretAccessToken, Self::Error>;
929
930    /// Get the base URL for requests
931    #[cfg(feature = "tracing")]
932    fn base_url(&self) -> &str;
933
934    /// Get the tracing configuration
935    #[cfg(feature = "tracing-config")]
936    fn tracing_configuration(&self) -> Option<&TracingConfiguration>;
937
938    /// Returns whether the given URL should be traced. Following
939    /// [`TracingConfiguration::is_enabled`], this defaults to `true`.
940    #[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                // Token refresh succeeded but persistence failed. Extract and return the access token from the error.
971                #[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
997/// Get a default http client.
998pub(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    /// When the auth server rejects a refresh token request (e.g. the refresh token was revoked
1106    /// or has expired), the failure should still surface as a normal error - not panic - even
1107    /// though the response body is read for logging before the error is returned.
1108    #[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            // Ignore any existing environment variables.
1191            jail.clear_env();
1192
1193            // Create a temporary secrets file
1194            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                // Set up the mock token endpoint
1220                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                // Create tokens and dispatcher
1232                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                // Test with QCS_SECRETS_READ_ONLY set first
1240                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                // Verify the file was not updated if QCS_SECRETS_READ_ONLY is set truthy
1263                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                // Verify the file was updated
1273                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    /// When the auth server rotates the refresh token, a [`OAuthGrant::RefreshToken`] grant should
1312    /// persist the new refresh token to the secrets file (not just the access token).
1313    #[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            // The rotated refresh token (and the new access token) should be persisted.
1385            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}