Skip to main content

dynamic_config_firestore/
auth.rs

1//! Getting an access token, and getting another one before it expires.
2//!
3//! *When* to fetch another one is [`Cached`]'s decision, shared with the
4//! Consul and Vault crates; what a token is worth fetching from, and how, is
5//! this module's. Firestore is the simplest of the three: the metadata server
6//! mints a token and cannot extend one, so there is no renewal path to choose
7//! between.
8
9use std::time::Duration;
10
11use dynamic_config::Error;
12use dynamic_config_store_core::credential::{Cached, Issued};
13
14/// Where a Google workload asks for its own token. Reachable from GKE, Cloud
15/// Run, GCE and Cloud Functions, and from nowhere else — which is the security
16/// property that makes it the right default.
17const METADATA_TOKEN_URL: &str =
18    "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
19
20/// How to obtain an access token for the Firestore API.
21#[derive(Clone)]
22#[non_exhaustive]
23pub enum Auth {
24    /// No token at all, for the Firestore emulator.
25    Emulator,
26
27    /// A token somebody already obtained.
28    ///
29    /// `gcloud auth print-access-token` produces one; so does any library that
30    /// already handles Google credentials. It expires, and this cannot renew
31    /// it — install a fresh source when it does, or use
32    /// [`metadata_server`](Self::metadata_server), which can.
33    AccessToken(String),
34
35    /// The workload's own identity, from the metadata server.
36    ///
37    /// The right answer on GKE, Cloud Run, GCE and Cloud Functions: no secret
38    /// is distributed, the token is short-lived, and it is renewed here as it
39    /// approaches expiry.
40    MetadataServer {
41        /// Where to ask. The conventional address unless a sidecar proxies it.
42        url: String,
43    },
44}
45
46impl Auth {
47    /// A token somebody already obtained.
48    pub fn access_token(token: impl Into<String>) -> Self {
49        Self::AccessToken(token.into())
50    }
51
52    /// The workload's own identity, from the conventional metadata address.
53    #[must_use]
54    pub fn metadata_server() -> Self {
55        Self::MetadataServer {
56            url: METADATA_TOKEN_URL.to_owned(),
57        }
58    }
59
60    /// Asks somewhere other than the conventional address.
61    #[must_use]
62    pub fn with_url(mut self, url: impl Into<String>) -> Self {
63        if let Self::MetadataServer { url: existing } = &mut self {
64            *existing = url.into();
65        }
66
67        self
68    }
69}
70
71// Debug is hand-written for every type on this page that can hold a secret:
72// a derive prints payloads, and the payload here is a live GCP access token.
73impl std::fmt::Debug for Auth {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::Emulator => f.write_str("Emulator"),
77            Self::AccessToken(_) => f.write_str("AccessToken(***)"),
78            Self::MetadataServer { url } => {
79                f.debug_struct("MetadataServer").field("url", url).finish()
80            }
81        }
82    }
83}
84
85/// The current token for one source.
86#[derive(Debug, Default)]
87pub(crate) struct Session {
88    token: Cached<String>,
89}
90
91impl Session {
92    pub(crate) const fn new() -> Self {
93        Self {
94            token: Cached::new(),
95        }
96    }
97
98    /// The token to present, fetching one if it is time.
99    ///
100    /// `Ok(None)` when there is nothing to present, which is the right answer
101    /// for the emulator.
102    ///
103    /// Only the metadata server's token is cached: the emulator presents
104    /// nothing, and a token handed in from outside is the same string every
105    /// time, so a cache in front of either would be a lock around a constant.
106    pub(crate) fn token(&self, auth: &Auth, agent: &ureq::Agent) -> Result<Option<String>, Error> {
107        match auth {
108            Auth::Emulator => Ok(None),
109            Auth::AccessToken(token) => Ok(Some(token.clone())),
110            Auth::MetadataServer { url } => self
111                // The previous token is ignored: the metadata server mints,
112                // it does not extend.
113                .token
114                .get(|_previous| Self::mint(url, agent))
115                .map(Some),
116        }
117    }
118
119    /// One trip to the metadata server.
120    fn mint(url: &str, agent: &ureq::Agent) -> Result<Issued<String>, Error> {
121        let response: serde_json::Value = agent
122            .get(url)
123            // Without this header the metadata server refuses, which is what
124            // stops a confused browser or a proxied request from reading a
125            // workload's credentials.
126            .header("Metadata-Flavor", "Google")
127            .call()
128            .map_err(|error| {
129                let described = format!("firestore: the metadata server: {error}");
130
131                // A token that could not be obtained is an auth failure, but
132                // only when the metadata server *answered* and refused —
133                // 403 is what it says when the `Metadata-Flavor` header is
134                // missing or the workload has no identity attached. Being
135                // unable to reach it at all is `Remote`: it comes back.
136                match error {
137                    ureq::Error::StatusCode(401 | 403) => Error::auth(described),
138                    _ => Error::remote(described),
139                }
140            })?
141            .body_mut()
142            .read_json()
143            .map_err(|error| {
144                Error::remote(format!(
145                    "firestore: the metadata server's response was not JSON: {error}"
146                ))
147            })?;
148
149        let secret = response
150            .get("access_token")
151            .and_then(serde_json::Value::as_str)
152            .ok_or_else(|| {
153                Error::remote("firestore: the metadata server returned no `access_token`")
154            })?
155            .to_owned();
156
157        // Zero seconds is a lifetime nothing can be done with, so it is read
158        // as "the server said nothing" rather than "already expired".
159        let ttl = response
160            .get("expires_in")
161            .and_then(serde_json::Value::as_u64)
162            .filter(|seconds| *seconds > 0)
163            .map(Duration::from_secs);
164
165        Ok(Issued { value: secret, ttl })
166    }
167
168    /// Throws the current token away, so the next request fetches one.
169    pub(crate) fn invalidate(&self) {
170        self.token.invalidate();
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn the_emulator_presents_nothing() {
180        let agent = ureq::Agent::new_with_defaults();
181
182        assert!(Session::new()
183            .token(&Auth::Emulator, &agent)
184            .unwrap()
185            .is_none());
186    }
187
188    #[test]
189    fn a_supplied_token_is_presented_as_it_is() {
190        let agent = ureq::Agent::new_with_defaults();
191
192        assert_eq!(
193            Session::new()
194                .token(&Auth::access_token("ya29.abc"), &agent)
195                .unwrap()
196                .as_deref(),
197            Some("ya29.abc")
198        );
199    }
200
201    #[test]
202    fn the_metadata_url_can_be_moved_for_a_sidecar() {
203        let auth = Auth::metadata_server().with_url("http://127.0.0.1:8081/token");
204
205        let Auth::MetadataServer { url } = auth else {
206            panic!("still a metadata auth");
207        };
208
209        assert_eq!(url, "http://127.0.0.1:8081/token");
210    }
211
212    // When a token is stale, what a lifetime too large to represent means,
213    // and that a fetch happens once rather than per request, are
214    // `dynamic-config-store-core`'s tests now: they were the same three
215    // assertions here, in the Consul crate and in the Vault crate, over the
216    // same code.
217}