Skip to main content

chorus_client/
auth.rs

1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5use arc_swap::ArcSwap;
6use async_trait::async_trait;
7use google_cloud_auth::credentials::{AccessTokenCredentials, Builder as AdcCredentialsBuilder};
8use tonic::metadata::MetadataValue;
9
10use crate::error::Error;
11
12const GCS_READ_WRITE_SCOPE: &str = "https://www.googleapis.com/auth/devstorage.read_write";
13const TOKEN_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
14
15#[derive(Clone, Debug)]
16/// Controls how aggressively a refreshing credential is renewed.
17///
18/// The source is queried once during construction, then every
19/// [`refresh_interval`](Self::refresh_interval). A failed refresh keeps the
20/// last valid token and retries after [`retry_interval`](Self::retry_interval).
21pub struct RefreshingAuthConfig {
22    /// Delay between successful token refreshes. Choose a value comfortably
23    /// below the credential lifetime; the default is appropriate for ADC.
24    pub refresh_interval: Duration,
25    /// Backoff after a token-source failure. Existing clients continue using
26    /// the last valid token during this interval.
27    pub retry_interval: Duration,
28}
29
30impl Default for RefreshingAuthConfig {
31    fn default() -> Self {
32        Self {
33            refresh_interval: Duration::from_secs(30),
34            retry_interval: Duration::from_secs(5),
35        }
36    }
37}
38
39#[async_trait]
40/// Supplies bearer tokens to [`BearerAuth`].
41///
42/// Implement this for workload identity systems other than Google ADC. Return
43/// the raw token without a `Bearer ` prefix.
44pub trait AccessTokenSource: Send + Sync {
45    /// Fetch a currently usable access token.
46    async fn access_token(&self) -> Result<String, Error>;
47}
48
49#[derive(Clone)]
50/// Cloneable authentication handle shared by existing gRPC clients.
51///
52/// Refreshing instances publish new tokens through `ArcSwap`, so clones and
53/// already-created [`crate::GrpcReplicaFactory`] values observe updates without
54/// reconnecting. Dropping the last clone stops the refresh task.
55pub struct BearerAuth {
56    token: Arc<ArcSwap<String>>,
57    refresh_task: Option<Arc<RefreshTask>>,
58}
59
60impl fmt::Debug for BearerAuth {
61    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62        formatter
63            .debug_struct("BearerAuth")
64            .field("refreshing", &self.refresh_task.is_some())
65            .finish_non_exhaustive()
66    }
67}
68
69impl BearerAuth {
70    /// Create a non-refreshing token handle for tests or controlled deployments.
71    ///
72    /// Prefer [`google_adc`](Self::google_adc) in production.
73    pub fn static_token(token: impl Into<String>) -> Self {
74        Self {
75            token: Arc::new(ArcSwap::from_pointee(token.into())),
76            refresh_task: None,
77        }
78    }
79
80    /// Create a refreshing handle backed by a custom token source.
81    ///
82    /// Construction fetches and validates the initial token before returning;
83    /// callers never receive a handle without a usable credential.
84    pub async fn refreshing(
85        source: Arc<dyn AccessTokenSource>,
86        config: RefreshingAuthConfig,
87    ) -> Result<Self, Error> {
88        if config.refresh_interval.is_zero() || config.retry_interval.is_zero() {
89            return Err(Error::InvalidRefreshInterval);
90        }
91        let initial = source.access_token().await?;
92        validate_token(&initial)?;
93        let token = Arc::new(ArcSwap::from_pointee(initial));
94        let task_token = Arc::clone(&token);
95        let task = tokio::spawn(async move {
96            let mut delay = config.refresh_interval;
97            loop {
98                tokio::time::sleep(delay).await;
99                match fetch_refresh_token(source.as_ref(), TOKEN_REFRESH_TIMEOUT).await {
100                    Some(Ok(next)) => match validate_token(&next) {
101                        Ok(()) => {
102                            task_token.store(Arc::new(next));
103                            delay = config.refresh_interval;
104                        }
105                        Err(error) => {
106                            tracing::warn!(%error, "auth token refresh returned an invalid token");
107                            delay = config.retry_interval;
108                        }
109                    },
110                    Some(Err(error)) => {
111                        tracing::warn!(%error, "auth token refresh failed");
112                        delay = config.retry_interval;
113                    }
114                    None => {
115                        // google-cloud-auth's remote ADC sources use reqwest,
116                        // whose client has no total request timeout by default.
117                        // Bound only renewal: the last validated token remains
118                        // available while the source is retried.
119                        tracing::warn!(
120                            timeout_seconds = TOKEN_REFRESH_TIMEOUT.as_secs(),
121                            "auth token refresh timed out"
122                        );
123                        delay = config.retry_interval;
124                    }
125                }
126            }
127        });
128        Ok(Self {
129            token,
130            refresh_task: Some(Arc::new(RefreshTask(task))),
131        })
132    }
133
134    /// Create the production Google Application Default Credentials path.
135    ///
136    /// The credential requests the GCS read/write scope and refreshes in the
137    /// background according to `config`.
138    pub async fn google_adc(config: RefreshingAuthConfig) -> Result<Self, Error> {
139        let credentials = AdcCredentialsBuilder::default()
140            .with_scopes([GCS_READ_WRITE_SCOPE])
141            .build_access_token_credentials()
142            .map_err(|error| Error::TokenSource(error.to_string()))?;
143        Self::refreshing(Arc::new(GoogleAdcTokenSource(credentials)), config).await
144    }
145
146    pub(crate) fn authorization_header(
147        &self,
148    ) -> Result<MetadataValue<tonic::metadata::Ascii>, Error> {
149        let token = self.token.load();
150        MetadataValue::try_from(format!("Bearer {}", token.as_str()))
151            .map_err(|_| Error::InvalidToken)
152    }
153}
154
155struct RefreshTask(tokio::task::JoinHandle<()>);
156
157impl Drop for RefreshTask {
158    fn drop(&mut self) {
159        self.0.abort();
160    }
161}
162
163#[derive(Debug)]
164struct GoogleAdcTokenSource(AccessTokenCredentials);
165
166#[async_trait]
167impl AccessTokenSource for GoogleAdcTokenSource {
168    async fn access_token(&self) -> Result<String, Error> {
169        self.0
170            .access_token()
171            .await
172            .map(|token| token.token)
173            .map_err(|error| Error::TokenSource(error.to_string()))
174    }
175}
176
177async fn fetch_refresh_token(
178    source: &dyn AccessTokenSource,
179    timeout: Duration,
180) -> Option<Result<String, Error>> {
181    tokio::time::timeout(timeout, source.access_token())
182        .await
183        .ok()
184}
185
186fn validate_token(token: &str) -> Result<(), Error> {
187    if token.is_empty() {
188        return Err(Error::InvalidToken);
189    }
190    MetadataValue::try_from(format!("Bearer {token}"))
191        .map(|_| ())
192        .map_err(|_| Error::InvalidToken)
193}
194
195#[cfg(test)]
196mod tests {
197    use std::sync::atomic::{AtomicUsize, Ordering};
198
199    use super::*;
200
201    #[derive(Debug)]
202    struct CountingSource(AtomicUsize);
203
204    #[async_trait]
205    impl AccessTokenSource for CountingSource {
206        async fn access_token(&self) -> Result<String, Error> {
207            Ok(format!("token-{}", self.0.fetch_add(1, Ordering::SeqCst)))
208        }
209    }
210
211    #[tokio::test]
212    async fn refresh_updates_existing_auth_handles() {
213        let auth = BearerAuth::refreshing(
214            Arc::new(CountingSource(AtomicUsize::new(0))),
215            RefreshingAuthConfig {
216                refresh_interval: Duration::from_millis(10),
217                retry_interval: Duration::from_millis(5),
218            },
219        )
220        .await
221        .unwrap();
222        let existing_handle = auth.clone();
223        assert_eq!(
224            existing_handle.authorization_header().unwrap(),
225            "Bearer token-0"
226        );
227
228        tokio::time::timeout(Duration::from_secs(1), async {
229            loop {
230                if existing_handle.authorization_header().unwrap() != "Bearer token-0" {
231                    break;
232                }
233                tokio::time::sleep(Duration::from_millis(2)).await;
234            }
235        })
236        .await
237        .unwrap();
238        assert_ne!(
239            existing_handle.authorization_header().unwrap(),
240            "Bearer token-0"
241        );
242    }
243
244    #[derive(Debug)]
245    struct HangingSource;
246
247    #[async_trait]
248    impl AccessTokenSource for HangingSource {
249        async fn access_token(&self) -> Result<String, Error> {
250            std::future::pending().await
251        }
252    }
253
254    #[tokio::test]
255    async fn refresh_source_calls_can_be_bounded() {
256        let source = HangingSource;
257        assert!(fetch_refresh_token(&source, Duration::from_millis(10))
258            .await
259            .is_none());
260    }
261}