Skip to main content

github_bot_sdk/auth/
jwt.rs

1//! JWT (JSON Web Token) generation for GitHub App authentication.
2//!
3//! This module provides JWT generation capabilities required for GitHub App authentication.
4//! JWTs are used to authenticate as a GitHub App and exchange for installation tokens.
5//!
6//! # GitHub Requirements
7//!
8//! - JWTs must use RS256 algorithm (RSA Signature with SHA-256)
9//! - Maximum expiration time is 10 minutes from issuance
10//! - Claims must include `iss` (app ID), `iat` (issued at), and `exp` (expiration)
11//!
12//! See `docs/specs/interfaces/` for complete interface specifications.
13
14use crate::auth::{GitHubAppId, JsonWebToken, JwtClaims, KeyAlgorithm, PrivateKey};
15use crate::error::{AuthError, ValidationError};
16use chrono::{Duration, Utc};
17use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
18use rsa::pkcs1::DecodeRsaPrivateKey;
19use rsa::RsaPrivateKey;
20
21/// Interface for JWT token generation and signing.
22///
23/// This trait abstracts JWT generation to allow for different implementations
24/// (production RSA signing, mock generators for testing, etc.).
25///
26/// # Examples
27///
28/// ```no_run
29/// # use github_bot_sdk::auth::jwt::JwtGenerator;
30/// # use github_bot_sdk::auth::{GitHubAppId, PrivateKey};
31/// # async fn example(generator: impl JwtGenerator) {
32/// let app_id = GitHubAppId::new(123456);
33/// let token = generator.generate_jwt(app_id).await.unwrap();
34/// assert!(!token.is_expired());
35/// # }
36/// ```
37#[async_trait::async_trait]
38pub trait JwtGenerator: Send + Sync {
39    /// Generate a JWT token for GitHub App authentication.
40    ///
41    /// Creates a JWT with the following claims:
42    /// - `iss`: GitHub App ID
43    /// - `iat`: Current timestamp (issued at)
44    /// - `exp`: Expiration timestamp (issued at + duration, max 10 minutes)
45    ///
46    /// # Arguments
47    ///
48    /// * `app_id` - The GitHub App ID to include in the token
49    ///
50    /// # Returns
51    ///
52    /// A `JsonWebToken` containing the signed JWT string and metadata.
53    ///
54    /// # Errors
55    ///
56    /// Returns `AuthError` if:
57    /// - Private key is invalid or cannot be loaded
58    /// - JWT signing fails
59    /// - System clock is unreliable
60    ///
61    /// # Examples
62    ///
63    /// ```no_run
64    /// # use github_bot_sdk::auth::jwt::JwtGenerator;
65    /// # use github_bot_sdk::auth::GitHubAppId;
66    /// # async fn example(generator: impl JwtGenerator) {
67    /// let app_id = GitHubAppId::new(123456);
68    /// let jwt = generator.generate_jwt(app_id).await.expect("JWT generation failed");
69    ///
70    /// // Token is valid for up to 10 minutes
71    /// assert!(!jwt.is_expired());
72    /// assert_eq!(jwt.app_id(), app_id);
73    /// # }
74    /// ```
75    async fn generate_jwt(&self, app_id: GitHubAppId) -> Result<JsonWebToken, AuthError>;
76
77    /// Get the JWT expiration duration configured for this generator.
78    ///
79    /// Returns the duration from issuance to expiration. This value should not
80    /// exceed 10 minutes (GitHub's maximum).
81    fn expiration_duration(&self) -> Duration;
82}
83
84/// RS256 JWT generator using RSA private keys.
85///
86/// This is the standard implementation for GitHub App authentication. It uses
87/// RSA-SHA256 signing as required by GitHub's API.
88///
89/// # Examples
90///
91/// ```no_run
92/// # use github_bot_sdk::auth::jwt::RS256JwtGenerator;
93/// # use github_bot_sdk::auth::PrivateKey;
94/// # use chrono::Duration;
95/// # let key_pem = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----";
96/// let private_key = PrivateKey::from_pem(key_pem).unwrap();
97/// let generator = RS256JwtGenerator::new(private_key);
98///
99/// // Generator is ready to produce JWTs
100/// ```
101pub struct RS256JwtGenerator {
102    private_key: PrivateKey,
103    expiration_duration: Duration,
104}
105
106impl RS256JwtGenerator {
107    /// Create a new RS256 JWT generator.
108    ///
109    /// # Arguments
110    ///
111    /// * `private_key` - RSA private key for signing JWTs
112    ///
113    /// # Examples
114    ///
115    /// ```no_run
116    /// # use github_bot_sdk::auth::jwt::RS256JwtGenerator;
117    /// # use github_bot_sdk::auth::PrivateKey;
118    /// # let key_pem = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----";
119    /// let private_key = PrivateKey::from_pem(key_pem).unwrap();
120    /// let generator = RS256JwtGenerator::new(private_key);
121    /// ```
122    pub fn new(private_key: PrivateKey) -> Self {
123        Self {
124            private_key,
125            expiration_duration: Duration::minutes(10), // GitHub's maximum
126        }
127    }
128
129    /// Create a new RS256 JWT generator with custom expiration duration.
130    ///
131    /// # Arguments
132    ///
133    /// * `private_key` - RSA private key for signing JWTs
134    /// * `expiration_duration` - How long JWTs should be valid (max 10 minutes)
135    ///
136    /// # Panics
137    ///
138    /// Panics if `expiration_duration` exceeds 10 minutes.
139    ///
140    /// # Examples
141    ///
142    /// ```no_run
143    /// # use github_bot_sdk::auth::jwt::RS256JwtGenerator;
144    /// # use github_bot_sdk::auth::PrivateKey;
145    /// # use chrono::Duration;
146    /// # let key_pem = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----";
147    /// let private_key = PrivateKey::from_pem(key_pem).unwrap();
148    ///
149    /// // Use 8-minute expiration for extra safety margin
150    /// let generator = RS256JwtGenerator::with_expiration(
151    ///     private_key,
152    ///     Duration::minutes(8)
153    /// );
154    /// ```
155    pub fn with_expiration(private_key: PrivateKey, expiration_duration: Duration) -> Self {
156        assert!(
157            expiration_duration <= Duration::minutes(10),
158            "JWT expiration cannot exceed 10 minutes (GitHub requirement)"
159        );
160
161        Self {
162            private_key,
163            expiration_duration,
164        }
165    }
166
167    /// Build JWT claims for the given app ID.
168    fn build_claims(&self, app_id: GitHubAppId) -> JwtClaims {
169        let now = Utc::now();
170        let iat = now.timestamp();
171        let exp = (now + self.expiration_duration).timestamp();
172
173        JwtClaims {
174            iss: app_id,
175            iat,
176            exp,
177        }
178    }
179}
180
181#[async_trait::async_trait]
182impl JwtGenerator for RS256JwtGenerator {
183    async fn generate_jwt(&self, app_id: GitHubAppId) -> Result<JsonWebToken, AuthError> {
184        let claims = self.build_claims(app_id);
185        let expires_at = Utc::now() + self.expiration_duration;
186
187        // Create encoding key from private key
188        let encoding_key = EncodingKey::from_rsa_pem(self.private_key.key_data()).map_err(|e| {
189            AuthError::InvalidPrivateKey {
190                message: format!("Failed to create encoding key: {}", e),
191            }
192        })?;
193
194        // Set up JWT header for RS256
195        let header = Header::new(Algorithm::RS256);
196
197        // Encode the JWT
198        let token_string = encode(&header, &claims, &encoding_key).map_err(|e| {
199            AuthError::JwtGenerationFailed {
200                message: format!("Failed to encode JWT: {}", e),
201            }
202        })?;
203
204        Ok(JsonWebToken::new(token_string, app_id, expires_at))
205    }
206
207    fn expiration_duration(&self) -> Duration {
208        self.expiration_duration
209    }
210}
211
212impl PrivateKey {
213    /// Create a private key from PEM-encoded string.
214    ///
215    /// # Arguments
216    ///
217    /// * `pem` - PEM-encoded RSA private key
218    ///
219    /// # Errors
220    ///
221    /// Returns `ValidationError` if:
222    /// - PEM format is invalid
223    /// - Key type is not RSA
224    /// - Key data is corrupted
225    ///
226    /// # Examples
227    ///
228    /// ```no_run
229    /// # use github_bot_sdk::auth::PrivateKey;
230    /// let pem = r#"-----BEGIN RSA PRIVATE KEY-----
231    /// MIIEpAIBAAKCAQEA...
232    /// -----END RSA PRIVATE KEY-----"#;
233    ///
234    /// let key = PrivateKey::from_pem(pem).expect("Invalid PEM");
235    /// ```
236    pub fn from_pem(pem: &str) -> Result<Self, ValidationError> {
237        // Trim whitespace
238        let pem = pem.trim();
239
240        // Validate PEM format
241        if pem.is_empty() {
242            return Err(ValidationError::InvalidFormat {
243                field: "private_key".to_string(),
244                message: "PEM string cannot be empty".to_string(),
245            });
246        }
247
248        if !pem.contains("-----BEGIN") || !pem.contains("-----END") {
249            return Err(ValidationError::InvalidFormat {
250                field: "private_key".to_string(),
251                message: "Invalid PEM format: missing BEGIN/END markers".to_string(),
252            });
253        }
254
255        // Attempt to parse the RSA private key to validate it
256        RsaPrivateKey::from_pkcs1_pem(pem).map_err(|e| ValidationError::InvalidFormat {
257            field: "private_key".to_string(),
258            message: format!("Failed to parse RSA private key: {}", e),
259        })?;
260
261        // Store the PEM bytes
262        Ok(Self {
263            key_data: pem.as_bytes().to_vec(),
264            algorithm: KeyAlgorithm::RS256,
265        })
266    }
267
268    /// Create a private key from PKCS#8 DER-encoded bytes.
269    ///
270    /// # Arguments
271    ///
272    /// * `der` - DER-encoded PKCS#8 private key bytes
273    ///
274    /// # Errors
275    ///
276    /// Returns `ValidationError` if:
277    /// - DER format is invalid
278    /// - Key type is not RSA
279    /// - Key data is corrupted
280    pub fn from_pkcs8_der(der: &[u8]) -> Result<Self, ValidationError> {
281        // Validate by attempting to parse
282        use rsa::pkcs8::DecodePrivateKey;
283        RsaPrivateKey::from_pkcs8_der(der).map_err(|e| ValidationError::InvalidFormat {
284            field: "private_key".to_string(),
285            message: format!("Failed to parse PKCS#8 DER private key: {}", e),
286        })?;
287
288        Ok(Self {
289            key_data: der.to_vec(),
290            algorithm: KeyAlgorithm::RS256,
291        })
292    }
293}
294
295#[cfg(test)]
296#[path = "jwt_tests.rs"]
297mod tests;