use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use crate::error::{ApiError, AuthError, CacheError, SecretError, SigningError, ValidationError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GitHubAppId(u64);
impl GitHubAppId {
pub fn new(id: u64) -> Self {
Self(id)
}
pub fn as_u64(&self) -> u64 {
self.0
}
}
impl std::fmt::Display for GitHubAppId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for GitHubAppId {
type Err = ValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let id = s
.parse::<u64>()
.map_err(|_| ValidationError::InvalidFormat {
field: "github_app_id".to_string(),
message: "must be a positive integer".to_string(),
})?;
Ok(Self::new(id))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct InstallationId(u64);
impl InstallationId {
pub fn new(id: u64) -> Self {
Self(id)
}
pub fn as_u64(&self) -> u64 {
self.0
}
}
impl std::fmt::Display for InstallationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for InstallationId {
type Err = ValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let id = s
.parse::<u64>()
.map_err(|_| ValidationError::InvalidFormat {
field: "installation_id".to_string(),
message: "must be a positive integer".to_string(),
})?;
Ok(Self::new(id))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RepositoryId(u64);
impl RepositoryId {
pub fn new(id: u64) -> Self {
Self(id)
}
pub fn as_u64(&self) -> u64 {
self.0
}
}
impl std::fmt::Display for RepositoryId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for RepositoryId {
type Err = ValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let id = s
.parse::<u64>()
.map_err(|_| ValidationError::InvalidFormat {
field: "repository_id".to_string(),
message: "must be a positive integer".to_string(),
})?;
Ok(Self::new(id))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UserId(u64);
impl UserId {
pub fn new(id: u64) -> Self {
Self(id)
}
pub fn as_u64(&self) -> u64 {
self.0
}
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for UserId {
type Err = ValidationError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let id = s
.parse::<u64>()
.map_err(|_| ValidationError::InvalidFormat {
field: "user_id".to_string(),
message: "must be a positive integer".to_string(),
})?;
Ok(Self::new(id))
}
}
#[derive(Clone)]
pub struct JsonWebToken {
token: String,
issued_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
app_id: GitHubAppId,
}
impl JsonWebToken {
pub fn new(token: String, app_id: GitHubAppId, expires_at: DateTime<Utc>) -> Self {
let issued_at = Utc::now();
Self {
token,
issued_at,
expires_at,
app_id,
}
}
pub fn token(&self) -> &str {
&self.token
}
pub fn app_id(&self) -> GitHubAppId {
self.app_id
}
pub fn issued_at(&self) -> DateTime<Utc> {
self.issued_at
}
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.expires_at
}
pub fn expires_soon(&self, margin: Duration) -> bool {
Utc::now() + margin >= self.expires_at
}
pub fn time_until_expiry(&self) -> Duration {
self.expires_at - Utc::now()
}
}
impl std::fmt::Debug for JsonWebToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsonWebToken")
.field("app_id", &self.app_id)
.field("issued_at", &self.issued_at)
.field("expires_at", &self.expires_at)
.field("token", &"<REDACTED>")
.finish()
}
}
#[derive(Clone)]
pub struct InstallationToken {
token: String,
installation_id: InstallationId,
issued_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
permissions: InstallationPermissions,
repositories: Vec<RepositoryId>,
}
impl InstallationToken {
pub fn new(
token: String,
installation_id: InstallationId,
expires_at: DateTime<Utc>,
permissions: InstallationPermissions,
repositories: Vec<RepositoryId>,
) -> Self {
let issued_at = Utc::now();
Self {
token,
installation_id,
issued_at,
expires_at,
permissions,
repositories,
}
}
pub fn token(&self) -> &str {
&self.token
}
pub fn installation_id(&self) -> InstallationId {
self.installation_id
}
pub fn issued_at(&self) -> DateTime<Utc> {
self.issued_at
}
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
pub fn permissions(&self) -> &InstallationPermissions {
&self.permissions
}
pub fn repositories(&self) -> &[RepositoryId] {
&self.repositories
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.expires_at
}
pub fn expires_soon(&self, margin: Duration) -> bool {
Utc::now() + margin >= self.expires_at
}
pub fn has_permission(&self, permission: Permission) -> bool {
match permission {
Permission::ReadIssues => matches!(
self.permissions.issues,
PermissionLevel::Read | PermissionLevel::Write | PermissionLevel::Admin
),
Permission::WriteIssues => matches!(
self.permissions.issues,
PermissionLevel::Write | PermissionLevel::Admin
),
Permission::ReadPullRequests => matches!(
self.permissions.pull_requests,
PermissionLevel::Read | PermissionLevel::Write | PermissionLevel::Admin
),
Permission::WritePullRequests => matches!(
self.permissions.pull_requests,
PermissionLevel::Write | PermissionLevel::Admin
),
Permission::ReadContents => matches!(
self.permissions.contents,
PermissionLevel::Read | PermissionLevel::Write | PermissionLevel::Admin
),
Permission::WriteContents => matches!(
self.permissions.contents,
PermissionLevel::Write | PermissionLevel::Admin
),
Permission::ReadChecks => matches!(
self.permissions.checks,
PermissionLevel::Read | PermissionLevel::Write | PermissionLevel::Admin
),
Permission::WriteChecks => matches!(
self.permissions.checks,
PermissionLevel::Write | PermissionLevel::Admin
),
}
}
pub fn can_access_repository(&self, repo_id: RepositoryId) -> bool {
self.repositories.contains(&repo_id)
}
}
impl std::fmt::Debug for InstallationToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InstallationToken")
.field("installation_id", &self.installation_id)
.field("issued_at", &self.issued_at)
.field("expires_at", &self.expires_at)
.field("permissions", &self.permissions)
.field("repositories", &self.repositories)
.field("token", &"<REDACTED>")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct InstallationPermissions {
pub issues: PermissionLevel,
pub pull_requests: PermissionLevel,
pub contents: PermissionLevel,
pub metadata: PermissionLevel,
pub checks: PermissionLevel,
pub actions: PermissionLevel,
}
impl Default for InstallationPermissions {
fn default() -> Self {
Self {
issues: PermissionLevel::None,
pull_requests: PermissionLevel::None,
contents: PermissionLevel::None,
metadata: PermissionLevel::None,
checks: PermissionLevel::None,
actions: PermissionLevel::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PermissionLevel {
#[default]
None,
Read,
Write,
Admin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Permission {
ReadIssues,
WriteIssues,
ReadPullRequests,
WritePullRequests,
ReadContents,
WriteContents,
ReadChecks,
WriteChecks,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum UserType {
User,
Bot,
Organization,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum TargetType {
Organization,
User,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct User {
pub id: UserId,
pub login: String,
#[serde(rename = "type")]
pub user_type: UserType,
pub avatar_url: Option<String>,
pub html_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Account {
pub id: UserId,
pub login: String,
#[serde(rename = "type")]
pub account_type: TargetType,
pub avatar_url: Option<String>,
pub html_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Repository {
pub id: RepositoryId,
pub name: String,
pub full_name: String,
pub owner: User,
pub private: bool,
pub html_url: String,
pub default_branch: String,
}
impl Repository {
pub fn new(
id: RepositoryId,
name: String,
full_name: String,
owner: User,
private: bool,
) -> Self {
Self {
id,
name: name.clone(),
full_name: full_name.clone(),
owner,
private,
html_url: format!("https://github.com/{}", full_name),
default_branch: "main".to_string(), }
}
pub fn owner_name(&self) -> &str {
&self.owner.login
}
pub fn repo_name(&self) -> &str {
&self.name
}
pub fn full_name(&self) -> &str {
&self.full_name
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Installation {
pub id: InstallationId,
pub account: Account,
pub access_tokens_url: String,
pub repositories_url: String,
pub html_url: String,
pub app_id: GitHubAppId,
pub target_type: TargetType,
pub repository_selection: RepositorySelection,
pub permissions: InstallationPermissions,
pub events: Vec<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub single_file_name: Option<String>,
#[serde(default)]
pub has_multiple_single_files: bool,
pub suspended_at: Option<DateTime<Utc>>,
pub suspended_by: Option<User>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RepositorySelection {
All,
Selected,
}
#[derive(Clone)]
pub struct PrivateKey {
key_data: Vec<u8>,
algorithm: KeyAlgorithm,
}
impl PrivateKey {
pub fn new(key_data: Vec<u8>, algorithm: KeyAlgorithm) -> Self {
Self {
key_data,
algorithm,
}
}
pub fn key_data(&self) -> &[u8] {
&self.key_data
}
pub fn algorithm(&self) -> &KeyAlgorithm {
&self.algorithm
}
}
impl std::fmt::Debug for PrivateKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PrivateKey")
.field("algorithm", &self.algorithm)
.field("key_data", &"<REDACTED>")
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyAlgorithm {
RS256,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtClaims {
pub iss: GitHubAppId,
pub iat: i64,
pub exp: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitInfo {
pub limit: u32,
pub remaining: u32,
pub reset_at: DateTime<Utc>,
pub used: u32,
}
#[async_trait::async_trait]
pub trait AuthenticationProvider: Send + Sync {
async fn app_token(&self) -> Result<JsonWebToken, AuthError>;
async fn installation_token(
&self,
installation_id: InstallationId,
) -> Result<InstallationToken, AuthError>;
async fn refresh_installation_token(
&self,
installation_id: InstallationId,
) -> Result<InstallationToken, AuthError>;
async fn list_installations(&self) -> Result<Vec<Installation>, AuthError>;
async fn get_installation_repositories(
&self,
installation_id: InstallationId,
) -> Result<Vec<Repository>, AuthError>;
}
#[async_trait::async_trait]
pub trait SecretProvider: Send + Sync {
async fn get_private_key(&self) -> Result<PrivateKey, SecretError>;
async fn get_app_id(&self) -> Result<GitHubAppId, SecretError>;
async fn get_webhook_secret(&self) -> Result<String, SecretError>;
fn cache_duration(&self) -> Duration;
}
#[async_trait::async_trait]
pub trait TokenCache: Send + Sync {
async fn get_jwt(&self, app_id: GitHubAppId) -> Result<Option<JsonWebToken>, CacheError>;
async fn store_jwt(&self, jwt: JsonWebToken) -> Result<(), CacheError>;
async fn get_installation_token(
&self,
installation_id: InstallationId,
) -> Result<Option<InstallationToken>, CacheError>;
async fn store_installation_token(&self, token: InstallationToken) -> Result<(), CacheError>;
async fn invalidate_installation_token(
&self,
installation_id: InstallationId,
) -> Result<(), CacheError>;
fn cleanup_expired_tokens(&self);
}
#[async_trait::async_trait]
pub trait JwtSigner: Send + Sync {
async fn sign_jwt(
&self,
claims: JwtClaims,
private_key: &PrivateKey,
) -> Result<JsonWebToken, SigningError>;
fn validate_private_key(&self, key: &PrivateKey) -> Result<(), ValidationError>;
}
#[async_trait::async_trait]
pub trait GitHubApiClient: Send + Sync {
async fn create_installation_access_token(
&self,
installation_id: InstallationId,
jwt: &JsonWebToken,
) -> Result<InstallationToken, ApiError>;
async fn list_app_installations(
&self,
jwt: &JsonWebToken,
) -> Result<Vec<Installation>, ApiError>;
async fn list_installation_repositories(
&self,
installation_id: InstallationId,
token: &InstallationToken,
) -> Result<Vec<Repository>, ApiError>;
async fn get_repository(
&self,
repo_id: RepositoryId,
token: &InstallationToken,
) -> Result<Repository, ApiError>;
async fn get_rate_limit(&self, token: &InstallationToken) -> Result<RateLimitInfo, ApiError>;
}
pub mod cache;
pub mod jwt;
pub mod tokens;
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;