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}