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