use crate::{CloudCredentials, HttpExchange, OidcProviderError};
use super::CredentialExchange;
#[derive(Debug, Clone)]
pub struct GcpExchange {
pub provider_resource_name: String,
pub service_account_email: String,
pub sts_endpoint: String,
pub scopes: Vec<String>,
}
impl GcpExchange {
pub fn new(provider_resource_name: String, service_account_email: String) -> Self {
Self {
provider_resource_name,
service_account_email,
sts_endpoint: "https://sts.googleapis.com/v1/token".into(),
scopes: vec!["https://www.googleapis.com/auth/cloud-platform".into()],
}
}
fn generate_access_token_url(&self) -> String {
format!(
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken",
self.service_account_email
)
}
}
impl<H: HttpExchange> CredentialExchange<H> for GcpExchange {
async fn exchange(&self, http: &H, jwt: &str) -> Result<CloudCredentials, OidcProviderError> {
let sts_form = [
(
"grant_type",
"urn:ietf:params:oauth:grant-type:token-exchange",
),
("audience", &self.provider_resource_name),
("scope", "https://www.googleapis.com/auth/cloud-platform"),
(
"requested_token_type",
"urn:ietf:params:oauth:token-type:access_token",
),
("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"),
("subject_token", jwt),
];
let sts_body = http.post_form(&self.sts_endpoint, &sts_form).await?;
let federated_token = parse_sts_token_response(&sts_body)?;
let scopes_str = self.scopes.join(",");
let impersonation_form = [
("scope", scopes_str.as_str()),
("_bearer_token", &federated_token),
];
let iam_body = http
.post_form(&self.generate_access_token_url(), &impersonation_form)
.await?;
parse_generate_access_token_response(&iam_body)
}
}
fn parse_sts_token_response(json: &str) -> Result<String, OidcProviderError> {
let parsed: serde_json::Value = serde_json::from_str(json)
.map_err(|e| OidcProviderError::ExchangeError(format!("invalid GCP STS response: {e}")))?;
if let Some(err) = parsed.get("error") {
let desc = parsed
.get("error_description")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(OidcProviderError::ExchangeError(format!(
"GCP STS error: {err} — {desc}"
)));
}
parsed["access_token"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| {
OidcProviderError::ExchangeError("missing access_token in STS response".into())
})
}
fn parse_generate_access_token_response(json: &str) -> Result<CloudCredentials, OidcProviderError> {
let parsed: serde_json::Value = serde_json::from_str(json).map_err(|e| {
OidcProviderError::ExchangeError(format!("invalid generateAccessToken response: {e}"))
})?;
let access_token = parsed["accessToken"]
.as_str()
.ok_or_else(|| OidcProviderError::ExchangeError("missing accessToken".into()))?;
let expire_time = parsed["expireTime"]
.as_str()
.ok_or_else(|| OidcProviderError::ExchangeError("missing expireTime".into()))?;
let expires_at = chrono::DateTime::parse_from_rfc3339(expire_time)
.map_err(|e| OidcProviderError::ExchangeError(format!("invalid expireTime: {e}")))?
.with_timezone(&chrono::Utc);
Ok(CloudCredentials {
access_key_id: String::new(),
secret_access_key: String::new(),
session_token: access_token.to_string(),
expires_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_sts_token() {
let json = r#"{"access_token": "ya29.federated-token", "token_type": "Bearer", "expires_in": 3600}"#;
let token = parse_sts_token_response(json).unwrap();
assert_eq!(token, "ya29.federated-token");
}
#[test]
fn parse_sts_error() {
let json = r#"{"error": "invalid_grant", "error_description": "bad token"}"#;
let err = parse_sts_token_response(json).unwrap_err();
assert!(err.to_string().contains("GCP STS error"));
}
#[test]
fn parse_generate_access_token() {
let json = r#"{
"accessToken": "ya29.sa-access-token",
"expireTime": "2025-06-15T12:00:00Z"
}"#;
let creds = parse_generate_access_token_response(json).unwrap();
assert_eq!(creds.session_token, "ya29.sa-access-token");
assert_eq!(creds.expires_at.to_rfc3339(), "2025-06-15T12:00:00+00:00");
}
#[test]
fn parse_generate_access_token_missing_field() {
let json = r#"{"accessToken": "tok"}"#;
let err = parse_generate_access_token_response(json).unwrap_err();
assert!(err.to_string().contains("expireTime"));
}
#[test]
fn generate_access_token_url_format() {
let ex = GcpExchange::new(
"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov".into(),
"my-sa@my-project.iam.gserviceaccount.com".into(),
);
assert!(ex
.generate_access_token_url()
.contains("my-sa@my-project.iam.gserviceaccount.com"));
}
}