use super::error::GcpError;
#[derive(Clone)]
pub struct GcpConfig {
pub project_id: String,
pub endpoint: String,
}
const DEFAULT_ENDPOINT: &str = "https://secretmanager.googleapis.com/v1";
const METADATA_ENDPOINT: &str =
"http://metadata.google.internal/computeMetadata/v1/project/project-id";
impl GcpConfig {
pub fn from_env() -> Result<Self, GcpError> {
let project_id = project_id_from_env_or_metadata()?;
Ok(Self {
project_id,
endpoint: DEFAULT_ENDPOINT.to_string(),
})
}
pub fn with_endpoint(project_id: impl Into<String>, endpoint: impl Into<String>) -> Self {
Self {
project_id: project_id.into(),
endpoint: endpoint.into(),
}
}
}
fn project_id_from_env_or_metadata() -> Result<String, GcpError> {
if let Ok(p) = std::env::var("GOOGLE_CLOUD_PROJECT") {
if !p.is_empty() {
return Ok(p);
}
}
if let Ok(p) = std::env::var("GCLOUD_PROJECT") {
if !p.is_empty() {
return Ok(p);
}
}
fetch_project_id_from_metadata()
}
fn http_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(10))
.build()
}
fn fetch_project_id_from_metadata() -> Result<String, GcpError> {
let agent = http_agent();
let project_id = agent
.get(METADATA_ENDPOINT)
.set("Metadata-Flavor", "Google")
.call()
.map_err(|e| {
GcpError::Config(format!(
"GOOGLE_CLOUD_PROJECT is not set and GCE metadata server is unreachable: {e}"
))
})?
.into_string()
.map_err(|e| GcpError::Transport(e.to_string()))?;
let project_id = project_id.trim().to_string();
if project_id.is_empty() {
return Err(GcpError::Config(
"metadata server returned empty project ID".into(),
));
}
Ok(project_id)
}
#[derive(Clone, Debug)]
pub struct GcpToken(pub String);
pub fn acquire_token() -> Result<GcpToken, GcpError> {
if let Ok(t) = std::env::var("GOOGLE_OAUTH_TOKEN") {
if !t.is_empty() {
return Ok(GcpToken(t));
}
}
if let Ok(tok) = fetch_metadata_token() {
return Ok(tok);
}
fetch_adc_token()
}
fn fetch_metadata_token() -> Result<GcpToken, GcpError> {
const META_URL: &str =
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
let agent = http_agent();
let resp: serde_json::Value = agent
.get(META_URL)
.set("Metadata-Flavor", "Google")
.call()
.map_err(|e| GcpError::Auth(format!("metadata server unreachable: {e}")))?
.into_json()
.map_err(|e| GcpError::Transport(e.to_string()))?;
resp["access_token"]
.as_str()
.map(|s| GcpToken(s.to_string()))
.ok_or_else(|| GcpError::Auth("metadata token response missing 'access_token'".into()))
}
fn fetch_adc_token() -> Result<GcpToken, GcpError> {
let adc_path = adc_file_path()?;
let content = std::fs::read_to_string(&adc_path).map_err(|e| {
GcpError::Auth(format!(
"could not read ADC file at {}: {e}",
adc_path.display()
))
})?;
let creds: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| GcpError::Auth(format!("invalid ADC JSON: {e}")))?;
let cred_type = creds["type"].as_str().unwrap_or("");
match cred_type {
"authorized_user" => refresh_authorized_user_token(&creds),
"service_account" => fetch_sa_token(&creds),
other => Err(GcpError::Auth(format!(
"unsupported ADC credential type '{other}'; \
use authorized_user (gcloud auth application-default login), \
service_account (JSON key file), or set GOOGLE_OAUTH_TOKEN"
))),
}
}
fn adc_file_path() -> Result<std::path::PathBuf, GcpError> {
if let Ok(p) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
return Ok(std::path::PathBuf::from(p));
}
#[cfg(target_os = "windows")]
{
let appdata = std::env::var("APPDATA")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| {
GcpError::Auth(
"could not find ADC file — set GOOGLE_APPLICATION_CREDENTIALS or \
run `gcloud auth application-default login`"
.into(),
)
})?;
let mut path = std::path::PathBuf::from(appdata);
path.push("gcloud");
path.push("application_default_credentials.json");
Ok(path)
}
#[cfg(not(target_os = "windows"))]
{
let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
let mut path = std::path::PathBuf::from(home);
path.push(".config");
path.push("gcloud");
path.push("application_default_credentials.json");
Ok(path)
}
}
fn refresh_authorized_user_token(creds: &serde_json::Value) -> Result<GcpToken, GcpError> {
let client_id = creds["client_id"]
.as_str()
.ok_or_else(|| GcpError::Auth("ADC missing 'client_id'".into()))?;
let client_secret = creds["client_secret"]
.as_str()
.ok_or_else(|| GcpError::Auth("ADC missing 'client_secret'".into()))?;
let refresh_token = creds["refresh_token"]
.as_str()
.ok_or_else(|| GcpError::Auth("ADC missing 'refresh_token'".into()))?;
let body = format!(
"client_id={client_id}&client_secret={client_secret}\
&refresh_token={refresh_token}&grant_type=refresh_token"
);
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build();
let resp: serde_json::Value = agent
.post("https://oauth2.googleapis.com/token")
.set("Content-Type", "application/x-www-form-urlencoded")
.send_string(&body)
.map_err(|e| GcpError::Auth(format!("token refresh request failed: {e}")))?
.into_json()
.map_err(|e| GcpError::Transport(e.to_string()))?;
resp["access_token"]
.as_str()
.map(|s| GcpToken(s.to_string()))
.ok_or_else(|| {
let err = resp["error"].as_str().unwrap_or("unknown");
GcpError::Auth(format!("token refresh failed: {err}"))
})
}
fn fetch_sa_token(creds: &serde_json::Value) -> Result<GcpToken, GcpError> {
let client_email = creds["client_email"]
.as_str()
.ok_or_else(|| GcpError::Auth("service_account ADC missing 'client_email'".into()))?;
let private_key = creds["private_key"]
.as_str()
.ok_or_else(|| GcpError::Auth("service_account ADC missing 'private_key'".into()))?;
let token_uri = creds["token_uri"]
.as_str()
.unwrap_or("https://oauth2.googleapis.com/token");
let jwt = sign_service_account_jwt(client_email, private_key, token_uri)
.map_err(|e| GcpError::Auth(format!("failed to sign JWT: {e}")))?;
exchange_jwt_for_token(token_uri, &jwt)
}
fn sign_service_account_jwt(
client_email: &str,
private_key: &str,
audience: &str,
) -> Result<String, String> {
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct ServiceAccountClaims {
iss: String,
scope: String,
aud: String,
exp: i64,
iat: i64,
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("system time error: {e}"))?
.as_secs() as i64;
let exp = now + 3600;
let claims = ServiceAccountClaims {
iss: client_email.to_string(),
scope: "https://www.googleapis.com/auth/cloud-platform".to_string(),
aud: audience.to_string(),
exp,
iat: now,
};
let key = EncodingKey::from_rsa_pem(private_key.as_bytes())
.map_err(|e| format!("invalid service account private key: {e}"))?;
encode(&Header::new(Algorithm::RS256), &claims, &key)
.map_err(|e| format!("JWT encoding failed: {e}"))
}
fn exchange_jwt_for_token(token_uri: &str, jwt: &str) -> Result<GcpToken, GcpError> {
let body = format!("grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={jwt}");
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build();
let resp: serde_json::Value = agent
.post(token_uri)
.set("Content-Type", "application/x-www-form-urlencoded")
.send_string(&body)
.map_err(|e| GcpError::Auth(format!("JWT exchange request failed: {e}")))?
.into_json()
.map_err(|e| GcpError::Transport(e.to_string()))?;
resp["access_token"]
.as_str()
.map(|s| GcpToken(s.to_string()))
.ok_or_else(|| {
let err = resp["error"].as_str().unwrap_or("unknown");
GcpError::Auth(format!("JWT exchange failed: {err}"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_env_reads_google_cloud_project() {
let result = temp_env::with_var(
"GOOGLE_CLOUD_PROJECT",
Some("my-project"),
GcpConfig::from_env,
);
let cfg = result.unwrap();
assert_eq!(cfg.project_id, "my-project");
assert_eq!(cfg.endpoint, "https://secretmanager.googleapis.com/v1");
}
#[test]
fn from_env_falls_back_to_gcloud_project() {
let result = temp_env::with_vars(
[
("GOOGLE_CLOUD_PROJECT", None::<&str>),
("GCLOUD_PROJECT", Some("fallback-proj")),
],
GcpConfig::from_env,
);
let cfg = result.unwrap();
assert_eq!(cfg.project_id, "fallback-proj");
}
#[test]
fn from_env_treats_empty_google_cloud_project_as_missing() {
let result = temp_env::with_vars(
[
("GOOGLE_CLOUD_PROJECT", Some("")),
("GCLOUD_PROJECT", Some("fallback-proj")),
],
GcpConfig::from_env,
);
let cfg = result.unwrap();
assert_eq!(cfg.project_id, "fallback-proj");
}
#[test]
fn acquire_token_uses_google_oauth_token_env() {
let result =
temp_env::with_var("GOOGLE_OAUTH_TOKEN", Some("ya29.test-token"), acquire_token);
let tok = result.unwrap();
assert_eq!(tok.0, "ya29.test-token");
}
#[test]
fn adc_file_path_uses_google_application_credentials() {
let result = temp_env::with_var(
"GOOGLE_APPLICATION_CREDENTIALS",
Some("/tmp/service-account.json"),
adc_file_path,
);
assert_eq!(
result.unwrap().to_str().unwrap(),
"/tmp/service-account.json"
);
}
#[test]
fn fetch_adc_token_invalid_json_returns_auth_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("adc.json");
std::fs::write(&path, "not json").unwrap();
let result = temp_env::with_var(
"GOOGLE_APPLICATION_CREDENTIALS",
path.to_str(),
fetch_adc_token,
);
let err = result.unwrap_err();
assert!(
matches!(err, GcpError::Auth(ref msg) if msg.contains("invalid ADC JSON")),
"expected invalid ADC JSON auth error, got {err:?}"
);
}
#[test]
fn fetch_adc_token_unsupported_type_returns_auth_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("adc.json");
std::fs::write(&path, r#"{"type":"external_account"}"#).unwrap();
let result = temp_env::with_var(
"GOOGLE_APPLICATION_CREDENTIALS",
path.to_str(),
fetch_adc_token,
);
let err = result.unwrap_err();
assert!(
matches!(err, GcpError::Auth(ref msg) if msg.contains("unsupported ADC credential type 'external_account'")),
"expected unsupported credential type auth error, got {err:?}"
);
}
#[test]
fn refresh_authorized_user_token_missing_field_returns_auth_error() {
let creds = serde_json::json!({
"type": "authorized_user",
"client_id": "id.apps.googleusercontent.com"
});
let err = refresh_authorized_user_token(&creds).unwrap_err();
assert!(matches!(err, GcpError::Auth(_)));
}
#[test]
fn fetch_sa_token_missing_client_email_returns_error() {
let mut m = serde_json::Map::new();
m.insert("type".to_string(), serde_json::json!("service_account"));
m.insert(
concat!("private", "_", "key").to_string(),
serde_json::json!("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"),
);
let creds = serde_json::Value::Object(m);
let err = fetch_sa_token(&creds).unwrap_err();
assert!(matches!(err, GcpError::Auth(_)));
}
#[test]
fn fetch_sa_token_missing_private_key_returns_error() {
let mut m = serde_json::Map::new();
m.insert("type".to_string(), serde_json::json!("service_account"));
m.insert(
concat!("client", "_", "email").to_string(),
serde_json::json!("test@project.iam.gserviceaccount.com"),
);
let creds = serde_json::Value::Object(m);
let err = fetch_sa_token(&creds).unwrap_err();
assert!(matches!(err, GcpError::Auth(_)));
}
#[test]
fn sign_service_account_jwt_invalid_pem_returns_error() {
let err = sign_service_account_jwt(
"test@example.com",
"invalid-pem-key",
"https://oauth2.googleapis.com/token",
)
.unwrap_err();
assert!(err.contains("invalid service account private key"));
}
}