1use crate::{Error, Result};
18use graph_r::fsio::now_ms;
19use std::future::Future;
20use std::sync::Mutex;
21use std::time::Duration;
22
23const METADATA_TOKEN: &str =
24 "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
25const TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
26const SKEW_MS: u64 = 60_000;
28
29#[derive(Clone)]
31pub struct AccessToken {
32 secret: String,
33 pub expires_at_ms: u64,
35}
36
37impl AccessToken {
38 #[must_use]
40 pub fn new(secret: impl Into<String>, expires_at_ms: u64) -> Self {
41 Self { secret: secret.into(), expires_at_ms }
42 }
43
44 #[must_use]
46 pub fn secret(&self) -> &str {
47 &self.secret
48 }
49
50 fn fresh(&self, now: u64) -> bool {
51 self.expires_at_ms > now.saturating_add(SKEW_MS)
52 }
53}
54
55impl std::fmt::Debug for AccessToken {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("AccessToken")
58 .field("secret", &"<redacted>")
59 .field("expires_at_ms", &self.expires_at_ms)
60 .finish()
61 }
62}
63
64pub trait TokenProvider {
66 type Token<'a>: Future<Output = Result<AccessToken>> + 'a
68 where
69 Self: 'a;
70
71 fn token(&self) -> Self::Token<'_>;
73
74 fn quota_project(&self) -> Option<&str> {
76 None
77 }
78}
79
80#[derive(Debug, Clone)]
82pub struct Static(AccessToken);
83
84impl Static {
85 #[must_use]
87 pub fn new(secret: impl Into<String>) -> Self {
88 Self(AccessToken::new(secret, u64::MAX))
89 }
90
91 #[must_use]
93 pub fn none() -> Self {
94 Self::new(String::new())
95 }
96}
97
98impl TokenProvider for Static {
99 type Token<'a> = std::future::Ready<Result<AccessToken>>;
100 fn token(&self) -> Self::Token<'_> {
101 std::future::ready(Ok(self.0.clone()))
102 }
103}
104
105#[derive(Debug, Default)]
106struct Cache(Mutex<Option<AccessToken>>);
107
108impl Cache {
109 fn get(&self, now: u64) -> Option<AccessToken> {
110 self.0
111 .lock()
112 .unwrap_or_else(std::sync::PoisonError::into_inner)
113 .as_ref()
114 .filter(|t| t.fresh(now))
115 .cloned()
116 }
117 fn put(&self, t: &AccessToken) {
118 *self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(t.clone());
119 }
120}
121
122#[derive(serde::Deserialize)]
123struct TokenResponse {
124 access_token: String,
125 expires_in: Option<u64>,
126}
127
128fn http_client() -> Result<reqwest::Client> {
129 reqwest::Client::builder()
130 .timeout(Duration::from_secs(10))
131 .redirect(reqwest::redirect::Policy::none())
132 .build()
133 .map_err(|e| Error::unavailable(format!("http client: {e}"), false))
134}
135
136async fn parse_token(resp: reqwest::Response) -> Result<AccessToken> {
137 let status = resp.status();
138 if !status.is_success() {
139 return Err(Error::Auth { message: format!("token endpoint answered {status}") });
140 }
141 let body =
142 resp.bytes().await.map_err(|_| Error::unavailable("token response truncated", true))?;
143 let t: TokenResponse = serde_json::from_slice(&body)
144 .map_err(|_| Error::Auth { message: "malformed token response".into() })?;
145 Ok(AccessToken::new(
146 t.access_token,
147 now_ms().saturating_add(t.expires_in.unwrap_or(300).saturating_mul(1000)),
148 ))
149}
150
151#[derive(Debug)]
153pub struct Metadata {
154 http: reqwest::Client,
155 url: String,
156 cache: Cache,
157}
158
159impl Metadata {
160 pub fn new() -> Result<Self> {
162 Self::with_url(METADATA_TOKEN)
163 }
164
165 pub fn with_url(url: &str) -> Result<Self> {
167 Ok(Self { http: http_client()?, url: url.to_owned(), cache: Cache::default() })
168 }
169}
170
171impl TokenProvider for Metadata {
172 type Token<'a> = impl Future<Output = Result<AccessToken>> + 'a;
173 fn token(&self) -> Self::Token<'_> {
174 async move {
175 if let Some(t) = self.cache.get(now_ms()) {
176 return Ok(t);
177 }
178 let resp = self
179 .http
180 .get(&self.url)
181 .header("Metadata-Flavor", "Google")
182 .send()
183 .await
184 .map_err(|_| Error::unavailable("metadata server unreachable", true))?;
185 let t = parse_token(resp).await?;
186 self.cache.put(&t);
187 Ok(t)
188 }
189 }
190}
191
192pub struct GcloudUser {
194 http: reqwest::Client,
195 client_id: String,
196 client_secret: String,
197 refresh_token: String,
198 quota_project: Option<String>,
199 token_uri: String,
200 cache: Cache,
201}
202
203impl std::fmt::Debug for GcloudUser {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.debug_struct("GcloudUser")
206 .field("quota_project", &self.quota_project)
207 .finish_non_exhaustive()
208 }
209}
210
211#[derive(serde::Deserialize)]
212struct UserFile {
213 #[serde(rename = "type")]
214 kind: String,
215 client_id: Option<String>,
216 client_secret: Option<String>,
217 refresh_token: Option<String>,
218 quota_project_id: Option<String>,
219}
220
221impl GcloudUser {
222 pub fn from_json(json: &[u8]) -> Result<Self> {
224 let f: UserFile = serde_json::from_slice(json)
225 .map_err(|_| Error::Auth { message: "unreadable credentials file".into() })?;
226 if f.kind != "authorized_user" {
227 return Err(Error::Auth {
228 message: format!("credentials of type {:?} are not supported", f.kind),
229 });
230 }
231 let (Some(client_id), Some(client_secret), Some(refresh_token)) =
232 (f.client_id, f.client_secret, f.refresh_token)
233 else {
234 return Err(Error::Auth { message: "incomplete user credentials".into() });
235 };
236 Ok(Self {
237 http: http_client()?,
238 client_id,
239 client_secret,
240 refresh_token,
241 quota_project: f.quota_project_id,
242 token_uri: TOKEN_URI.to_owned(),
243 cache: Cache::default(),
244 })
245 }
246
247 #[must_use]
249 pub fn with_token_uri(mut self, uri: &str) -> Self {
250 uri.clone_into(&mut self.token_uri);
251 self
252 }
253}
254
255impl TokenProvider for GcloudUser {
256 type Token<'a> = impl Future<Output = Result<AccessToken>> + 'a;
257 fn token(&self) -> Self::Token<'_> {
258 async move {
259 if let Some(t) = self.cache.get(now_ms()) {
260 return Ok(t);
261 }
262 let form = [
263 ("grant_type", "refresh_token"),
264 ("client_id", self.client_id.as_str()),
265 ("client_secret", self.client_secret.as_str()),
266 ("refresh_token", self.refresh_token.as_str()),
267 ];
268 let body = form
269 .iter()
270 .map(|(k, v)| format!("{k}={}", form_encode(v)))
271 .collect::<Vec<_>>()
272 .join("&");
273 let resp = self
274 .http
275 .post(&self.token_uri)
276 .header("content-type", "application/x-www-form-urlencoded")
277 .body(body)
278 .send()
279 .await
280 .map_err(|_| Error::unavailable("token endpoint unreachable", true))?;
281 let t = parse_token(resp).await?;
282 self.cache.put(&t);
283 Ok(t)
284 }
285 }
286
287 fn quota_project(&self) -> Option<&str> {
288 self.quota_project.as_deref()
289 }
290}
291
292fn form_encode(s: &str) -> String {
293 let mut out = String::with_capacity(s.len());
294 crate::key::encode_component(s, &mut out);
295 out
296}
297
298#[derive(Debug)]
300pub enum Adc {
301 Metadata(Metadata),
303 User(GcloudUser),
305 Static(Static),
307}
308
309impl Adc {
310 pub fn discover() -> Result<Self> {
313 if let Some(path) = std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS") {
314 let json = std::fs::read(path)
315 .map_err(|_| Error::Auth { message: "unreadable credentials file".into() })?;
316 return GcloudUser::from_json(&json).map(Adc::User);
317 }
318 if let Some(dir) = gcloud_config_dir(|name: &str| std::env::var_os(name), cfg!(windows)) {
319 if let Ok(json) = std::fs::read(dir.join("application_default_credentials.json")) {
320 return GcloudUser::from_json(&json).map(Adc::User);
321 }
322 }
323 Metadata::new().map(Adc::Metadata)
324 }
325}
326
327fn gcloud_config_dir<F>(var: F, windows: bool) -> Option<std::path::PathBuf>
339where
340 F: Fn(&str) -> Option<std::ffi::OsString>,
341{
342 let set = |name: &str| var(name).filter(|v| !v.is_empty());
343 if let Some(dir) = set("CLOUDSDK_CONFIG") {
344 return Some(std::path::PathBuf::from(dir));
345 }
346 if windows {
347 return set("APPDATA").map(|a| std::path::PathBuf::from(a).join("gcloud"));
348 }
349 set("HOME").map(|h| std::path::PathBuf::from(h).join(".config").join("gcloud"))
350}
351
352impl TokenProvider for Adc {
353 type Token<'a> = impl Future<Output = Result<AccessToken>> + 'a;
354 fn token(&self) -> Self::Token<'_> {
355 async move {
356 match self {
357 Adc::Metadata(m) => m.token().await,
358 Adc::User(u) => u.token().await,
359 Adc::Static(s) => s.token().await,
360 }
361 }
362 }
363
364 fn quota_project(&self) -> Option<&str> {
365 match self {
366 Adc::User(u) => u.quota_project(),
367 _ => None,
368 }
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn env<'a>(
377 pairs: &'a [(&'a str, &'a str)],
378 ) -> impl Fn(&str) -> Option<std::ffi::OsString> + 'a {
379 move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))
380 }
381
382 #[test]
383 fn windows_reads_appdata_even_when_home_is_set() {
384 let e = [("HOME", r"C:\Users\a"), ("APPDATA", r"C:\Users\a\AppData\Roaming")];
385 assert_eq!(
386 gcloud_config_dir(env(&e), true),
387 Some(std::path::PathBuf::from(r"C:\Users\a\AppData\Roaming").join("gcloud"))
388 );
389 }
390
391 #[test]
392 fn posix_reads_home_dot_config() {
393 let e = [("HOME", "/home/a"), ("APPDATA", "/ignored")];
394 assert_eq!(
395 gcloud_config_dir(env(&e), false),
396 Some(std::path::PathBuf::from("/home/a").join(".config").join("gcloud"))
397 );
398 }
399
400 #[test]
401 fn cloudsdk_config_wins_on_every_os() {
402 let e = [("CLOUDSDK_CONFIG", "/custom"), ("HOME", "/home/a"), ("APPDATA", "/appdata")];
403 for windows in [true, false] {
404 assert_eq!(
405 gcloud_config_dir(env(&e), windows),
406 Some(std::path::PathBuf::from("/custom"))
407 );
408 }
409 }
410
411 #[test]
412 fn empty_values_count_as_unset() {
413 let e = [("CLOUDSDK_CONFIG", ""), ("APPDATA", "")];
414 assert_eq!(gcloud_config_dir(env(&e), true), None);
415 assert_eq!(gcloud_config_dir(env(&[("HOME", "")]), false), None);
416 }
417
418 #[test]
419 fn tokens_are_never_printed() {
420 let t = AccessToken::new("ya29.secret", 1);
421 assert!(!format!("{t:?}").contains("ya29"));
422 let u = GcloudUser::from_json(
423 br#"{"type":"authorized_user","client_id":"id","client_secret":"cs","refresh_token":"rt","quota_project_id":"p"}"#,
424 )
425 .unwrap();
426 let dbg = format!("{u:?}");
427 assert!(!dbg.contains("cs") && !dbg.contains("rt"), "{dbg}");
428 assert_eq!(u.quota_project(), Some("p"));
429 assert!(GcloudUser::from_json(br#"{"type":"service_account"}"#).is_err());
430 }
431
432 #[test]
433 fn freshness_has_skew() {
434 let now = 1_000_000;
435 assert!(!AccessToken::new("x", now + 30_000).fresh(now));
436 assert!(AccessToken::new("x", now + 120_000).fresh(now));
437 }
438}