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;