Skip to main content

wiremux_auth/providers/
gcp.rs

1//! Google Cloud TokenProvider: service-account JWT or user ADC refresh.
2
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use serde::Deserialize;
9use tokio::sync::RwLock;
10
11use crate::TokenProvider;
12use crate::error::AuthError;
13use crate::helpers::{
14    InFlight, MAX_CREDS_BYTES, duration_from_expires_in_secs, lead_or_follow, oauth_http_client,
15    post_form_url, redact_url_origin, vendor_rejected_summary,
16};
17
18const DEFAULT_LIFETIME_SECS: u64 = 3600;
19const DEFAULT_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
20const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
21const JWT_BEARER: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
22const REFRESH_TOKEN: &str = "refresh_token";
23const ADC_FILE: &str = "application_default_credentials.json";
24
25struct CachedToken {
26    access_token: String,
27    acquired_at: Instant,
28    lifetime: Duration,
29}
30
31impl CachedToken {
32    fn needs_refresh(&self) -> bool {
33        self.acquired_at.elapsed() >= self.lifetime.mul_f64(0.8)
34    }
35}
36
37/// Service-account JWT bearer grant, or `authorized_user` ADC refresh.
38#[derive(Clone)]
39pub struct GcpTokenProvider {
40    inner: Arc<Inner>,
41}
42
43enum GcpGrant {
44    ServiceAccount {
45        client_email: String,
46        private_key_pem: String,
47        scope: String,
48    },
49    AuthorizedUser {
50        client_id: String,
51        client_secret: String,
52        refresh_token: String,
53        quota_project_id: Option<String>,
54    },
55}
56
57struct Inner {
58    grant: GcpGrant,
59    token_uri: String,
60    http: reqwest::Client,
61    force_refresh: AtomicBool,
62    state: RwLock<Option<CachedToken>>,
63    inflight: InFlight,
64}
65
66impl std::fmt::Debug for GcpTokenProvider {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        let mut dbg = f.debug_struct("GcpTokenProvider");
69        match &self.inner.grant {
70            GcpGrant::ServiceAccount { client_email, .. } => {
71                dbg.field("grant", &"service_account")
72                    .field("client_email", client_email)
73                    .field("private_key", &"[REDACTED]");
74            }
75            GcpGrant::AuthorizedUser {
76                client_id,
77                quota_project_id,
78                ..
79            } => {
80                dbg.field("grant", &"authorized_user")
81                    .field("client_id", client_id)
82                    .field("client_secret", &"[REDACTED]")
83                    .field("refresh_token", &"[REDACTED]");
84                if let Some(quota) = quota_project_id {
85                    dbg.field("quota_project_id", quota);
86                }
87            }
88        }
89        dbg.field("token_uri", &redact_url_origin(&self.inner.token_uri))
90            .finish()
91    }
92}
93
94#[derive(Debug, Deserialize)]
95struct AdcFile {
96    #[serde(rename = "type")]
97    kind: Option<String>,
98    #[serde(default)]
99    client_email: Option<String>,
100    #[serde(default)]
101    private_key: Option<String>,
102    #[serde(default)]
103    client_id: Option<String>,
104    #[serde(default)]
105    client_secret: Option<String>,
106    #[serde(default)]
107    refresh_token: Option<String>,
108    #[serde(default)]
109    token_uri: Option<String>,
110    #[serde(default)]
111    scope: Option<String>,
112    #[serde(default)]
113    quota_project_id: Option<String>,
114}
115
116/// Well-known `gcloud` ADC path (`CLOUDSDK_CONFIG`, else `~/.config/gcloud`).
117pub fn default_adc_path() -> Option<PathBuf> {
118    if let Ok(dir) = std::env::var("CLOUDSDK_CONFIG")
119        && !dir.trim().is_empty()
120    {
121        return Some(PathBuf::from(dir).join(ADC_FILE));
122    }
123    #[cfg(windows)]
124    if let Some(appdata) = std::env::var_os("APPDATA")
125        && !appdata.is_empty()
126    {
127        return Some(PathBuf::from(appdata).join("gcloud").join(ADC_FILE));
128    }
129    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
130    if home.is_empty() {
131        return None;
132    }
133    Some(
134        PathBuf::from(home)
135            .join(".config")
136            .join("gcloud")
137            .join(ADC_FILE),
138    )
139}
140
141impl GcpTokenProvider {
142    /// Build from a service-account key or `authorized_user` ADC JSON file.
143    pub fn from_key_file(path: impl AsRef<Path>) -> Result<Self, AuthError> {
144        let path = path.as_ref();
145        let meta =
146            std::fs::metadata(path).map_err(|e| AuthError::io(Some(path.to_path_buf()), e))?;
147        if meta.len() > MAX_CREDS_BYTES {
148            return Err(AuthError::TokenProvider(format!(
149                "GCP key file too large ({} bytes)",
150                meta.len()
151            )));
152        }
153        let text = std::fs::read_to_string(path)
154            .map_err(|e| AuthError::io(Some(path.to_path_buf()), e))?;
155        Self::from_key(&text)
156    }
157
158    /// Billing project from `authorized_user` ADC `quota_project_id`, if set.
159    #[must_use]
160    pub fn quota_project_id(&self) -> Option<&str> {
161        match &self.inner.grant {
162            GcpGrant::AuthorizedUser {
163                quota_project_id, ..
164            } => quota_project_id.as_deref(),
165            GcpGrant::ServiceAccount { .. } => None,
166        }
167    }
168
169    /// Build from service-account JSON or `authorized_user` ADC JSON.
170    pub fn from_key(json: &str) -> Result<Self, AuthError> {
171        let file: AdcFile = serde_json::from_str(json)
172            .map_err(|e| AuthError::TokenProvider(format!("GCP credentials: {e}")))?;
173        let token_uri = file
174            .token_uri
175            .as_deref()
176            .map(str::trim)
177            .filter(|s| !s.is_empty())
178            .unwrap_or(DEFAULT_TOKEN_URI)
179            .to_string();
180        let grant = grant_from_adc(file)?;
181        Ok(Self {
182            inner: Arc::new(Inner {
183                grant,
184                token_uri,
185                http: oauth_http_client()?,
186                force_refresh: AtomicBool::new(false),
187                state: RwLock::new(None),
188                inflight: InFlight::new(),
189            }),
190        })
191    }
192
193    fn signed_jwt(&self) -> Result<String, AuthError> {
194        let GcpGrant::ServiceAccount {
195            client_email,
196            private_key_pem,
197            scope,
198        } = &self.inner.grant
199        else {
200            return Err(AuthError::TokenProvider(
201                "GCP authorized_user has no JWT key".into(),
202            ));
203        };
204        let now = SystemTime::now()
205            .duration_since(UNIX_EPOCH)
206            .unwrap_or_default()
207            .as_secs();
208        let claims = GcpJwtClaims {
209            iss: client_email.clone(),
210            sub: client_email.clone(),
211            aud: self.inner.token_uri.clone(),
212            iat: now,
213            exp: now + 3600,
214            scope: scope.clone(),
215        };
216        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
217        header.typ = Some("JWT".into());
218        let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
219            .map_err(|e| AuthError::TokenProvider(format!("GCP private_key PEM: {e}")))?;
220        jsonwebtoken::encode(&header, &claims, &key)
221            .map_err(|e| AuthError::TokenProvider(format!("GCP JWT sign: {e}")))
222    }
223
224    async fn refresh(&self, force: bool) -> Result<String, AuthError> {
225        {
226            let state = self.inner.state.read().await;
227            if !force
228                && let Some(tok) = state.as_ref()
229                && !tok.needs_refresh()
230            {
231                return Ok(tok.access_token.clone());
232            }
233        }
234        let (status, body) = match &self.inner.grant {
235            GcpGrant::ServiceAccount { .. } => {
236                let assertion = self.signed_jwt()?;
237                let form = [
238                    ("grant_type", JWT_BEARER),
239                    ("assertion", assertion.as_str()),
240                ];
241                post_form_url(&self.inner.http, &self.inner.token_uri, &form).await?
242            }
243            GcpGrant::AuthorizedUser {
244                client_id,
245                client_secret,
246                refresh_token,
247                ..
248            } => {
249                let form = [
250                    ("grant_type", REFRESH_TOKEN),
251                    ("client_id", client_id.as_str()),
252                    ("client_secret", client_secret.as_str()),
253                    ("refresh_token", refresh_token.as_str()),
254                ];
255                post_form_url(&self.inner.http, &self.inner.token_uri, &form).await?
256            }
257        };
258        if !(200..300).contains(&status) {
259            return Err(AuthError::VendorRejected {
260                status,
261                summary: vendor_rejected_summary(
262                    "GCP token request failed",
263                    &body,
264                    &self.inner.token_uri,
265                ),
266            });
267        }
268        let parsed = parse_gcp_token(&body)?;
269        if parsed.access_token.trim().is_empty() {
270            return Err(AuthError::EmptyWriteRefused);
271        }
272        let lifetime =
273            duration_from_expires_in_secs(parsed.expires_in.unwrap_or(DEFAULT_LIFETIME_SECS));
274        let token = parsed.access_token.clone();
275        *self.inner.state.write().await = Some(CachedToken {
276            access_token: parsed.access_token,
277            acquired_at: Instant::now(),
278            lifetime,
279        });
280        Ok(token)
281    }
282}
283
284#[derive(serde::Serialize)]
285struct GcpJwtClaims {
286    iss: String,
287    sub: String,
288    aud: String,
289    iat: u64,
290    exp: u64,
291    scope: String,
292}
293
294struct ParsedGcpToken {
295    access_token: String,
296    expires_in: Option<u64>,
297}
298
299fn grant_from_adc(file: AdcFile) -> Result<GcpGrant, AuthError> {
300    let kind = file.kind.as_deref().unwrap_or("").trim();
301    match kind {
302        "authorized_user" => {
303            let client_id = file.client_id.unwrap_or_default();
304            let client_secret = file.client_secret.unwrap_or_default();
305            let refresh_token = file.refresh_token.unwrap_or_default();
306            if client_id.trim().is_empty() {
307                return Err(AuthError::MissingField("GCP client_id".into()));
308            }
309            if client_secret.trim().is_empty() {
310                return Err(AuthError::MissingField("GCP client_secret".into()));
311            }
312            if refresh_token.trim().is_empty() {
313                return Err(AuthError::MissingField("GCP refresh_token".into()));
314            }
315            let quota_project_id = file
316                .quota_project_id
317                .map(|s| s.trim().to_string())
318                .filter(|s| !s.is_empty());
319            Ok(GcpGrant::AuthorizedUser {
320                client_id,
321                client_secret,
322                refresh_token,
323                quota_project_id,
324            })
325        }
326        "service_account" | "" => {
327            let client_email = file.client_email.unwrap_or_default();
328            let private_key = file.private_key.unwrap_or_default();
329            if client_email.trim().is_empty() {
330                return Err(AuthError::MissingField("GCP client_email".into()));
331            }
332            if private_key.trim().is_empty() {
333                return Err(AuthError::MissingField("GCP private_key".into()));
334            }
335            let scope = file
336                .scope
337                .filter(|s| !s.trim().is_empty())
338                .unwrap_or_else(|| DEFAULT_SCOPE.to_string());
339            Ok(GcpGrant::ServiceAccount {
340                client_email,
341                private_key_pem: private_key,
342                scope,
343            })
344        }
345        other => Err(AuthError::TokenProvider(format!(
346            "GCP ADC type `{other}` is not supported (service_account or authorized_user)"
347        ))),
348    }
349}
350
351fn parse_gcp_token(body: &str) -> Result<ParsedGcpToken, AuthError> {
352    let doc: serde_json::Value = serde_json::from_str(body)
353        .map_err(|e| AuthError::TokenProvider(format!("GCP token: invalid JSON: {e}")))?;
354    let access_token = doc
355        .get("access_token")
356        .and_then(|v| v.as_str())
357        .unwrap_or("")
358        .to_string();
359    if access_token.is_empty() {
360        return Err(AuthError::TokenProvider(
361            "GCP token response has no access_token".into(),
362        ));
363    }
364    let expires_in = doc.get("expires_in").and_then(|v| {
365        v.as_u64()
366            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
367    });
368    Ok(ParsedGcpToken {
369        access_token,
370        expires_in,
371    })
372}
373
374impl TokenProvider for GcpTokenProvider {
375    fn mark_stale(&self) {
376        self.inner.force_refresh.store(true, Ordering::SeqCst);
377    }
378
379    async fn get_token(&self) -> Result<String, AuthError> {
380        // load() not swap: a concurrent get_token must not see a cleared force
381        // flag before the InFlight claim.
382        {
383            let state = self.inner.state.read().await;
384            if !self.inner.force_refresh.load(Ordering::SeqCst)
385                && let Some(tok) = state.as_ref()
386                && !tok.needs_refresh()
387                && !self.inner.inflight.is_busy()
388            {
389                return Ok(tok.access_token.clone());
390            }
391        }
392        lead_or_follow(&self.inner.inflight, || async {
393            let force = self.inner.force_refresh.swap(false, Ordering::SeqCst);
394            let result = self.refresh(force).await;
395            if result.is_err() && force {
396                self.inner.force_refresh.store(true, Ordering::SeqCst);
397            }
398            result
399        })
400        .await
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::io::{Read, Write};
408    use std::net::TcpListener;
409    use std::thread;
410    use std::time::Duration;
411
412    // Test-only PKCS8 RSA key (openssl genpkey). Not a production secret.
413    const TEST_PKCS8: &str = "-----BEGIN PRIVATE KEY-----
414MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC8amrIB7iFClhT
415MSWHQWnUlrBAL7amaNd1+LS1ja7l8o4LVhviO8i1odLnLKnwqEpDqlSenNwY4Fkx
416Zb3y8IKkZ2Iy6wahHyeLNXyC7BJnGX1pm2XVRWnQB5PjGBr1Gm3ySmXf3Pq5iOyQ
417NyykJtPT3Lmh7u5Nx4LkF/FLP++NRoWT31MpGHz9I0nWjyWOaDDB2Tu31+fU9p1H
418bVN4WT3GAimy3c4yLMZeP6sirEBr7MPPSPcpJSNDSrQcul+wEjPeJzgv0yKYAoZD
419gVCrc1eKchV3FzPwO30XBNeTS0Hg0pGb7/JbSpAU9OnyEJVMAB7TMJBqdBEIrCLj
4206lEEgen1AgMBAAECggEAAIIL+UrhIKG69pEf3s8IEX/315AY4pu7ZGC7QRbJCYoa
421FujyfnsFY8kwXPT0z33fVSTi/2X+3qXQzwUikXT2bXO24BhUfR/fYQcKGiS/hp7Z
422EGlfqmYN2GeHfhdYCBx6OM3UqxdkfoMFTJ/RN0SR9LqcA7M9jHRRdwaTj4P8Cd/s
423QAftCe7ZNgZU7Mf6F3DV7pgPwfvA0vUADKWuyawCq1Yp+9BHlMn9H39fMchW2bNR
424V8Qo5dp+FUEOQM41gcHVHtyelaU/efy/GoHTOCPFgxykR3CdMB1s0ShvbfDUZDia
425R03IY3cByHQJ/UDO6SeiY0Hxyycigc0pPjLJNkvldQKBgQDrxb4OKjuxuePFMHiB
426G3VhoWVa8i8OhcKD4q7XoBnnItdsjFIGi6l2Mxn+yHFTTwt/6DeiTcw93kUYkG7S
427XhqQEb+5Kks1E3SX4YH+s0m0W7skyR4aZZLwar6WnbHk+g+5zdWG4i1zQ+thjiiB
428wZAJOTiR81q/alR9iSvmSZkKnwKBgQDMlJTECVfSYhJ1YerKssHud0I9mzYOrTEP
429f9NHQvYYq4fVMriPWtUDSrL2D9TEqgx9u7WfCL+XCwnr6qHaMy6yM92m2mWunWNN
430HB5y52emd/EdyB5i0FKkPQ2aBWyKDFnOD/UFWLX50UBMK7JG35Eall7JBowBBEic
431UP/PD7SW6wKBgQCyhWTV5uaSONWdHolwALGNfh53kX9N+LwDDqYiwKg8WiZRm6IU
432MLXcuO7K+0zLrsNfUx6k91FZ2y3oXpx7DyP/yGCqPLr7ckLLKcY7a9e4B+kY/mub
433wyNShRDQjJEBdtJndtJiMmoFp/zXPkOvlDeStFAAOwqQe1uEPlQOJ9YIswKBgQCZ
434z4f9z6x4n4WTPVgip71Ixd9GpEBDTpFZPtihdkXCjIxmjWjXVwpaHDpq58InTlZv
4353cYSWKh7LjB6cADaJasRDg+y1/alDu3O1rpJ15NFRF5C7udxkYDgvIpSZ4uQSvLm
436C3dDWswOk/WMjznNMV9OJwoCh+qRBSB2biu2CO/UmwKBgQCrLsCyMAnrB8kPtUUv
437gdmsOkF73DglCWe2TXjDGVGkKe81D1SwTD+AphwKuaXCH7go5iWfHBeXZi4ZNhOP
4389s6eqEDT3hWzZA9/S1xzz2pWe9J1c/gNztvP/N1oCJ+UExv0lf8cZAJl5KdpZ/GU
439c+5RXVheoFNjzJpbLyOIeEEttw==
440-----END PRIVATE KEY-----
441";
442
443    fn test_key_json(token_uri: &str) -> String {
444        serde_json::json!({
445            "type": "service_account",
446            "client_email": "svc@example.iam.gserviceaccount.com",
447            "private_key": TEST_PKCS8,
448            "token_uri": token_uri,
449        })
450        .to_string()
451    }
452
453    fn read_http_headers(stream: &mut impl Read) -> Vec<u8> {
454        let mut buf = Vec::new();
455        let mut tmp = [0u8; 2048];
456        loop {
457            match stream.read(&mut tmp) {
458                Ok(0) => break,
459                Ok(n) => {
460                    buf.extend_from_slice(&tmp[..n]);
461                    if buf.windows(4).any(|w| w == b"\r\n\r\n") {
462                        break;
463                    }
464                }
465                Err(_) => break,
466            }
467        }
468        buf
469    }
470
471    fn write_http_json(stream: &mut impl Write, status: u16, body: &str) {
472        let resp = format!(
473            "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
474            body.len()
475        );
476        let _ = stream.write_all(resp.as_bytes());
477    }
478
479    fn spawn_http_server(status: u16, body: &str) -> (String, thread::JoinHandle<String>) {
480        let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
481        let addr = listener.local_addr().expect("local_addr");
482        let body = body.to_owned();
483        let handle = thread::spawn(move || {
484            let (mut stream, _) = listener.accept().expect("accept");
485            stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
486            let buf = read_http_headers(&mut stream);
487            write_http_json(&mut stream, status, &body);
488            String::from_utf8_lossy(&buf).into_owned()
489        });
490        (format!("http://{addr}/token"), handle)
491    }
492
493    fn spawn_http_script(responses: &[(u16, &str)]) -> (String, thread::JoinHandle<usize>) {
494        let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
495        let addr = listener.local_addr().expect("local_addr");
496        let responses: Vec<(u16, String)> = responses
497            .iter()
498            .map(|(status, body)| (*status, (*body).to_owned()))
499            .collect();
500        let handle = thread::spawn(move || {
501            let mut served = 0usize;
502            for (status, body) in responses {
503                let (mut stream, _) = listener.accept().expect("accept");
504                stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
505                let _ = read_http_headers(&mut stream);
506                write_http_json(&mut stream, status, &body);
507                served += 1;
508            }
509            served
510        });
511        (format!("http://{addr}/token"), handle)
512    }
513
514    #[tokio::test]
515    async fn gcp_jwt_bearer_returns_access_token() {
516        let (url, handle) = spawn_http_server(
517            200,
518            r#"{"access_token":"ya29.gcp","expires_in":3600,"token_type":"Bearer"}"#,
519        );
520        let p = GcpTokenProvider::from_key(&test_key_json(&url)).expect("from_key");
521        assert_eq!(p.get_token().await.expect("token"), "ya29.gcp");
522        let req = handle.join().expect("join");
523        assert!(
524            req.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer")
525                || req.contains("grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer"),
526            "{req}"
527        );
528        assert!(req.contains("assertion="), "{req}");
529    }
530
531    #[tokio::test]
532    async fn gcp_from_key_file_round_trips() {
533        let dir = tempfile::tempdir().expect("tempdir");
534        let path = dir.path().join("sa.json");
535        let (url, handle) =
536            spawn_http_server(200, r#"{"access_token":"from-file","expires_in":60}"#);
537        std::fs::write(&path, test_key_json(&url)).expect("write key");
538        let p = GcpTokenProvider::from_key_file(&path).expect("from_key_file");
539        assert_eq!(p.get_token().await.expect("token"), "from-file");
540        let _ = handle.join();
541    }
542
543    #[test]
544    fn gcp_debug_redacts_private_key() {
545        let json = test_key_json(
546            "https://user:s3cret@oauth2.googleapis.com/token?client_secret=supersecret",
547        );
548        let p = GcpTokenProvider::from_key(&json).expect("from_key");
549        let debug = format!("{p:?}");
550        assert!(!debug.contains("BEGIN PRIVATE"), "{debug}");
551        assert!(debug.contains("[REDACTED]"), "{debug}");
552        assert!(!debug.contains("s3cret"), "{debug}");
553        assert!(!debug.contains("supersecret"), "{debug}");
554        assert!(!debug.contains("client_secret="), "{debug}");
555        assert!(!debug.contains("user:"), "{debug}");
556        assert!(debug.contains("https://oauth2.googleapis.com"), "{debug}");
557    }
558
559    #[test]
560    fn gcp_missing_email_fails() {
561        let err = GcpTokenProvider::from_key(r#"{"private_key":"x"}"#).expect_err("missing");
562        assert!(err.to_string().contains("client_email"), "{err}");
563    }
564
565    fn authorized_user_json(token_uri: &str) -> String {
566        serde_json::json!({
567            "type": "authorized_user",
568            "client_id": "123.apps.googleusercontent.com",
569            "client_secret": "user-secret",
570            "refresh_token": "1//refresh-me",
571            "token_uri": token_uri,
572        })
573        .to_string()
574    }
575
576    #[tokio::test]
577    async fn authorized_user_refresh_returns_access_token() {
578        let (url, handle) = spawn_http_server(
579            200,
580            r#"{"access_token":"ya29.user","expires_in":3600,"token_type":"Bearer"}"#,
581        );
582        let p = GcpTokenProvider::from_key(&authorized_user_json(&url)).expect("from_key");
583        assert_eq!(p.get_token().await.expect("token"), "ya29.user");
584        let req = handle.join().expect("join");
585        assert!(req.contains("grant_type=refresh_token"), "{req}");
586        assert!(
587            req.contains("client_id=123.apps.googleusercontent.com"),
588            "{req}"
589        );
590        assert!(req.contains("refresh_token="), "{req}");
591    }
592
593    #[test]
594    fn authorized_user_debug_redacts_secrets() {
595        let p =
596            GcpTokenProvider::from_key(&authorized_user_json(DEFAULT_TOKEN_URI)).expect("from_key");
597        let debug = format!("{p:?}");
598        assert!(debug.contains("authorized_user"), "{debug}");
599        assert!(debug.contains("[REDACTED]"), "{debug}");
600        assert!(!debug.contains("user-secret"), "{debug}");
601        assert!(!debug.contains("1//refresh-me"), "{debug}");
602    }
603
604    fn authorized_user_json_with_quota(token_uri: &str, quota_project_id: &str) -> String {
605        serde_json::json!({
606            "type": "authorized_user",
607            "client_id": "123.apps.googleusercontent.com",
608            "client_secret": "user-secret",
609            "refresh_token": "1//refresh-me",
610            "token_uri": token_uri,
611            "quota_project_id": quota_project_id,
612        })
613        .to_string()
614    }
615
616    #[test]
617    fn authorized_user_quota_project_id_from_key_and_file() {
618        let json = authorized_user_json_with_quota(DEFAULT_TOKEN_URI, "billing-proj");
619        let p = GcpTokenProvider::from_key(&json).expect("from_key");
620        assert_eq!(p.quota_project_id(), Some("billing-proj"));
621        let debug = format!("{p:?}");
622        assert!(debug.contains("billing-proj"), "{debug}");
623        assert!(!debug.contains("user-secret"), "{debug}");
624        assert!(!debug.contains("1//refresh-me"), "{debug}");
625
626        let dir = tempfile::tempdir().expect("tempdir");
627        let path = dir.path().join("adc.json");
628        std::fs::write(&path, json).expect("write adc");
629        let from_file = GcpTokenProvider::from_key_file(&path).expect("from_key_file");
630        assert_eq!(from_file.quota_project_id(), Some("billing-proj"));
631    }
632
633    #[test]
634    fn authorized_user_without_quota_project_id_is_none() {
635        let p =
636            GcpTokenProvider::from_key(&authorized_user_json(DEFAULT_TOKEN_URI)).expect("from_key");
637        assert_eq!(p.quota_project_id(), None);
638    }
639
640    #[test]
641    fn external_account_adc_is_refused() {
642        let err = GcpTokenProvider::from_key(
643            r#"{"type":"external_account","audience":"//iam.googleapis.com/x"}"#,
644        )
645        .expect_err("wif");
646        assert!(err.to_string().contains("external_account"), "{err}");
647    }
648
649    #[tokio::test]
650    async fn provider_from_profile_uses_default_adc_file() {
651        use crate::{IsolatedHome, parse_profile_str, provider_from_profile};
652
653        let (url, handle) = spawn_http_server(
654            200,
655            r#"{"access_token":"ya29.adc","expires_in":3600,"token_type":"Bearer"}"#,
656        );
657        let home = IsolatedHome::new();
658        let adc = default_adc_path().expect("default adc path");
659        std::fs::create_dir_all(adc.parent().expect("parent")).expect("mkdir");
660        std::fs::write(&adc, authorized_user_json(&url)).expect("write adc");
661        let profile = parse_profile_str(
662            r#"
663schema_version = 1
664id = "google-vertex"
665wire = "gemini"
666gcp_key_env = "GOOGLE_APPLICATION_CREDENTIALS"
667access_env = ["GOOGLE_OAUTH_ACCESS_TOKEN"]
668"#,
669        )
670        .expect("parse");
671        let provider = provider_from_profile(&profile).expect("adc");
672        assert_eq!(provider.get_token().await.expect("token"), "ya29.adc");
673        handle.join().expect("join");
674        let _ = home;
675    }
676
677    #[tokio::test]
678    async fn forced_refresh_http_error_restores_force_flag() {
679        let (url, handle) = spawn_http_script(&[
680            (200, r#"{"access_token":"ya29.first","expires_in":3600}"#),
681            (401, r#"{"error":"invalid_grant"}"#),
682            (200, r#"{"access_token":"ya29.retry","expires_in":3600}"#),
683        ]);
684        let p = GcpTokenProvider::from_key(&test_key_json(&url)).expect("from_key");
685        assert_eq!(p.get_token().await.expect("cache"), "ya29.first");
686        p.mark_stale();
687        let err = p.get_token().await.expect_err("forced 401");
688        let msg = err.to_string();
689        assert!(msg.contains("401") || msg.contains("GCP"), "{msg}");
690        assert_eq!(
691            msg.matches("(HTTP ").count(),
692            1,
693            "VendorRejected must not re-wrap HTTP: {msg}"
694        );
695        assert_eq!(
696            p.get_token().await.expect("retry after failed force"),
697            "ya29.retry"
698        );
699        assert_eq!(handle.join().expect("join"), 3);
700    }
701
702    #[tokio::test]
703    async fn provider_from_profile_uses_gcp_key_file() {
704        use crate::{IsolatedHome, parse_profile_str, provider_from_profile};
705
706        let (url, handle) = spawn_http_server(
707            200,
708            r#"{"access_token":"ya29.from-file","expires_in":3600,"token_type":"Bearer"}"#,
709        );
710        let home = IsolatedHome::new();
711        let key_path = home.path().join("sa.json");
712        std::fs::write(&key_path, test_key_json(&url)).expect("write key");
713        home.set_env(
714            "GOOGLE_APPLICATION_CREDENTIALS",
715            key_path.to_str().expect("utf8"),
716        );
717        let profile = parse_profile_str(
718            r#"
719schema_version = 1
720id = "google-vertex"
721wire = "gemini"
722gcp_key_env = "GOOGLE_APPLICATION_CREDENTIALS"
723access_env = ["GOOGLE_OAUTH_ACCESS_TOKEN"]
724"#,
725        )
726        .expect("parse");
727        let provider = provider_from_profile(&profile).expect("gcp");
728        assert_eq!(provider.get_token().await.expect("token"), "ya29.from-file");
729        handle.join().expect("join");
730        let _ = home;
731    }
732
733    #[tokio::test]
734    async fn provider_from_profile_gcp_falls_back_to_access_env() {
735        use crate::{IsolatedHome, parse_profile_str, provider_from_profile};
736
737        let home = IsolatedHome::new();
738        home.set_env("GOOGLE_OAUTH_ACCESS_TOKEN", "ya29.bearer");
739        let profile = parse_profile_str(
740            r#"
741schema_version = 1
742id = "google-vertex"
743wire = "gemini"
744gcp_key_env = "GOOGLE_APPLICATION_CREDENTIALS"
745access_env = ["GOOGLE_OAUTH_ACCESS_TOKEN"]
746"#,
747        )
748        .expect("parse");
749        let provider = provider_from_profile(&profile).expect("bearer");
750        assert_eq!(provider.get_token().await.expect("token"), "ya29.bearer");
751        let _ = home;
752    }
753}