Skip to main content

github_bot_sdk/auth/
tokens.rs

1//! GitHub App token management and AuthProvider implementation.
2//!
3//! This module provides the concrete implementation of GitHub App authentication,
4//! including JWT generation, installation token exchange, and intelligent caching.
5//!
6//! See `docs/specs/interfaces/` for complete interface specifications.
7
8use async_trait::async_trait;
9use chrono::Duration;
10use std::sync::Arc;
11
12use super::{
13    AuthenticationProvider, GitHubApiClient, Installation, InstallationId, InstallationToken,
14    JsonWebToken, JwtClaims, JwtSigner, Repository, SecretProvider, TokenCache,
15};
16use crate::error::AuthError;
17
18/// Configuration for authentication behavior.
19#[derive(Debug, Clone)]
20pub struct AuthConfig {
21    /// JWT expiration duration (max 10 minutes per GitHub)
22    pub jwt_expiration: Duration,
23
24    /// JWT refresh margin (refresh if expires in this window)
25    pub jwt_refresh_margin: Duration,
26
27    /// Installation token cache TTL (refresh before GitHub's 1-hour expiry)
28    pub token_cache_ttl: Duration,
29
30    /// Installation token refresh margin (refresh if expires in this window)
31    pub token_refresh_margin: Duration,
32
33    /// GitHub API endpoint (for GitHub Enterprise support)
34    pub github_api_url: String,
35
36    /// User agent for GitHub API requests
37    pub user_agent: String,
38}
39
40impl Default for AuthConfig {
41    fn default() -> Self {
42        Self {
43            jwt_expiration: Duration::minutes(10),
44            jwt_refresh_margin: Duration::minutes(2),
45            token_cache_ttl: Duration::minutes(55),
46            token_refresh_margin: Duration::minutes(5),
47            github_api_url: "https://api.github.com".to_string(),
48            user_agent: "github-bot-sdk".to_string(),
49        }
50    }
51}
52
53/// Main GitHub App authentication provider.
54///
55/// Handles both app-level (JWT) and installation-level (installation token) authentication
56/// with intelligent caching and automatic refresh.
57pub struct GitHubAppAuth<S, J, A, C>
58where
59    S: SecretProvider,
60    J: JwtSigner,
61    A: GitHubApiClient,
62    C: TokenCache,
63{
64    secret_provider: Arc<S>,
65    jwt_signer: Arc<J>,
66    api_client: Arc<A>,
67    token_cache: Arc<C>,
68    config: AuthConfig,
69}
70
71impl<S, J, A, C> GitHubAppAuth<S, J, A, C>
72where
73    S: SecretProvider,
74    J: JwtSigner,
75    A: GitHubApiClient,
76    C: TokenCache,
77{
78    /// Create a new GitHub App authentication provider.
79    pub fn new(
80        secret_provider: S,
81        jwt_signer: J,
82        api_client: A,
83        token_cache: C,
84        config: AuthConfig,
85    ) -> Self {
86        Self {
87            secret_provider: Arc::new(secret_provider),
88            jwt_signer: Arc::new(jwt_signer),
89            api_client: Arc::new(api_client),
90            token_cache: Arc::new(token_cache),
91            config,
92        }
93    }
94
95    /// Get configuration.
96    pub fn config(&self) -> &AuthConfig {
97        &self.config
98    }
99}
100
101#[async_trait]
102impl<S, J, A, C> AuthenticationProvider for GitHubAppAuth<S, J, A, C>
103where
104    S: SecretProvider + 'static,
105    J: JwtSigner + 'static,
106    A: GitHubApiClient + 'static,
107    C: TokenCache + 'static,
108{
109    async fn app_token(&self) -> Result<JsonWebToken, AuthError> {
110        // Get app ID from secret provider
111        let app_id = self
112            .secret_provider
113            .get_app_id()
114            .await
115            .map_err(AuthError::SecretError)?;
116
117        // Check cache first (graceful fallback on cache errors)
118        let cached_jwt = match self.token_cache.get_jwt(app_id).await {
119            Ok(Some(jwt)) => Some(jwt),
120            Ok(None) => None,
121            Err(_) => {
122                // Cache read error - log and continue with generation
123                // This ensures cache failures don't block authentication
124                None
125            }
126        };
127
128        if let Some(jwt) = cached_jwt {
129            // Return cached token if it's not expired and not expiring soon
130            if !jwt.expires_soon(self.config.jwt_refresh_margin) {
131                return Ok(jwt);
132            }
133        }
134
135        // Generate new JWT
136        let private_key = self
137            .secret_provider
138            .get_private_key()
139            .await
140            .map_err(AuthError::SecretError)?;
141
142        let now = chrono::Utc::now();
143        let expiration = now + self.config.jwt_expiration;
144
145        let claims = JwtClaims {
146            iss: app_id,
147            iat: now.timestamp(),
148            exp: expiration.timestamp(),
149        };
150
151        let jwt = self
152            .jwt_signer
153            .sign_jwt(claims, &private_key)
154            .await
155            .map_err(AuthError::SigningError)?;
156
157        // Store in cache (ignore cache errors, we have the token)
158        let _ = self.token_cache.store_jwt(jwt.clone()).await;
159
160        Ok(jwt)
161    }
162
163    async fn installation_token(
164        &self,
165        installation_id: InstallationId,
166    ) -> Result<InstallationToken, AuthError> {
167        // Check cache first (graceful fallback on cache errors)
168        let cached_token = match self
169            .token_cache
170            .get_installation_token(installation_id)
171            .await
172        {
173            Ok(Some(token)) => Some(token),
174            Ok(None) => None,
175            Err(_) => {
176                // Cache read error - log and continue with token exchange
177                // This ensures cache failures don't block authentication
178                None
179            }
180        };
181
182        if let Some(token) = cached_token {
183            // Return cached token if it's not expired and not expiring soon
184            if !token.expires_soon(self.config.token_refresh_margin) {
185                return Ok(token);
186            }
187        }
188
189        // Get fresh token from GitHub API
190        let jwt = self.app_token().await?;
191
192        let token = self
193            .api_client
194            .create_installation_access_token(installation_id, &jwt)
195            .await
196            .map_err(AuthError::ApiError)?;
197
198        // Store in cache (ignore cache errors, we have the token)
199        let _ = self
200            .token_cache
201            .store_installation_token(token.clone())
202            .await;
203
204        Ok(token)
205    }
206
207    async fn refresh_installation_token(
208        &self,
209        installation_id: InstallationId,
210    ) -> Result<InstallationToken, AuthError> {
211        // Invalidate cache first
212        let _ = self
213            .token_cache
214            .invalidate_installation_token(installation_id)
215            .await;
216
217        // Get fresh token (bypasses cache since we just invalidated)
218        let jwt = self.app_token().await?;
219
220        let token = self
221            .api_client
222            .create_installation_access_token(installation_id, &jwt)
223            .await
224            .map_err(AuthError::ApiError)?;
225
226        // Store in cache
227        let _ = self
228            .token_cache
229            .store_installation_token(token.clone())
230            .await;
231
232        Ok(token)
233    }
234
235    async fn list_installations(&self) -> Result<Vec<Installation>, AuthError> {
236        let jwt = self.app_token().await?;
237
238        self.api_client
239            .list_app_installations(&jwt)
240            .await
241            .map_err(AuthError::ApiError)
242    }
243
244    async fn get_installation_repositories(
245        &self,
246        installation_id: InstallationId,
247    ) -> Result<Vec<Repository>, AuthError> {
248        let token = self.installation_token(installation_id).await?;
249
250        self.api_client
251            .list_installation_repositories(installation_id, &token)
252            .await
253            .map_err(AuthError::ApiError)
254    }
255}
256
257#[cfg(test)]
258#[path = "tokens_tests.rs"]
259mod tests;