1use std::collections::HashMap;
11use std::sync::{Mutex, OnceLock};
12
13use base64::Engine as _;
14use serde_json::{Map, Value, json};
15use tokio_util::sync::CancellationToken;
16
17use super::{AuthError, now_ms};
18
19pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
21pub const OAUTH2_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
23const JWT_BEARER: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
24const TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
25const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
26const EAGER_REFRESH_MS: u64 = 5 * 60 * 1000;
29const NO_CREDENTIALS: &str = "Could not load the default credentials. Browse to https://cloud.google.com/docs/authentication/getting-started for more information.";
30
31#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct Token {
35 pub access_token: String,
37 pub quota_project: Option<String>,
39}
40
41pub fn credentials_file(env: impl Fn(&str) -> Option<String>) -> Option<String> {
46 if let Some(path) = env("GOOGLE_APPLICATION_CREDENTIALS") {
47 return Some(path);
48 }
49 let dir = env("CLOUDSDK_CONFIG")
50 .map(std::path::PathBuf::from)
51 .or_else(|| env("HOME").map(|home| std::path::Path::new(&home).join(".config/gcloud")))?;
52 let path = dir.join("application_default_credentials.json");
53 path.exists().then(|| path.to_string_lossy().into_owned())
54}
55
56struct Cached {
57 token: Token,
58 expires_at_ms: u64,
59}
60
61fn cache() -> &'static Mutex<HashMap<String, Cached>> {
62 static CACHE: OnceLock<Mutex<HashMap<String, Cached>>> = OnceLock::new();
63 CACHE.get_or_init(Default::default)
64}
65
66pub async fn token(
70 path: Option<String>,
71 oauth2_token_url: &str,
72 cancel: &CancellationToken,
73) -> Result<Token, String> {
74 let path = path.ok_or_else(|| NO_CREDENTIALS.to_owned())?;
75 let text = tokio::fs::read_to_string(&path)
76 .await
77 .map_err(|err| file_error(&err, &path))?;
78 let key = format!("{path}\0{text}");
79 if let Ok(cache) = cache().lock()
80 && let Some(cached) = cache.get(&key)
81 && cached.expires_at_ms > now_ms() + EAGER_REFRESH_MS
82 {
83 return Ok(cached.token.clone());
84 }
85 let credentials: Value = serde_json::from_str(&text)
86 .map_err(|err| format!("Unexpected token in JSON at {path}: {err}"))?;
87 let (access_token, expires_at_ms) = fetch(&credentials, oauth2_token_url, cancel).await?;
88 let token = Token {
89 access_token,
90 quota_project: credentials["quota_project_id"]
91 .as_str()
92 .filter(|id| !id.is_empty())
93 .map(str::to_owned),
94 };
95 if let Ok(mut cache) = cache().lock() {
96 cache.insert(
97 key,
98 Cached {
99 token: token.clone(),
100 expires_at_ms,
101 },
102 );
103 }
104 Ok(token)
105}
106
107fn file_error(err: &std::io::Error, path: &str) -> String {
109 match err.kind() {
110 std::io::ErrorKind::NotFound => {
111 format!("ENOENT: no such file or directory, open '{path}'")
112 }
113 std::io::ErrorKind::PermissionDenied => {
114 format!("EACCES: permission denied, open '{path}'")
115 }
116 _ => format!("{err}, open '{path}'"),
117 }
118}
119
120fn field<'a>(credentials: &'a Value, name: &str) -> Result<&'a str, String> {
121 credentials[name]
122 .as_str()
123 .filter(|value| !value.is_empty())
124 .ok_or_else(|| format!("The incoming JSON object does not contain a {name} field"))
125}
126
127fn fetch<'a>(
129 credentials: &'a Value,
130 oauth2_token_url: &'a str,
131 cancel: &'a CancellationToken,
132) -> super::BoxFuture<'a, Result<(String, u64), String>> {
133 Box::pin(async move {
134 match credentials["type"].as_str().unwrap_or_default() {
135 "authorized_user" => {
136 let fields = [
137 ("refresh_token", field(credentials, "refresh_token")?),
138 ("client_id", field(credentials, "client_id")?),
139 ("client_secret", field(credentials, "client_secret")?),
140 ("grant_type", "refresh_token"),
141 ];
142 oauth_token(oauth2_token_url, &fields, cancel).await
143 }
144 "service_account" => {
145 let token_uri = credentials["token_uri"]
146 .as_str()
147 .filter(|uri| !uri.is_empty())
148 .unwrap_or(OAUTH2_TOKEN_URL);
149 let assertion = signed_jwt(
150 field(credentials, "client_email")?,
151 field(credentials, "private_key")?,
152 token_uri,
153 )?;
154 let fields = [("grant_type", JWT_BEARER), ("assertion", &assertion)];
155 oauth_token(token_uri, &fields, cancel).await
156 }
157 "impersonated_service_account" => {
158 let source = &credentials["source_credentials"];
159 if !source.is_object() {
160 return Err(
161 "The incoming JSON object does not contain a source_credentials field"
162 .into(),
163 );
164 }
165 let (source_token, _) = fetch(source, oauth2_token_url, cancel).await?;
166 let url = field(credentials, "service_account_impersonation_url")?;
167 let delegates = credentials["delegates"].clone();
168 impersonate(url, &source_token, delegates, cancel).await
169 }
170 "external_account" => external_account(credentials, cancel).await,
171 other => Err(format!(
172 "Unsupported Google credentials type for Vertex AI: {}",
173 if other.is_empty() { "undefined" } else { other }
174 )),
175 }
176 })
177}
178
179fn signed_jwt(email: &str, private_key: &str, audience: &str) -> Result<String, String> {
182 let encode = |value: &Value| {
183 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(yapi_types::json::stringify(value))
184 };
185 let issued = now_ms() / 1000;
186 let header = encode(&json!({"alg": "RS256"}));
187 let claims = encode(&json!({
188 "iss": email,
189 "scope": CLOUD_PLATFORM_SCOPE,
190 "aud": audience,
191 "exp": issued + 3600,
192 "iat": issued,
193 }));
194 let message = format!("{header}.{claims}");
195 let signature = rs256(private_key, message.as_bytes())?;
196 Ok(format!(
197 "{message}.{}",
198 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature)
199 ))
200}
201
202fn rs256(pem: &str, message: &[u8]) -> Result<Vec<u8>, String> {
204 use aws_lc_rs::rand::SystemRandom;
205 use aws_lc_rs::signature::{RSA_PKCS1_SHA256, RsaKeyPair};
206 let body: String = pem
207 .lines()
208 .filter(|line| !line.starts_with("-----"))
209 .flat_map(|line| line.trim().chars())
210 .collect();
211 let der = base64::engine::general_purpose::STANDARD
212 .decode(body)
213 .map_err(|_| "Invalid private key in service account credentials".to_owned())?;
214 let key = RsaKeyPair::from_pkcs8(&der)
215 .or_else(|_| RsaKeyPair::from_der(&der))
216 .map_err(|_| "Invalid private key in service account credentials".to_owned())?;
217 let mut signature = vec![0; key.public_modulus_len()];
218 key.sign(
219 &RSA_PKCS1_SHA256,
220 &SystemRandom::new(),
221 message,
222 &mut signature,
223 )
224 .map_err(|_| "Could not sign the service account assertion".to_owned())?;
225 Ok(signature)
226}
227
228async fn post(
229 request: reqwest::RequestBuilder,
230 cancel: &CancellationToken,
231) -> Result<(u16, Value), String> {
232 let response = super::send(request, cancel)
233 .await
234 .map_err(|err| match err {
235 AuthError::Cancelled => crate::http::ABORTED_BEFORE_RESPONSE.to_owned(),
236 AuthError::Failed(message) => message,
237 })?;
238 let status = response.status().as_u16();
239 Ok((status, super::json_body(response).await))
240}
241
242fn token_error(status: u16, body: &Value) -> String {
245 match &body["error"] {
246 Value::String(code) => code.clone(),
247 Value::Object(error) => error.get("message").and_then(Value::as_str).map_or_else(
248 || format!("Request failed with status code {status}"),
249 str::to_owned,
250 ),
251 _ => format!("Request failed with status code {status}"),
252 }
253}
254
255async fn oauth_token(
257 url: &str,
258 fields: &[(&str, &str)],
259 cancel: &CancellationToken,
260) -> Result<(String, u64), String> {
261 let request = super::post_form(url, fields);
262 let (status, body) = post(request, cancel).await?;
263 if !(200..300).contains(&status) {
264 return Err(token_error(status, &body));
265 }
266 let token = body["access_token"]
267 .as_str()
268 .filter(|token| !token.is_empty())
269 .ok_or_else(|| "No access token in the token response".to_owned())?;
270 let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
271 Ok((token.to_owned(), now_ms() + expires_in * 1000))
272}
273
274async fn impersonate(
277 url: &str,
278 source_token: &str,
279 delegates: Value,
280 cancel: &CancellationToken,
281) -> Result<(String, u64), String> {
282 let mut body = Map::new();
283 body.insert(
284 "delegates".into(),
285 if delegates.is_array() {
286 delegates
287 } else {
288 json!([])
289 },
290 );
291 body.insert("scope".into(), json!([CLOUD_PLATFORM_SCOPE]));
292 body.insert("lifetime".into(), json!("3600s"));
293 let request = crate::http::client()
294 .post(url)
295 .header("authorization", format!("Bearer {source_token}"))
296 .header("content-type", "application/json")
297 .body(yapi_types::json::stringify(&Value::Object(body)));
298 let (status, body) = post(request, cancel).await?;
299 if !(200..300).contains(&status) {
300 return Err(format!(
301 "unable to impersonate: {}",
302 token_error(status, &body)
303 ));
304 }
305 let token = body["accessToken"]
306 .as_str()
307 .filter(|token| !token.is_empty())
308 .ok_or_else(|| "No access token in the impersonation response".to_owned())?;
309 let expires_at_ms = body["expireTime"]
310 .as_str()
311 .and_then(yapi_types::time::parse_iso)
312 .unwrap_or_else(|| now_ms() + 3600 * 1000);
313 Ok((token.to_owned(), expires_at_ms))
314}
315
316async fn external_account(
319 credentials: &Value,
320 cancel: &CancellationToken,
321) -> Result<(String, u64), String> {
322 let source = &credentials["credential_source"];
323 let raw = if let Some(path) = source["file"].as_str() {
324 tokio::fs::read_to_string(path)
325 .await
326 .map_err(|err| file_error(&err, path))?
327 } else if let Some(url) = source["url"].as_str() {
328 let mut request = crate::http::client().get(url);
329 if let Some(headers) = source["headers"].as_object() {
330 for (name, value) in headers {
331 if let Some(value) = value.as_str() {
332 request = request.header(name.as_str(), value);
333 }
334 }
335 }
336 let response = super::send(request, cancel)
337 .await
338 .map_err(|err| match err {
339 AuthError::Cancelled => crate::http::ABORTED_BEFORE_RESPONSE.to_owned(),
340 AuthError::Failed(message) => message,
341 })?;
342 response.text().await.unwrap_or_default()
343 } else {
344 return Err(
345 "Only file and URL credential sources are supported for external accounts".into(),
346 );
347 };
348 let subject = match source["format"]["type"].as_str() {
349 Some("json") => {
350 let name = source["format"]["subject_token_field_name"]
351 .as_str()
352 .unwrap_or_default();
353 let json: Value = serde_json::from_str(&raw)
354 .map_err(|_| "Unable to parse the subject token response as JSON".to_owned())?;
355 json[name].as_str().map(str::to_owned).ok_or_else(|| {
356 format!("Unable to parse the subject_token from the credential_source {name} field")
357 })?
358 }
359 _ => raw.trim().to_owned(),
360 };
361 if subject.is_empty() {
362 return Err("Unable to parse the subject_token from the credential_source".into());
363 }
364 let token_url = credentials["token_url"]
365 .as_str()
366 .unwrap_or("https://sts.googleapis.com/v1/token");
367 let mut pairs = vec![
368 ("grant_type", TOKEN_EXCHANGE.to_owned()),
369 ("audience", field(credentials, "audience")?.to_owned()),
370 ("scope", CLOUD_PLATFORM_SCOPE.to_owned()),
371 ("requested_token_type", ACCESS_TOKEN_TYPE.to_owned()),
372 ("subject_token", subject),
373 (
374 "subject_token_type",
375 field(credentials, "subject_token_type")?.to_owned(),
376 ),
377 ];
378 if let Some(project) = credentials["workforce_pool_user_project"].as_str() {
379 pairs.push((
380 "options",
381 yapi_types::json::stringify(&json!({"userProject": project})),
382 ));
383 }
384 let borrowed: Vec<(&str, &str)> = pairs.iter().map(|(k, v)| (*k, v.as_str())).collect();
385 let (sts_token, expires_at_ms) = oauth_token(token_url, &borrowed, cancel).await?;
386 match credentials["service_account_impersonation_url"].as_str() {
387 Some(url) if !url.is_empty() => impersonate(url, &sts_token, json!([]), cancel).await,
388 _ => Ok((sts_token, expires_at_ms)),
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 pub(crate) fn test_key() -> (String, Vec<u8>) {
398 use aws_lc_rs::encoding::AsDer;
399 use aws_lc_rs::rsa::{KeyPair, KeySize};
400 use aws_lc_rs::signature::KeyPair as _;
401 let key = KeyPair::generate(KeySize::Rsa2048).unwrap();
402 let der = AsDer::<aws_lc_rs::encoding::Pkcs8V1Der<'static>>::as_der(&key).unwrap();
403 let body = base64::engine::general_purpose::STANDARD.encode(der.as_ref());
404 let lines: Vec<&str> = body
405 .as_bytes()
406 .chunks(64)
407 .map(|chunk| std::str::from_utf8(chunk).unwrap())
408 .collect();
409 let pem = format!(
410 "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
411 lines.join("\n")
412 );
413 (pem, key.public_key().as_ref().to_vec())
414 }
415
416 #[test]
417 fn signs_service_account_assertions() {
418 use aws_lc_rs::signature::{RSA_PKCS1_2048_8192_SHA256, UnparsedPublicKey};
419 let (pem, public) = test_key();
420 let jwt = signed_jwt("sa@p.iam.gserviceaccount.com", &pem, OAUTH2_TOKEN_URL).unwrap();
421 let parts: Vec<&str> = jwt.split('.').collect();
422 assert_eq!(parts.len(), 3);
423 assert_eq!(parts[0], "eyJhbGciOiJSUzI1NiJ9");
424 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
425 .decode(parts[2])
426 .unwrap();
427 UnparsedPublicKey::new(&RSA_PKCS1_2048_8192_SHA256, &public)
428 .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature)
429 .unwrap();
430 let claims: Value = serde_json::from_slice(
431 &base64::engine::general_purpose::URL_SAFE_NO_PAD
432 .decode(parts[1])
433 .unwrap(),
434 )
435 .unwrap();
436 assert_eq!(claims["scope"], CLOUD_PLATFORM_SCOPE);
437 assert_eq!(claims["aud"], OAUTH2_TOKEN_URL);
438 assert_eq!(
439 claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(),
440 3600
441 );
442 assert_eq!(
443 signed_jwt("x", "not a key", OAUTH2_TOKEN_URL),
444 Err("Invalid private key in service account credentials".into())
445 );
446 }
447}