use async_trait::async_trait;
use chrono::Duration;
use std::sync::Arc;
use super::{
AuthenticationProvider, GitHubApiClient, Installation, InstallationId, InstallationToken,
JsonWebToken, JwtClaims, JwtSigner, Repository, SecretProvider, TokenCache,
};
use crate::error::AuthError;
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub jwt_expiration: Duration,
pub jwt_refresh_margin: Duration,
pub token_cache_ttl: Duration,
pub token_refresh_margin: Duration,
pub github_api_url: String,
pub user_agent: String,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
jwt_expiration: Duration::minutes(10),
jwt_refresh_margin: Duration::minutes(2),
token_cache_ttl: Duration::minutes(55),
token_refresh_margin: Duration::minutes(5),
github_api_url: "https://api.github.com".to_string(),
user_agent: "github-bot-sdk".to_string(),
}
}
}
pub struct GitHubAppAuth<S, J, A, C>
where
S: SecretProvider,
J: JwtSigner,
A: GitHubApiClient,
C: TokenCache,
{
secret_provider: Arc<S>,
jwt_signer: Arc<J>,
api_client: Arc<A>,
token_cache: Arc<C>,
config: AuthConfig,
}
impl<S, J, A, C> GitHubAppAuth<S, J, A, C>
where
S: SecretProvider,
J: JwtSigner,
A: GitHubApiClient,
C: TokenCache,
{
pub fn new(
secret_provider: S,
jwt_signer: J,
api_client: A,
token_cache: C,
config: AuthConfig,
) -> Self {
Self {
secret_provider: Arc::new(secret_provider),
jwt_signer: Arc::new(jwt_signer),
api_client: Arc::new(api_client),
token_cache: Arc::new(token_cache),
config,
}
}
pub fn config(&self) -> &AuthConfig {
&self.config
}
}
#[async_trait]
impl<S, J, A, C> AuthenticationProvider for GitHubAppAuth<S, J, A, C>
where
S: SecretProvider + 'static,
J: JwtSigner + 'static,
A: GitHubApiClient + 'static,
C: TokenCache + 'static,
{
async fn app_token(&self) -> Result<JsonWebToken, AuthError> {
let app_id = self
.secret_provider
.get_app_id()
.await
.map_err(AuthError::SecretError)?;
let cached_jwt = match self.token_cache.get_jwt(app_id).await {
Ok(Some(jwt)) => Some(jwt),
Ok(None) => None,
Err(_) => {
None
}
};
if let Some(jwt) = cached_jwt {
if !jwt.expires_soon(self.config.jwt_refresh_margin) {
return Ok(jwt);
}
}
let private_key = self
.secret_provider
.get_private_key()
.await
.map_err(AuthError::SecretError)?;
let now = chrono::Utc::now();
let expiration = now + self.config.jwt_expiration;
let claims = JwtClaims {
iss: app_id,
iat: now.timestamp(),
exp: expiration.timestamp(),
};
let jwt = self
.jwt_signer
.sign_jwt(claims, &private_key)
.await
.map_err(AuthError::SigningError)?;
let _ = self.token_cache.store_jwt(jwt.clone()).await;
Ok(jwt)
}
async fn installation_token(
&self,
installation_id: InstallationId,
) -> Result<InstallationToken, AuthError> {
let cached_token = match self
.token_cache
.get_installation_token(installation_id)
.await
{
Ok(Some(token)) => Some(token),
Ok(None) => None,
Err(_) => {
None
}
};
if let Some(token) = cached_token {
if !token.expires_soon(self.config.token_refresh_margin) {
return Ok(token);
}
}
let jwt = self.app_token().await?;
let token = self
.api_client
.create_installation_access_token(installation_id, &jwt)
.await
.map_err(AuthError::ApiError)?;
let _ = self
.token_cache
.store_installation_token(token.clone())
.await;
Ok(token)
}
async fn refresh_installation_token(
&self,
installation_id: InstallationId,
) -> Result<InstallationToken, AuthError> {
let _ = self
.token_cache
.invalidate_installation_token(installation_id)
.await;
let jwt = self.app_token().await?;
let token = self
.api_client
.create_installation_access_token(installation_id, &jwt)
.await
.map_err(AuthError::ApiError)?;
let _ = self
.token_cache
.store_installation_token(token.clone())
.await;
Ok(token)
}
async fn list_installations(&self) -> Result<Vec<Installation>, AuthError> {
let jwt = self.app_token().await?;
self.api_client
.list_app_installations(&jwt)
.await
.map_err(AuthError::ApiError)
}
async fn get_installation_repositories(
&self,
installation_id: InstallationId,
) -> Result<Vec<Repository>, AuthError> {
let token = self.installation_token(installation_id).await?;
self.api_client
.list_installation_repositories(installation_id, &token)
.await
.map_err(AuthError::ApiError)
}
}
#[cfg(test)]
#[path = "tokens_tests.rs"]
mod tests;