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 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("APPDATA"));
319 if let Some(home) = home {
320 let mut p = std::path::PathBuf::from(home);
321 if cfg!(windows) {
322 p.push("gcloud");
323 } else {
324 p.push(".config/gcloud");
325 }
326 p.push("application_default_credentials.json");
327 if let Ok(json) = std::fs::read(&p) {
328 return GcloudUser::from_json(&json).map(Adc::User);
329 }
330 }
331 Metadata::new().map(Adc::Metadata)
332 }
333}
334
335impl TokenProvider for Adc {
336 type Token<'a> = impl Future<Output = Result<AccessToken>> + 'a;
337 fn token(&self) -> Self::Token<'_> {
338 async move {
339 match self {
340 Adc::Metadata(m) => m.token().await,
341 Adc::User(u) => u.token().await,
342 Adc::Static(s) => s.token().await,
343 }
344 }
345 }
346
347 fn quota_project(&self) -> Option<&str> {
348 match self {
349 Adc::User(u) => u.quota_project(),
350 _ => None,
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn tokens_are_never_printed() {
361 let t = AccessToken::new("ya29.secret", 1);
362 assert!(!format!("{t:?}").contains("ya29"));
363 let u = GcloudUser::from_json(
364 br#"{"type":"authorized_user","client_id":"id","client_secret":"cs","refresh_token":"rt","quota_project_id":"p"}"#,
365 )
366 .unwrap();
367 let dbg = format!("{u:?}");
368 assert!(!dbg.contains("cs") && !dbg.contains("rt"), "{dbg}");
369 assert_eq!(u.quota_project(), Some("p"));
370 assert!(GcloudUser::from_json(br#"{"type":"service_account"}"#).is_err());
371 }
372
373 #[test]
374 fn freshness_has_skew() {
375 let now = 1_000_000;
376 assert!(!AccessToken::new("x", now + 30_000).fresh(now));
377 assert!(AccessToken::new("x", now + 120_000).fresh(now));
378 }
379}