use std::time::{SystemTime, UNIX_EPOCH};
use crate::provider::AppleConfig;
pub fn mint_client_secret(cfg: &AppleConfig, client_id: &str) -> Result<String, String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("apple jwt clock: {e}"))?
.as_secs();
let exp = now + 5 * 60;
let header = serde_json::json!({
"alg": "ES256",
"kid": cfg.key_id,
});
let claims = serde_json::json!({
"iss": cfg.team_id,
"iat": now,
"exp": exp,
"aud": "https://appleid.apple.com",
"sub": client_id,
});
let header_b64 = base64_url(serde_json::to_vec(&header).map_err(|e| e.to_string())?);
let claims_b64 = base64_url(serde_json::to_vec(&claims).map_err(|e| e.to_string())?);
let signing_input = format!("{header_b64}.{claims_b64}");
let signature = es256_sign(&cfg.private_key_pem, signing_input.as_bytes())?;
let sig_b64 = base64_url(signature);
Ok(format!("{signing_input}.{sig_b64}"))
}
fn es256_sign(key_pem_or_path: &str, msg: &[u8]) -> Result<Vec<u8>, String> {
let pem = if key_pem_or_path.contains("BEGIN") {
key_pem_or_path.to_string()
} else {
std::fs::read_to_string(key_pem_or_path)
.map_err(|e| format!("apple key read {key_pem_or_path}: {e}"))?
};
let der = pem_to_der(&pem)?;
use ring::rand::SystemRandom;
use ring::signature::{EcdsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING};
let rng = SystemRandom::new();
let key_pair =
EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &der, &rng)
.map_err(|e| format!("apple key parse: {e}"))?;
let sig = key_pair
.sign(&rng, msg)
.map_err(|e| format!("apple key sign: {e}"))?;
Ok(sig.as_ref().to_vec())
}
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
let body: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
base64_decode(body.trim()).map_err(|e| format!("apple key base64 decode: {e}"))
}
pub(crate) fn base64_url(bytes: impl AsRef<[u8]>) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
URL_SAFE_NO_PAD.encode(bytes.as_ref())
}
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
use base64::{engine::general_purpose::STANDARD, Engine};
STANDARD
.decode(input)
.map_err(|e| format!("base64 decode: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mint_produces_three_segment_jwt_with_correct_header() {
let cfg = AppleConfig {
team_id: "TEAMID12".into(),
key_id: "KEYID0001".into(),
private_key_pem: "not-a-real-key".into(),
};
let r = mint_client_secret(&cfg, "com.example.app");
assert!(r.is_err(), "garbage PEM should error, got: {r:?}");
}
#[test]
fn pem_to_der_strips_armor_and_whitespace() {
let pem = "-----BEGIN PRIVATE KEY-----\nQUJDREVG\n-----END PRIVATE KEY-----\n";
let der = pem_to_der(pem).expect("decode");
assert_eq!(der, b"ABCDEF");
}
#[test]
fn base64_url_drops_padding_and_swaps_chars() {
let raw = b"Hello, world!?";
let encoded = base64_url(raw);
assert!(!encoded.contains('+'));
assert!(!encoded.contains('/'));
assert!(!encoded.ends_with('='));
}
}