Skip to main content

meritocrab_github/
auth.rs

1use crate::error::{GithubError, GithubResult};
2use serde::{Deserialize, Serialize};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5/// GitHub App authentication configuration
6#[derive(Clone)]
7pub struct GithubAppAuth {
8    app_id: i64,
9    private_key: String,
10}
11
12impl GithubAppAuth {
13    /// Create new GitHub App authentication
14    pub fn new(app_id: i64, private_key: String) -> Self {
15        Self {
16            app_id,
17            private_key,
18        }
19    }
20
21    /// Get the app ID
22    pub fn app_id(&self) -> i64 {
23        self.app_id
24    }
25
26    /// Get the private key (exposed for JWT signing)
27    pub fn private_key(&self) -> &str {
28        &self.private_key
29    }
30
31    /// Generate a JWT token for GitHub App authentication
32    ///
33    /// GitHub requires JWTs to be signed with RS256 and have specific claims:
34    /// - iat: issued at time (current time)
35    /// - exp: expiration time (max 10 minutes from iat)
36    /// - iss: issuer (the app ID)
37    ///
38    /// Note: This is a placeholder that returns the necessary structure.
39    /// In production, use a proper JWT library like `jsonwebtoken` to sign with RS256.
40    pub fn generate_jwt(&self) -> GithubResult<String> {
41        let now = SystemTime::now()
42            .duration_since(UNIX_EPOCH)
43            .map_err(|e| GithubError::AuthError(format!("System time error: {}", e)))?
44            .as_secs() as i64;
45
46        let claims = JwtClaims {
47            iat: now,
48            exp: now + 600, // 10 minutes (max allowed by GitHub)
49            iss: self.app_id.to_string(),
50        };
51
52        // In production, this would use jsonwebtoken crate with RS256
53        // For now, return a placeholder that indicates what needs to be done
54        let jwt_payload = format!("PLACEHOLDER_JWT_FOR_APP_{}_AT_{}", self.app_id, claims.iat);
55
56        Ok(jwt_payload)
57    }
58}
59
60/// JWT claims for GitHub App authentication
61#[derive(Debug, Serialize, Deserialize)]
62struct JwtClaims {
63    /// Issued at time (Unix timestamp)
64    iat: i64,
65    /// Expiration time (Unix timestamp)
66    exp: i64,
67    /// Issuer (GitHub App ID)
68    iss: String,
69}
70
71/// Installation token for authenticating as a GitHub App installation
72#[derive(Debug, Clone)]
73pub struct InstallationToken {
74    token: String,
75    expires_at: SystemTime,
76}
77
78impl InstallationToken {
79    /// Create new installation token
80    pub fn new(token: String, expires_at: SystemTime) -> Self {
81        Self { token, expires_at }
82    }
83
84    /// Get the token value
85    pub fn token(&self) -> &str {
86        &self.token
87    }
88
89    /// Check if token is expired
90    pub fn is_expired(&self) -> bool {
91        SystemTime::now() >= self.expires_at
92    }
93
94    /// Check if token will expire soon (within 5 minutes)
95    pub fn is_expiring_soon(&self) -> bool {
96        if let Ok(duration) = self.expires_at.duration_since(SystemTime::now()) {
97            duration < Duration::from_secs(300) // 5 minutes
98        } else {
99            true // Already expired
100        }
101    }
102}
103
104/// Installation token manager that handles caching and refreshing
105pub struct InstallationTokenManager {
106    auth: GithubAppAuth,
107    cached_token: Option<InstallationToken>,
108}
109
110impl InstallationTokenManager {
111    /// Create new installation token manager
112    pub fn new(auth: GithubAppAuth) -> Self {
113        Self {
114            auth,
115            cached_token: None,
116        }
117    }
118
119    /// Get a valid installation token, refreshing if necessary
120    ///
121    /// This method would:
122    /// 1. Check if cached token exists and is still valid
123    /// 2. If not, generate a new JWT
124    /// 3. Use JWT to request installation token from GitHub API
125    /// 4. Cache and return the new token
126    pub async fn get_token(&mut self, installation_id: i64) -> GithubResult<String> {
127        // Check if we have a cached token that's still valid
128        if let Some(ref token) = self.cached_token {
129            if !token.is_expiring_soon() {
130                return Ok(token.token().to_string());
131            }
132        }
133
134        // Need to refresh token
135        self.refresh_token(installation_id).await
136    }
137
138    /// Refresh the installation token
139    async fn refresh_token(&mut self, installation_id: i64) -> GithubResult<String> {
140        // Generate JWT for app authentication
141        let _jwt = self.auth.generate_jwt()?;
142
143        // In production, this would:
144        // 1. Use the JWT to call GitHub API: POST /app/installations/{installation_id}/access_tokens
145        // 2. Parse the response to get token and expires_at
146        // 3. Cache the token
147        //
148        // For now, return a placeholder
149        let token_value = format!("ghs_installation_token_for_{}", installation_id);
150        let expires_at = SystemTime::now() + Duration::from_secs(3600); // 1 hour
151
152        let token = InstallationToken::new(token_value.clone(), expires_at);
153        self.cached_token = Some(token);
154
155        Ok(token_value)
156    }
157
158    /// Clear cached token (useful for testing or forcing refresh)
159    pub fn clear_cache(&mut self) {
160        self.cached_token = None;
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_github_app_auth_new() {
170        let auth = GithubAppAuth::new(12345, "private-key".to_string());
171        assert_eq!(auth.app_id(), 12345);
172        assert_eq!(auth.private_key(), "private-key");
173    }
174
175    #[test]
176    fn test_generate_jwt() {
177        let auth = GithubAppAuth::new(12345, "private-key".to_string());
178        let jwt = auth.generate_jwt();
179        assert!(jwt.is_ok());
180        let jwt_str = jwt.unwrap();
181        assert!(jwt_str.contains("12345"));
182    }
183
184    #[test]
185    fn test_installation_token_is_expired() {
186        let expired_time = SystemTime::now() - Duration::from_secs(60);
187        let token = InstallationToken::new("token".to_string(), expired_time);
188        assert!(token.is_expired());
189    }
190
191    #[test]
192    fn test_installation_token_not_expired() {
193        let future_time = SystemTime::now() + Duration::from_secs(3600);
194        let token = InstallationToken::new("token".to_string(), future_time);
195        assert!(!token.is_expired());
196    }
197
198    #[test]
199    fn test_installation_token_is_expiring_soon() {
200        let soon_time = SystemTime::now() + Duration::from_secs(120); // 2 minutes
201        let token = InstallationToken::new("token".to_string(), soon_time);
202        assert!(token.is_expiring_soon());
203    }
204
205    #[test]
206    fn test_installation_token_not_expiring_soon() {
207        let future_time = SystemTime::now() + Duration::from_secs(3600); // 1 hour
208        let token = InstallationToken::new("token".to_string(), future_time);
209        assert!(!token.is_expiring_soon());
210    }
211
212    #[tokio::test]
213    async fn test_installation_token_manager() {
214        let auth = GithubAppAuth::new(12345, "private-key".to_string());
215        let mut manager = InstallationTokenManager::new(auth);
216
217        let token = manager.get_token(67890).await;
218        assert!(token.is_ok());
219        assert!(token.unwrap().contains("67890"));
220    }
221
222    #[tokio::test]
223    async fn test_installation_token_manager_caching() {
224        let auth = GithubAppAuth::new(12345, "private-key".to_string());
225        let mut manager = InstallationTokenManager::new(auth);
226
227        // First call should create token
228        let token1 = manager.get_token(67890).await.unwrap();
229
230        // Second call should return cached token
231        let token2 = manager.get_token(67890).await.unwrap();
232
233        assert_eq!(token1, token2);
234    }
235
236    #[tokio::test]
237    async fn test_installation_token_manager_clear_cache() {
238        let auth = GithubAppAuth::new(12345, "private-key".to_string());
239        let mut manager = InstallationTokenManager::new(auth);
240
241        // Get initial token
242        let _token1 = manager.get_token(67890).await.unwrap();
243
244        // Clear cache
245        manager.clear_cache();
246
247        // Should refresh token
248        let token2 = manager.get_token(67890).await.unwrap();
249        assert!(token2.contains("67890"));
250    }
251}