use serde::Deserialize;
use std::time::{SystemTime, UNIX_EPOCH};
const LIFETIME_SECONDS: u64 = 3600;
const TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
#[derive(Debug, Deserialize)]
pub struct ServiceAccount {
pub client_email: String,
pub private_key: String,
#[serde(default)]
pub token_uri: Option<String>,
}
impl ServiceAccount {
pub fn load(value: &str, root: &std::path::Path) -> Result<Self, String> {
let text = if value.trim_start().starts_with('{') {
value.to_string()
} else {
let path = root.join(value.trim());
std::fs::read_to_string(&path)
.map_err(|err| format!("cannot read {}: {err}", path.display()))?
};
serde_yaml::from_str(&text)
.map_err(|err| format!("this does not look like a service account key: {err}"))
}
pub fn token_uri(&self) -> &str {
self.token_uri.as_deref().unwrap_or(TOKEN_URI)
}
}
pub fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let triple = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
let indices = [
(triple >> 18) & 0x3F,
(triple >> 12) & 0x3F,
(triple >> 6) & 0x3F,
triple & 0x3F,
];
for (position, index) in indices.iter().enumerate() {
if position <= chunk.len() {
out.push(ALPHABET[*index as usize] as char);
}
}
}
out
}
pub fn decode_base64(text: &str) -> Result<Vec<u8>, String> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut buffer: u32 = 0;
let mut bits = 0;
let mut out = Vec::new();
for byte in text.bytes() {
if byte.is_ascii_whitespace() || byte == b'=' {
continue;
}
let Some(value) = ALPHABET.iter().position(|candidate| *candidate == byte) else {
return Err(format!("'{}' is not base64", byte as char));
};
buffer = (buffer << 6) | value as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buffer >> bits) as u8);
}
}
Ok(out)
}
pub fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
let body: String = pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect();
if body.is_empty() {
return Err("the private key is empty or not in PEM form".to_string());
}
decode_base64(&body)
}
pub fn assertion_claims(account: &ServiceAccount, scope: &str, now: u64) -> String {
let header = base64url(br#"{"alg":"RS256","typ":"JWT"}"#);
let claims = format!(
r#"{{"iss":"{}","scope":"{scope}","aud":"{}","iat":{now},"exp":{}}}"#,
account.client_email,
account.token_uri(),
now + LIFETIME_SECONDS
);
format!("{header}.{}", base64url(claims.as_bytes()))
}
fn now_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default()
}
pub fn assertion(account: &ServiceAccount, scope: &str) -> Result<String, String> {
let der = pem_to_der(&account.private_key)?;
let key = ring::signature::RsaKeyPair::from_pkcs8(&der)
.map_err(|err| format!("the service account's private key could not be read: {err}"))?;
let signed = assertion_claims(account, scope, now_seconds());
let mut signature = vec![0; key.public().modulus_len()];
key.sign(
&ring::signature::RSA_PKCS1_SHA256,
&ring::rand::SystemRandom::new(),
signed.as_bytes(),
&mut signature,
)
.map_err(|err| format!("could not sign the request: {err}"))?;
Ok(format!("{signed}.{}", base64url(&signature)))
}
pub fn form_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
pub fn token_request_body(assertion: &str) -> String {
format!(
"grant_type={}&assertion={}",
form_encode("urn:ietf:params:oauth:grant-type:jwt-bearer"),
form_encode(assertion)
)
}
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn account() -> ServiceAccount {
ServiceAccount {
client_email: "bot@example.iam.gserviceaccount.com".to_string(),
private_key: String::new(),
token_uri: None,
}
}
#[test]
fn encodes_base64url_without_padding() {
assert_eq!(base64url(b"hello"), "aGVsbG8");
assert_eq!(base64url(b"hi"), "aGk");
assert_eq!(base64url(b""), "");
assert_eq!(base64url(&[251, 255]), "-_8");
}
#[test]
fn round_trips_through_the_standard_alphabet() {
assert_eq!(decode_base64("aGVsbG8=").expect("valid"), b"hello");
assert!(decode_base64("!!!").is_err());
}
#[test]
fn strips_pem_armour() {
let pem = "-----BEGIN PRIVATE KEY-----\naGVsbG8=\n-----END PRIVATE KEY-----\n";
assert_eq!(pem_to_der(pem).expect("valid"), b"hello");
assert!(pem_to_der("").is_err());
}
#[test]
fn claims_carry_the_account_scope_and_expiry() {
let claims = assertion_claims(&account(), "https://example.com/scope", 1_000);
let (header, payload) = claims.split_once('.').expect("two parts");
assert!(!header.is_empty());
let decoded = String::from_utf8(
decode_base64(&payload.replace('-', "+").replace('_', "/")).expect("valid"),
)
.expect("utf8");
assert!(
decoded.contains("\"iss\":\"bot@example.iam.gserviceaccount.com\""),
"{decoded}"
);
assert!(
decoded.contains("\"scope\":\"https://example.com/scope\""),
"{decoded}"
);
assert!(decoded.contains("\"iat\":1000"), "{decoded}");
assert!(decoded.contains("\"exp\":4600"), "{decoded}");
assert!(decoded.contains("oauth2.googleapis.com"), "{decoded}");
}
#[test]
fn reads_a_service_account_from_json() {
let json = r#"{"client_email":"a@b.com","private_key":"-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n","project_id":"demo"}"#;
let account = ServiceAccount::load(json, std::path::Path::new(".")).expect("valid");
assert_eq!(account.client_email, "a@b.com");
assert!(account.private_key.contains("BEGIN PRIVATE KEY"));
}
#[test]
fn rejects_json_that_is_not_a_service_account() {
assert!(ServiceAccount::load("{\"hello\":1}", std::path::Path::new(".")).is_err());
}
#[test]
fn form_encodes_the_grant_type() {
let body = token_request_body("a.b.c");
assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"));
assert!(body.ends_with("assertion=a.b.c"));
}
}