use crate::credentials::errors::CredentialsError;
use crate::credentials::service_account::jws::{JwsClaims, JwsHeader};
use crate::{Result, errors};
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
use rustls::sign::Signer;
use rustls_pki_types::PrivateKeyDer;
use rustls_pki_types::pem::PemObject;
use serde::Deserialize;
#[derive(Deserialize, Clone)]
struct GdchServiceAccountKey {
#[serde(rename = "type")]
cred_type: String,
format_version: String,
project: String,
private_key_id: String,
private_key: String,
name: String,
ca_cert_path: Option<String>,
token_uri: String,
}
impl GdchServiceAccountKey {
#[allow(dead_code)]
fn signer(&self) -> std::result::Result<Box<dyn Signer>, CredentialsError> {
let private_key = self.private_key.clone();
let key_provider = crate::credentials::crypto_provider::get_key_provider();
let key_der = PrivateKeyDer::from_pem_slice(private_key.as_bytes()).map_err(|e| {
errors::non_retryable_from_str(format!(
"failed to parse GDCH service account private key PEM: {}",
e,
))
})?;
let pk = key_provider
.load_private_key(key_der)
.map_err(|e| CredentialsError::from_source(false, e))?;
pk.choose_scheme(&[rustls::SignatureScheme::ECDSA_NISTP256_SHA256])
.ok_or_else(|| errors::non_retryable_from_str(
"unable to choose ECDSA_NISTP256_SHA256 signing scheme as it is not supported by current signer",
))
}
}
impl std::fmt::Debug for GdchServiceAccountKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GdchServiceAccountKey")
.field("type", &self.cred_type)
.field("format_version", &self.format_version)
.field("project", &self.project)
.field("name", &self.name)
.field("ca_cert_path", &self.ca_cert_path)
.field("private_key_id", &self.private_key_id)
.field("private_key", &"[censored]")
.field("token_uri", &self.token_uri)
.finish()
}
}
#[derive(Debug)]
#[allow(dead_code)]
struct GdchServiceAccountTokenProvider {
#[allow(dead_code)]
audience: String,
key: GdchServiceAccountKey,
}
impl GdchServiceAccountTokenProvider {
#[allow(dead_code)]
fn new(audience: String, key: GdchServiceAccountKey) -> Self {
Self { audience, key }
}
#[allow(dead_code)]
fn generate_subject_token(&self) -> Result<String> {
let current_time = time::OffsetDateTime::now_utc();
let header = JwsHeader {
alg: "ES256",
typ: "JWT",
kid: Some(self.key.private_key_id.clone()),
};
let iss = format!(
"system:serviceaccount:{}:{}",
self.key.project, self.key.name
);
let claims = JwsClaims {
iss: iss.clone(),
sub: Some(iss),
aud: Some(self.key.token_uri.clone()),
iat: current_time,
exp: current_time + std::time::Duration::from_secs(3600),
scope: None,
typ: None,
target_audience: None,
};
let encoded_header = header.encode()?;
let encoded_claims = claims.encode()?;
let to_sign = format!("{}.{}", encoded_header, encoded_claims);
let signer = self.key.signer()?;
let sig_der = signer
.sign(to_sign.as_bytes())
.map_err(errors::non_retryable)?;
let sig = p256::ecdsa::Signature::from_der(&sig_der).map_err(|e| {
errors::non_retryable_from_str(format!("failed to parse ecdsa DER signature: {}", e))
})?;
let encoded_sig = BASE64_URL_SAFE_NO_PAD.encode(&sig.to_bytes()[..]);
Ok(format!("{}.{}", to_sign, encoded_sig))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn get_mock_key() -> GdchServiceAccountKey {
GdchServiceAccountKey {
cred_type: "gdch_service_account".to_string(),
format_version: "1".to_string(),
project: "test-project".to_string(),
private_key_id: "test-key-id".to_string(),
private_key: (*crate::credentials::tests::ES256_PEM).clone(),
name: "test-name".to_string(),
ca_cert_path: None,
token_uri: "http://localhost/token".to_string(),
}
}
#[test]
fn debug_gdch_service_account_key() {
let key = get_mock_key();
let fmt = format!("{key:?}");
assert!(fmt.contains("GdchServiceAccountKey"));
assert!(fmt.contains("test-project"));
assert!(fmt.contains("test-name"));
assert!(fmt.contains("test-key-id"));
assert!(fmt.contains("[censored]"));
assert!(!fmt.contains(crate::credentials::tests::ES256_PEM.as_str()));
}
#[test]
fn parse_valid_json() {
let json = json!({
"type": "gdch_service_account",
"format_version": "1",
"project": "test-project",
"private_key_id": "test-key-id",
"private_key": crate::credentials::tests::ES256_PEM.as_str(),
"name": "test-name",
"token_uri": "http://localhost/token"
});
let key: GdchServiceAccountKey = serde_json::from_value(json).unwrap();
assert_eq!(key.cred_type, "gdch_service_account");
assert_eq!(key.project, "test-project");
}
#[test]
fn generate_subject_token() {
let key = get_mock_key();
let provider = GdchServiceAccountTokenProvider::new("test-audience".to_string(), key);
let jwt = provider.generate_subject_token().unwrap();
let parts: Vec<&str> = jwt.split('.').collect();
assert_eq!(parts.len(), 3);
let header = String::from_utf8(BASE64_URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap();
let claims = String::from_utf8(BASE64_URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
let header_json: serde_json::Value = serde_json::from_str(&header).unwrap();
let claims_json: serde_json::Value = serde_json::from_str(&claims).unwrap();
assert_eq!(header_json["alg"], "ES256");
assert_eq!(header_json["typ"], "JWT");
assert_eq!(header_json["kid"], "test-key-id");
assert_eq!(
claims_json["iss"],
"system:serviceaccount:test-project:test-name"
);
assert_eq!(
claims_json["sub"],
"system:serviceaccount:test-project:test-name"
);
assert_eq!(claims_json["aud"], "http://localhost/token");
}
}