use crate::auth::{GitHubAppId, JsonWebToken, JwtClaims, KeyAlgorithm, PrivateKey};
use crate::error::{AuthError, ValidationError};
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use rsa::pkcs1::DecodeRsaPrivateKey;
use rsa::RsaPrivateKey;
#[async_trait::async_trait]
pub trait JwtGenerator: Send + Sync {
async fn generate_jwt(&self, app_id: GitHubAppId) -> Result<JsonWebToken, AuthError>;
fn expiration_duration(&self) -> Duration;
}
pub struct RS256JwtGenerator {
private_key: PrivateKey,
expiration_duration: Duration,
}
impl RS256JwtGenerator {
pub fn new(private_key: PrivateKey) -> Self {
Self {
private_key,
expiration_duration: Duration::minutes(10), }
}
pub fn with_expiration(private_key: PrivateKey, expiration_duration: Duration) -> Self {
assert!(
expiration_duration <= Duration::minutes(10),
"JWT expiration cannot exceed 10 minutes (GitHub requirement)"
);
Self {
private_key,
expiration_duration,
}
}
fn build_claims(&self, app_id: GitHubAppId) -> JwtClaims {
let now = Utc::now();
let iat = now.timestamp();
let exp = (now + self.expiration_duration).timestamp();
JwtClaims {
iss: app_id,
iat,
exp,
}
}
}
#[async_trait::async_trait]
impl JwtGenerator for RS256JwtGenerator {
async fn generate_jwt(&self, app_id: GitHubAppId) -> Result<JsonWebToken, AuthError> {
let claims = self.build_claims(app_id);
let expires_at = Utc::now() + self.expiration_duration;
let encoding_key = EncodingKey::from_rsa_pem(self.private_key.key_data()).map_err(|e| {
AuthError::InvalidPrivateKey {
message: format!("Failed to create encoding key: {}", e),
}
})?;
let header = Header::new(Algorithm::RS256);
let token_string = encode(&header, &claims, &encoding_key).map_err(|e| {
AuthError::JwtGenerationFailed {
message: format!("Failed to encode JWT: {}", e),
}
})?;
Ok(JsonWebToken::new(token_string, app_id, expires_at))
}
fn expiration_duration(&self) -> Duration {
self.expiration_duration
}
}
impl PrivateKey {
pub fn from_pem(pem: &str) -> Result<Self, ValidationError> {
let pem = pem.trim();
if pem.is_empty() {
return Err(ValidationError::InvalidFormat {
field: "private_key".to_string(),
message: "PEM string cannot be empty".to_string(),
});
}
if !pem.contains("-----BEGIN") || !pem.contains("-----END") {
return Err(ValidationError::InvalidFormat {
field: "private_key".to_string(),
message: "Invalid PEM format: missing BEGIN/END markers".to_string(),
});
}
RsaPrivateKey::from_pkcs1_pem(pem).map_err(|e| ValidationError::InvalidFormat {
field: "private_key".to_string(),
message: format!("Failed to parse RSA private key: {}", e),
})?;
Ok(Self {
key_data: pem.as_bytes().to_vec(),
algorithm: KeyAlgorithm::RS256,
})
}
pub fn from_pkcs8_der(der: &[u8]) -> Result<Self, ValidationError> {
use rsa::pkcs8::DecodePrivateKey;
RsaPrivateKey::from_pkcs8_der(der).map_err(|e| ValidationError::InvalidFormat {
field: "private_key".to_string(),
message: format!("Failed to parse PKCS#8 DER private key: {}", e),
})?;
Ok(Self {
key_data: der.to_vec(),
algorithm: KeyAlgorithm::RS256,
})
}
}
#[cfg(test)]
#[path = "jwt_tests.rs"]
mod tests;