use std::sync::Mutex;
use std::time::{Duration, Instant};
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("missing environment variable: {0}")]
MissingEnvVar(String),
#[error("failed to read key file: {0}")]
KeyReadError(String),
#[error("invalid key data: {0}")]
InvalidKey(String),
#[error("jwt encoding failed: {0}")]
EncodingError(String),
}
#[derive(Debug, Serialize)]
struct Claims {
iss: String,
iat: i64,
exp: i64,
aud: String,
}
struct CachedToken {
token: String,
created_at: Instant,
}
pub struct Credentials {
key_id: String,
issuer_id: String,
key_pem: Vec<u8>,
cache: Mutex<Option<CachedToken>>,
}
const TOKEN_LIFETIME: Duration = Duration::from_secs(15 * 60);
impl Credentials {
pub fn new(key_id: String, issuer_id: String, key_pem: Vec<u8>) -> Self {
Self {
key_id,
issuer_id,
key_pem,
cache: Mutex::new(None),
}
}
pub fn from_env() -> Result<Self, AuthError> {
let key_id = std::env::var("ASC_KEY_ID")
.map_err(|_| AuthError::MissingEnvVar("ASC_KEY_ID".into()))?;
let issuer_id = std::env::var("ASC_ISSUER_ID")
.map_err(|_| AuthError::MissingEnvVar("ASC_ISSUER_ID".into()))?;
let key_path = std::env::var("ASC_PRIVATE_KEY_PATH")
.map_err(|_| AuthError::MissingEnvVar("ASC_PRIVATE_KEY_PATH".into()))?;
let key_pem =
std::fs::read(&key_path).map_err(|e| AuthError::KeyReadError(e.to_string()))?;
Ok(Self::new(key_id, issuer_id, key_pem))
}
pub fn token(&self) -> Result<String, AuthError> {
let mut cache = self
.cache
.lock()
.map_err(|_| AuthError::EncodingError("token cache lock poisoned".into()))?;
if let Some(cached) = cache.as_ref() {
if cached.created_at.elapsed() < TOKEN_LIFETIME {
return Ok(cached.token.clone());
}
}
let token = self.generate_token()?;
*cache = Some(CachedToken {
token: token.clone(),
created_at: Instant::now(),
});
Ok(token)
}
fn generate_token(&self) -> Result<String, AuthError> {
let now = chrono::Utc::now().timestamp();
let claims = Claims {
iss: self.issuer_id.clone(),
iat: now,
exp: now + 20 * 60,
aud: "appstoreconnect-v1".into(),
};
let mut header = Header::new(Algorithm::ES256);
header.kid = Some(self.key_id.clone());
header.typ = Some("JWT".into());
let key = EncodingKey::from_ec_pem(&self.key_pem)
.map_err(|e| AuthError::InvalidKey(e.to_string()))?;
encode(&header, &claims, &key).map_err(|e| AuthError::EncodingError(e.to_string()))
}
}
#[cfg(test)]
#[path = "auth_tests.rs"]
mod tests;