Skip to main content

gcloud_sdk/token_source/
auth_token_generator.rs

1use hyper::header::HeaderValue;
2use jiff::{SignedDuration, Timestamp};
3use tokio::sync::RwLock;
4
5use crate::token_source::*;
6use tracing::*;
7
8/// A token together with its pre-validated `authorization` header value, so that
9/// serving a cache hit never re-parses or re-allocates the header.
10struct CachedToken {
11    token: Token,
12    authorization: HeaderValue,
13}
14
15impl CachedToken {
16    fn from_token(token: Token) -> crate::error::Result<Self> {
17        let authorization = token.authorization()?;
18        Ok(Self {
19            token,
20            authorization,
21        })
22    }
23}
24
25pub struct GoogleAuthTokenGenerator {
26    token_source: BoxSource,
27    cached_token: RwLock<Option<CachedToken>>,
28}
29
30impl GoogleAuthTokenGenerator {
31    pub async fn new(
32        token_source_type: TokenSourceType,
33        token_scopes: Vec<String>,
34    ) -> crate::error::Result<GoogleAuthTokenGenerator> {
35        let token_source: BoxSource = create_source(token_source_type, token_scopes).await?;
36        Ok(Self::from_source(token_source))
37    }
38
39    /// A generator that caches the tokens of `token_source` and asks it for a new one
40    /// shortly before the cached one expires. Pass an
41    /// [`IdTokenSource`](crate::IdTokenSource) to serve ID tokens.
42    pub fn from_source(token_source: impl Into<BoxSource>) -> GoogleAuthTokenGenerator {
43        GoogleAuthTokenGenerator {
44            token_source: token_source.into(),
45            cached_token: RwLock::new(None),
46        }
47    }
48
49    pub async fn clear_cache(&self) {
50        let mut write_state = self.cached_token.write().await;
51        *write_state = None;
52    }
53
54    pub async fn create_token(&self) -> crate::error::Result<Token> {
55        self.with_cached(|cached| cached.token.clone()).await
56    }
57
58    /// The `authorization` header value for the current token, already validated
59    /// and marked sensitive; cloning it is a `Bytes` refcount bump, not an allocation.
60    pub async fn authorization_header(&self) -> crate::error::Result<HeaderValue> {
61        self.with_cached(|cached| cached.authorization.clone())
62            .await
63    }
64
65    /// Runs the double-checked refresh and applies `pick` to the resulting cached
66    /// token by reference, so a cache hit clones only what the caller asks for.
67    async fn with_cached<R>(
68        &self,
69        pick: impl FnOnce(&CachedToken) -> R,
70    ) -> crate::error::Result<R> {
71        let now = Timestamp::now();
72        // Give a bit more time for the network call than the token strictly has left;
73        // both the read-only fast path and the write-lock recheck below must use this
74        // same threshold, or a token can sit inside the margin forever, with every
75        // caller taking the write lock and getting the stale token back without ever
76        // triggering the refresh the margin exists to trigger.
77        let refresh_after = now.add(SignedDuration::from_secs(15));
78
79        {
80            let read_state = self.cached_token.read().await;
81            if let Some(cached) = read_state.as_ref() {
82                if cached.token.expiry.gt(&refresh_after) {
83                    return Ok(pick(cached));
84                }
85            }
86        }
87
88        let mut write_token = self.cached_token.write().await;
89        match write_token.as_ref() {
90            Some(updated_cached) if updated_cached.token.expiry.gt(&refresh_after) => {
91                Ok(pick(updated_cached))
92            }
93            _ => {
94                let new_token = self.token_source.token().await?;
95                debug!(
96                    "Created a new Google OAuth token. Type: {}. Expiring: {}.",
97                    new_token.token_type, new_token.expiry,
98                );
99                let new_cached = CachedToken::from_token(new_token)?;
100                let result = pick(&new_cached);
101                *write_token = Some(new_cached);
102                Ok(result)
103            }
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::token_source::Source;
112    use async_trait::async_trait;
113    use secret_vault_value::SecretValue;
114    use std::sync::atomic::{AtomicUsize, Ordering};
115    use std::sync::Arc;
116
117    struct CountingSource {
118        calls: Arc<AtomicUsize>,
119    }
120
121    #[async_trait]
122    impl Source for CountingSource {
123        async fn token(&self) -> crate::error::Result<Token> {
124            self.calls.fetch_add(1, Ordering::SeqCst);
125            Ok(Token {
126                token_type: "Bearer".to_string(),
127                token: SecretValue::from("cached-token"),
128                expiry: Timestamp::now() + SignedDuration::from_hours(1),
129            })
130        }
131    }
132
133    #[tokio::test]
134    async fn authorization_header_reuses_cached_token() {
135        let calls = Arc::new(AtomicUsize::new(0));
136        let source = CountingSource {
137            calls: calls.clone(),
138        };
139        let generator = GoogleAuthTokenGenerator::new(
140            TokenSourceType::ExternalSource(Box::new(source)),
141            vec![],
142        )
143        .await
144        .unwrap();
145
146        let first = generator.authorization_header().await.unwrap();
147        let second = generator.authorization_header().await.unwrap();
148
149        assert_eq!(first, second);
150        assert!(first.is_sensitive());
151        assert_eq!(calls.load(Ordering::SeqCst), 1);
152    }
153
154    struct FixedExpirySource {
155        calls: Arc<AtomicUsize>,
156        expires_in: SignedDuration,
157    }
158
159    #[async_trait]
160    impl Source for FixedExpirySource {
161        async fn token(&self) -> crate::error::Result<Token> {
162            self.calls.fetch_add(1, Ordering::SeqCst);
163            Ok(Token {
164                token_type: "Bearer".to_string(),
165                token: SecretValue::from("margin-token"),
166                expiry: Timestamp::now() + self.expires_in,
167            })
168        }
169    }
170
171    #[tokio::test]
172    async fn token_inside_refresh_margin_is_refreshed() {
173        let calls = Arc::new(AtomicUsize::new(0));
174        let source = FixedExpirySource {
175            calls: calls.clone(),
176            expires_in: SignedDuration::from_secs(10),
177        };
178        let generator = GoogleAuthTokenGenerator::new(
179            TokenSourceType::ExternalSource(Box::new(source)),
180            vec![],
181        )
182        .await
183        .unwrap();
184
185        generator.authorization_header().await.unwrap();
186        generator.authorization_header().await.unwrap();
187
188        assert_eq!(calls.load(Ordering::SeqCst), 2);
189    }
190}