Skip to main content

github_bot_sdk/auth/
mod.rs

1//! GitHub App authentication types and interfaces.
2//!
3//! This module provides the authentication foundation for GitHub Apps, handling the complexities
4//! of GitHub's two-tier authentication model with type safety and production-ready patterns.
5//!
6//! # Overview
7//!
8//! GitHub Apps use a two-tier authentication system:
9//!
10//! 1. **App-level JWT tokens** - Short-lived (max 10 minutes) tokens for app-level operations
11//! 2. **Installation tokens** - Scoped tokens for operations within specific installations
12//!
13//! This module provides:
14//!
15//! - **ID Types** - Branded types for [`GitHubAppId`], [`InstallationId`], [`RepositoryId`], [`UserId`]
16//! - **Token Types** - [`JsonWebToken`] and [`InstallationToken`] with automatic expiration tracking
17//! - **Permission Models** - [`InstallationPermissions`] and [`PermissionLevel`] for access control
18//! - **Trait Interfaces** - [`AuthenticationProvider`], [`SecretProvider`], [`TokenCache`], [`JwtSigner`]
19//! - **Metadata Types** - [`Installation`], [`Repository`], [`User`] for GitHub entities
20//!
21//! # Authentication Flow
22//!
23//! ```text
24//! ┌─────────────────┐
25//! │  GitHub App ID  │
26//! │  + Private Key  │
27//! └────────┬────────┘
28//!          │
29//!          ▼
30//! ┌─────────────────┐
31//! │   JWT Token     │  ◄── Sign with RS256 (max 10 min expiry)
32//! │ (App-level)     │
33//! └────────┬────────┘
34//!          │
35//!          ▼
36//! ┌─────────────────┐
37//! │  Installation   │  ◄── Exchange JWT for installation token
38//! │     Token       │      (scoped to installation permissions)
39//! └─────────────────┘
40//! ```
41//!
42//! # Usage Examples
43//!
44//! ## Working with ID Types
45//!
46//! ID types use the newtype pattern to prevent mixing up different identifier types:
47//!
48//! ```
49//! use github_bot_sdk::auth::{GitHubAppId, InstallationId, RepositoryId};
50//!
51//! // Create IDs - type-safe, cannot be confused
52//! let app_id = GitHubAppId::new(123456);
53//! let installation_id = InstallationId::new(789012);
54//! let repo_id = RepositoryId::new(345678);
55//!
56//! // Parse from strings
57//! let app_id: GitHubAppId = "123456".parse().unwrap();
58//! assert_eq!(app_id.as_u64(), 123456);
59//!
60//! // Convert to strings for display
61//! println!("App ID: {}", app_id);  // Prints: App ID: 123456
62//! ```
63//!
64//! ## Token Expiration Checking
65//!
66//! Tokens automatically track expiration and provide methods to check validity:
67//!
68//! ```
69//! use github_bot_sdk::auth::{JsonWebToken, GitHubAppId};
70//! use chrono::{Utc, Duration};
71//!
72//! let app_id = GitHubAppId::new(123);
73//! let expires_at = Utc::now() + Duration::minutes(10);
74//! let jwt = JsonWebToken::new("eyJhbGc...".to_string(), app_id, expires_at);
75//!
76//! // Check if token is expired
77//! if jwt.is_expired() {
78//!     println!("Token has expired - need to generate new one");
79//! }
80//!
81//! // Check if token expires soon (within specified duration)
82//! if jwt.expires_soon(Duration::minutes(5)) {
83//!     println!("Token expires in less than 5 minutes - should refresh proactively");
84//! }
85//! ```
86//!
87//! ## Implementing Authentication Provider
88//!
89//! The [`AuthenticationProvider`] trait is the main interface for authentication:
90//!
91//! ```no_run
92//! use github_bot_sdk::auth::{
93//!     AuthenticationProvider, GitHubAppId, InstallationId,
94//!     JsonWebToken, InstallationToken, Installation, Repository
95//! };
96//! use github_bot_sdk::error::AuthError;
97//! use async_trait::async_trait;
98//!
99//! struct MyAuthProvider {
100//!     // Your implementation fields
101//! }
102//!
103//! #[async_trait]
104//! impl AuthenticationProvider for MyAuthProvider {
105//!     async fn app_token(&self) -> Result<JsonWebToken, AuthError> {
106//!         // Generate JWT for app-level operations
107//!         // - Read private key from secure storage
108//!         // - Sign JWT claims with RS256
109//!         // - Set 10-minute expiration
110//!         # todo!()
111//!     }
112//!
113//!     async fn installation_token(
114//!         &self,
115//!         installation_id: InstallationId,
116//!     ) -> Result<InstallationToken, AuthError> {
117//!         // Get installation token
118//!         // - Generate app JWT
119//!         // - Exchange for installation token via GitHub API
120//!         // - Cache token until near expiration
121//!         # todo!()
122//!     }
123//!
124//!     async fn refresh_installation_token(
125//!         &self,
126//!         installation_id: InstallationId,
127//!     ) -> Result<InstallationToken, AuthError> {
128//!         // Force refresh - bypass cache
129//!         # todo!()
130//!     }
131//!
132//!     async fn list_installations(&self) -> Result<Vec<Installation>, AuthError> {
133//!         // List all installations for this app
134//!         # todo!()
135//!     }
136//!
137//!     async fn get_installation_repositories(
138//!         &self,
139//!         installation_id: InstallationId,
140//!     ) -> Result<Vec<Repository>, AuthError> {
141//!         // Get repositories accessible to installation
142//!         # todo!()
143//!     }
144//! }
145//! ```
146//!
147//! ## Working with Permissions
148//!
149//! Installation tokens include permission information:
150//!
151//! ```
152//! use github_bot_sdk::auth::{InstallationPermissions, PermissionLevel};
153//!
154//! // Create permissions with struct fields (not HashMap)
155//! let mut permissions = InstallationPermissions {
156//!     issues: PermissionLevel::Write,
157//!     pull_requests: PermissionLevel::Write,
158//!     contents: PermissionLevel::Write,
159//!     metadata: PermissionLevel::Read,
160//!     checks: PermissionLevel::None,
161//!     actions: PermissionLevel::None,
162//! };
163//!
164//! // Check permissions via fields
165//! match permissions.contents {
166//!     PermissionLevel::Read => println!("Read-only access to contents"),
167//!     PermissionLevel::Write => println!("Read-write access to contents"),
168//!     PermissionLevel::Admin => println!("Admin access to contents"),
169//!     PermissionLevel::None => println!("No access to contents"),
170//! }
171//! ```
172//!
173//! ## Secret Management
174//!
175//! Implement [`SecretProvider`] to integrate with your secret management system:
176//!
177//! ```no_run
178//! use github_bot_sdk::auth::{SecretProvider, PrivateKey, GitHubAppId};
179//! use github_bot_sdk::error::SecretError;
180//! use chrono::Duration;
181//! use async_trait::async_trait;
182//!
183//! struct MySecretProvider {
184//!     // Your secret storage integration
185//! }
186//!
187//! #[async_trait]
188//! impl SecretProvider for MySecretProvider {
189//!     async fn get_private_key(&self) -> Result<PrivateKey, SecretError> {
190//!         // Retrieve private key from Azure Key Vault, AWS Secrets Manager,
191//!         // environment variables, or your preferred secret store
192//!         # todo!()
193//!     }
194//!
195//!     async fn get_app_id(&self) -> Result<GitHubAppId, SecretError> {
196//!         // Retrieve GitHub App ID
197//!         # todo!()
198//!     }
199//!
200//!     async fn get_webhook_secret(&self) -> Result<String, SecretError> {
201//!         // Retrieve webhook secret for signature validation
202//!         # todo!()
203//!     }
204//!
205//!     fn cache_duration(&self) -> Duration {
206//!         // How long to cache secrets (e.g., 1 hour)
207//!         Duration::hours(1)
208//!     }
209//! }
210//! ```
211//!
212//! # Security Considerations
213//!
214//! This module implements several security best practices:
215//!
216//! - **Memory Safety** - Token types implement `Drop` to zero memory
217//! - **No Logging** - Debug implementations redact sensitive values
218//! - **Type Safety** - Branded types prevent ID confusion at compile time
219//! - **Expiration Tracking** - Automatic token expiration detection
220//! - **Constant-Time Comparison** - Used where timing attacks are a concern
221//!
222//! # Error Handling
223//!
224//! Authentication operations can fail for various reasons:
225//!
226//! - [`AuthError::InvalidCredentials`] - Invalid private key or app ID
227//! - [`AuthError::TokenExpired`] - Token has expired and needs refresh
228//! - [`AuthError::InsufficientPermissions`] - Insufficient permissions for operation
229//! - [`AuthError::GitHubApiError`] - GitHub API errors including rate limiting
230//! - [`AuthError::NetworkError`] - Network connectivity issues
231//!
232//! All errors include context for debugging and support retry classification.
233//!
234//! # Architecture
235//!
236//! This module follows the ports and adapters (hexagonal) architecture:
237//!
238//! - **Domain Types** - ID types, token types, permission models (in this module)
239//! - **Port Interfaces** - Traits for external dependencies ([`SecretProvider`], [`TokenCache`], etc.)
240//! - **Adapters** - Your implementations for specific infrastructure (Azure, AWS, etc.)
241//!
242//! This design enables:
243//! - Testability through dependency injection
244//! - Flexibility to swap infrastructure components
245//! - Clear separation between domain logic and infrastructure
246//!
247//! # See Also
248//!
249//! - [`crate::client`] - GitHub API client using these authentication types
250//! - [`crate::webhook`] - Webhook signature validation using secrets from this module
251//! - [GitHub App Authentication Documentation](https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps)
252
253use chrono::{DateTime, Duration, Utc};
254use serde::{Deserialize, Serialize};
255use std::str::FromStr;
256
257use crate::error::{ApiError, AuthError, CacheError, SecretError, SigningError, ValidationError};
258
259// ============================================================================
260// Core ID Types
261// ============================================================================
262
263/// GitHub App identifier assigned during app registration.
264///
265/// This is a globally unique identifier for your GitHub App, found in the
266/// app settings page. It's used for JWT generation and app identification.
267///
268/// # Examples
269///
270/// ```
271/// use github_bot_sdk::auth::GitHubAppId;
272///
273/// let app_id = GitHubAppId::new(123456);
274/// assert_eq!(app_id.as_u64(), 123456);
275/// assert_eq!(app_id.to_string(), "123456");
276/// ```
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
278pub struct GitHubAppId(u64);
279
280impl GitHubAppId {
281    /// Create a new GitHub App ID.
282    ///
283    /// # Examples
284    ///
285    /// ```
286    /// use github_bot_sdk::auth::GitHubAppId;
287    ///
288    /// let app_id = GitHubAppId::new(123456);
289    /// ```
290    pub fn new(id: u64) -> Self {
291        Self(id)
292    }
293
294    /// Get the raw u64 value.
295    pub fn as_u64(&self) -> u64 {
296        self.0
297    }
298}
299
300impl std::fmt::Display for GitHubAppId {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        write!(f, "{}", self.0)
303    }
304}
305
306impl FromStr for GitHubAppId {
307    type Err = ValidationError;
308
309    fn from_str(s: &str) -> Result<Self, Self::Err> {
310        let id = s
311            .parse::<u64>()
312            .map_err(|_| ValidationError::InvalidFormat {
313                field: "github_app_id".to_string(),
314                message: "must be a positive integer".to_string(),
315            })?;
316        Ok(Self::new(id))
317    }
318}
319
320/// GitHub App installation identifier for specific accounts.
321///
322/// When a GitHub App is installed on an organization or user account, GitHub
323/// assigns an installation ID. This ID is used to obtain installation tokens
324/// and perform operations on behalf of that installation.
325///
326/// # Examples
327///
328/// ```
329/// use github_bot_sdk::auth::InstallationId;
330///
331/// let installation = InstallationId::new(98765);
332/// assert_eq!(installation.as_u64(), 98765);
333/// ```
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
335pub struct InstallationId(u64);
336
337impl InstallationId {
338    /// Create a new installation ID.
339    pub fn new(id: u64) -> Self {
340        Self(id)
341    }
342
343    /// Get the raw u64 value.
344    pub fn as_u64(&self) -> u64 {
345        self.0
346    }
347}
348
349impl std::fmt::Display for InstallationId {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        write!(f, "{}", self.0)
352    }
353}
354
355impl FromStr for InstallationId {
356    type Err = ValidationError;
357
358    fn from_str(s: &str) -> Result<Self, Self::Err> {
359        let id = s
360            .parse::<u64>()
361            .map_err(|_| ValidationError::InvalidFormat {
362                field: "installation_id".to_string(),
363                message: "must be a positive integer".to_string(),
364            })?;
365        Ok(Self::new(id))
366    }
367}
368
369/// Repository identifier used by GitHub API.
370///
371/// This numeric ID uniquely identifies a repository and remains stable even
372/// if the repository is renamed or transferred.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
374pub struct RepositoryId(u64);
375
376impl RepositoryId {
377    /// Create a new repository ID.
378    pub fn new(id: u64) -> Self {
379        Self(id)
380    }
381
382    /// Get the raw u64 value.
383    pub fn as_u64(&self) -> u64 {
384        self.0
385    }
386}
387
388impl std::fmt::Display for RepositoryId {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        write!(f, "{}", self.0)
391    }
392}
393
394impl FromStr for RepositoryId {
395    type Err = ValidationError;
396
397    fn from_str(s: &str) -> Result<Self, Self::Err> {
398        let id = s
399            .parse::<u64>()
400            .map_err(|_| ValidationError::InvalidFormat {
401                field: "repository_id".to_string(),
402                message: "must be a positive integer".to_string(),
403            })?;
404        Ok(Self::new(id))
405    }
406}
407
408/// User identifier used by GitHub API.
409///
410/// This numeric ID uniquely identifies a user or organization and remains
411/// stable even if the username changes.
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
413pub struct UserId(u64);
414
415impl UserId {
416    /// Create a new user ID.
417    pub fn new(id: u64) -> Self {
418        Self(id)
419    }
420
421    /// Get the raw u64 value.
422    pub fn as_u64(&self) -> u64 {
423        self.0
424    }
425}
426
427impl std::fmt::Display for UserId {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        write!(f, "{}", self.0)
430    }
431}
432
433impl FromStr for UserId {
434    type Err = ValidationError;
435
436    fn from_str(s: &str) -> Result<Self, Self::Err> {
437        let id = s
438            .parse::<u64>()
439            .map_err(|_| ValidationError::InvalidFormat {
440                field: "user_id".to_string(),
441                message: "must be a positive integer".to_string(),
442            })?;
443        Ok(Self::new(id))
444    }
445}
446
447// ============================================================================
448// Token Types
449// ============================================================================
450
451/// JWT token for GitHub App authentication.
452///
453/// JSON Web Tokens (JWTs) are used to authenticate as a GitHub App. They have
454/// a maximum lifetime of 10 minutes and are used to obtain installation tokens.
455///
456/// The token string is never exposed in Debug output for security.
457///
458/// # Examples
459///
460/// ```
461/// use github_bot_sdk::auth::{JsonWebToken, GitHubAppId};
462/// use chrono::{Utc, Duration};
463///
464/// let app_id = GitHubAppId::new(123);
465/// let expires_at = Utc::now() + Duration::minutes(10);
466/// let jwt = JsonWebToken::new("encoded.jwt.token".to_string(), app_id, expires_at);
467///
468/// assert!(!jwt.is_expired());
469/// assert_eq!(jwt.app_id(), app_id);
470/// ```
471#[derive(Clone)]
472pub struct JsonWebToken {
473    token: String,
474    issued_at: DateTime<Utc>,
475    expires_at: DateTime<Utc>,
476    app_id: GitHubAppId,
477}
478
479impl JsonWebToken {
480    /// Create a new JWT token.
481    ///
482    /// # Arguments
483    ///
484    /// * `token` - The encoded JWT string
485    /// * `app_id` - The GitHub App ID this token represents
486    /// * `expires_at` - When the token expires (max 10 minutes from creation)
487    pub fn new(token: String, app_id: GitHubAppId, expires_at: DateTime<Utc>) -> Self {
488        let issued_at = Utc::now();
489        Self {
490            token,
491            issued_at,
492            expires_at,
493            app_id,
494        }
495    }
496
497    /// Get the token string for use in API requests.
498    ///
499    /// This should be included in the Authorization header as:
500    /// `Authorization: Bearer <token>`
501    pub fn token(&self) -> &str {
502        &self.token
503    }
504
505    /// Get the GitHub App ID this token represents.
506    pub fn app_id(&self) -> GitHubAppId {
507        self.app_id
508    }
509
510    /// Get when this token was issued.
511    pub fn issued_at(&self) -> DateTime<Utc> {
512        self.issued_at
513    }
514
515    /// Get when this token expires.
516    pub fn expires_at(&self) -> DateTime<Utc> {
517        self.expires_at
518    }
519
520    /// Check if the token is currently expired.
521    pub fn is_expired(&self) -> bool {
522        Utc::now() >= self.expires_at
523    }
524
525    /// Check if the token will expire soon.
526    ///
527    /// # Arguments
528    ///
529    /// * `margin` - How far in the future to check (e.g., 5 minutes)
530    ///
531    /// Returns true if the token will expire within the margin period.
532    pub fn expires_soon(&self, margin: Duration) -> bool {
533        Utc::now() + margin >= self.expires_at
534    }
535
536    /// Get the time remaining until expiry.
537    pub fn time_until_expiry(&self) -> Duration {
538        self.expires_at - Utc::now()
539    }
540}
541
542// Security: Don't expose token in debug output
543impl std::fmt::Debug for JsonWebToken {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        f.debug_struct("JsonWebToken")
546            .field("app_id", &self.app_id)
547            .field("issued_at", &self.issued_at)
548            .field("expires_at", &self.expires_at)
549            .field("token", &"<REDACTED>")
550            .finish()
551    }
552}
553
554/// Installation-scoped access token for GitHub API operations.
555///
556/// Installation tokens provide access to perform operations on behalf of a
557/// specific installation. They have a 1-hour lifetime and include permission
558/// and repository scope information.
559///
560/// The token string is never exposed in Debug output for security.
561///
562/// # Examples
563///
564/// ```
565/// use github_bot_sdk::auth::{InstallationToken, InstallationId, InstallationPermissions, Permission, RepositoryId};
566/// use chrono::{Utc, Duration};
567///
568/// let installation_id = InstallationId::new(456);
569/// let expires_at = Utc::now() + Duration::hours(1);
570/// let permissions = InstallationPermissions::default();
571/// let repositories = vec![RepositoryId::new(789)];
572///
573/// let token = InstallationToken::new(
574///     "ghs_token".to_string(),
575///     installation_id,
576///     expires_at,
577///     permissions,
578///     repositories,
579/// );
580///
581/// assert_eq!(token.installation_id(), installation_id);
582/// assert!(!token.is_expired());
583/// ```
584#[derive(Clone)]
585pub struct InstallationToken {
586    token: String,
587    installation_id: InstallationId,
588    issued_at: DateTime<Utc>,
589    expires_at: DateTime<Utc>,
590    permissions: InstallationPermissions,
591    repositories: Vec<RepositoryId>,
592}
593
594impl InstallationToken {
595    /// Create a new installation token.
596    ///
597    /// # Arguments
598    ///
599    /// * `token` - The token string from GitHub API
600    /// * `installation_id` - The installation this token is for
601    /// * `expires_at` - When the token expires (typically 1 hour)
602    /// * `permissions` - The permissions granted to this token
603    /// * `repositories` - The repositories this token can access
604    pub fn new(
605        token: String,
606        installation_id: InstallationId,
607        expires_at: DateTime<Utc>,
608        permissions: InstallationPermissions,
609        repositories: Vec<RepositoryId>,
610    ) -> Self {
611        let issued_at = Utc::now();
612        Self {
613            token,
614            installation_id,
615            issued_at,
616            expires_at,
617            permissions,
618            repositories,
619        }
620    }
621
622    /// Get the token string for use in API requests.
623    ///
624    /// This should be included in the Authorization header as:
625    /// `Authorization: Bearer <token>`
626    pub fn token(&self) -> &str {
627        &self.token
628    }
629
630    /// Get the installation ID this token is for.
631    pub fn installation_id(&self) -> InstallationId {
632        self.installation_id
633    }
634
635    /// Get when this token was issued.
636    pub fn issued_at(&self) -> DateTime<Utc> {
637        self.issued_at
638    }
639
640    /// Get when this token expires.
641    pub fn expires_at(&self) -> DateTime<Utc> {
642        self.expires_at
643    }
644
645    /// Get the permissions granted to this token.
646    pub fn permissions(&self) -> &InstallationPermissions {
647        &self.permissions
648    }
649
650    /// Get the repositories this token can access.
651    pub fn repositories(&self) -> &[RepositoryId] {
652        &self.repositories
653    }
654
655    /// Check if the token is currently expired.
656    pub fn is_expired(&self) -> bool {
657        Utc::now() >= self.expires_at
658    }
659
660    /// Check if the token will expire soon.
661    ///
662    /// # Arguments
663    ///
664    /// * `margin` - How far in the future to check (e.g., 5 minutes)
665    ///
666    /// Returns true if the token will expire within the margin period.
667    pub fn expires_soon(&self, margin: Duration) -> bool {
668        Utc::now() + margin >= self.expires_at
669    }
670
671    /// Check if the token has a specific permission.
672    ///
673    /// # Examples
674    ///
675    /// ```
676    /// # use github_bot_sdk::auth::{InstallationToken, InstallationId, InstallationPermissions, Permission, PermissionLevel, RepositoryId};
677    /// # use chrono::{Utc, Duration};
678    /// let mut permissions = InstallationPermissions::default();
679    /// permissions.issues = PermissionLevel::Write;
680    ///
681    /// let token = InstallationToken::new(
682    ///     "token".to_string(),
683    ///     InstallationId::new(1),
684    ///     Utc::now() + Duration::hours(1),
685    ///     permissions,
686    ///     vec![],
687    /// );
688    ///
689    /// assert!(token.has_permission(Permission::ReadIssues));
690    /// assert!(token.has_permission(Permission::WriteIssues));
691    /// assert!(!token.has_permission(Permission::WriteContents));
692    /// ```
693    pub fn has_permission(&self, permission: Permission) -> bool {
694        match permission {
695            Permission::ReadIssues => matches!(
696                self.permissions.issues,
697                PermissionLevel::Read | PermissionLevel::Write | PermissionLevel::Admin
698            ),
699            Permission::WriteIssues => matches!(
700                self.permissions.issues,
701                PermissionLevel::Write | PermissionLevel::Admin
702            ),
703            Permission::ReadPullRequests => matches!(
704                self.permissions.pull_requests,
705                PermissionLevel::Read | PermissionLevel::Write | PermissionLevel::Admin
706            ),
707            Permission::WritePullRequests => matches!(
708                self.permissions.pull_requests,
709                PermissionLevel::Write | PermissionLevel::Admin
710            ),
711            Permission::ReadContents => matches!(
712                self.permissions.contents,
713                PermissionLevel::Read | PermissionLevel::Write | PermissionLevel::Admin
714            ),
715            Permission::WriteContents => matches!(
716                self.permissions.contents,
717                PermissionLevel::Write | PermissionLevel::Admin
718            ),
719            Permission::ReadChecks => matches!(
720                self.permissions.checks,
721                PermissionLevel::Read | PermissionLevel::Write | PermissionLevel::Admin
722            ),
723            Permission::WriteChecks => matches!(
724                self.permissions.checks,
725                PermissionLevel::Write | PermissionLevel::Admin
726            ),
727        }
728    }
729
730    /// Check if the token can access a specific repository.
731    pub fn can_access_repository(&self, repo_id: RepositoryId) -> bool {
732        self.repositories.contains(&repo_id)
733    }
734}
735
736// Security: Redact token in debug output
737impl std::fmt::Debug for InstallationToken {
738    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
739        f.debug_struct("InstallationToken")
740            .field("installation_id", &self.installation_id)
741            .field("issued_at", &self.issued_at)
742            .field("expires_at", &self.expires_at)
743            .field("permissions", &self.permissions)
744            .field("repositories", &self.repositories)
745            .field("token", &"<REDACTED>")
746            .finish()
747    }
748}
749
750// ============================================================================
751// Permission Types
752// ============================================================================
753
754/// Permissions granted to a GitHub App installation.
755///
756/// Each permission can be set to None, Read, Write, or Admin level.
757/// See GitHub's documentation for details on what each permission allows.
758///
759/// # GitHub API Compatibility
760///
761/// The GitHub API returns permissions as optional fields - installations only
762/// include permissions they were granted during installation. This struct uses
763/// `#[serde(default)]` to automatically default missing fields to `PermissionLevel::None`,
764/// which provides better ergonomics than `Option<PermissionLevel>` while accurately
765/// representing the API semantics (missing permission = no permission).
766///
767/// # Examples
768///
769/// ```
770/// # use github_bot_sdk::auth::{InstallationPermissions, PermissionLevel};
771/// // Partial permissions from GitHub API (only metadata and contents)
772/// let json = r#"{"metadata": "read", "contents": "read"}"#;
773/// let perms: InstallationPermissions = serde_json::from_str(json).unwrap();
774///
775/// assert_eq!(perms.metadata, PermissionLevel::Read);
776/// assert_eq!(perms.contents, PermissionLevel::Read);
777/// assert_eq!(perms.issues, PermissionLevel::None);  // Defaulted
778/// assert_eq!(perms.pull_requests, PermissionLevel::None);  // Defaulted
779/// ```
780#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
781#[serde(default)]
782pub struct InstallationPermissions {
783    pub issues: PermissionLevel,
784    pub pull_requests: PermissionLevel,
785    pub contents: PermissionLevel,
786    pub metadata: PermissionLevel,
787    pub checks: PermissionLevel,
788    pub actions: PermissionLevel,
789}
790
791impl Default for InstallationPermissions {
792    fn default() -> Self {
793        Self {
794            issues: PermissionLevel::None,
795            pull_requests: PermissionLevel::None,
796            contents: PermissionLevel::None,
797            metadata: PermissionLevel::None,
798            checks: PermissionLevel::None,
799            actions: PermissionLevel::None,
800        }
801    }
802}
803
804/// Permission level for GitHub resources.
805///
806/// Represents the access level granted for a specific permission.
807/// Defaults to `None` when not specified.
808#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
809#[serde(rename_all = "lowercase")]
810pub enum PermissionLevel {
811    #[default]
812    None,
813    Read,
814    Write,
815    Admin,
816}
817
818/// Specific permissions that can be checked on tokens.
819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820pub enum Permission {
821    ReadIssues,
822    WriteIssues,
823    ReadPullRequests,
824    WritePullRequests,
825    ReadContents,
826    WriteContents,
827    ReadChecks,
828    WriteChecks,
829}
830
831// ============================================================================
832// Supporting Types
833// ============================================================================
834
835/// User type classification.
836#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
837#[serde(rename_all = "PascalCase")]
838pub enum UserType {
839    User,
840    Bot,
841    Organization,
842}
843
844/// Installation target type (where the app is installed).
845///
846/// Indicates whether the GitHub App is installed on an organization or user account.
847#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
848#[serde(rename_all = "PascalCase")]
849pub enum TargetType {
850    Organization,
851    User,
852}
853
854/// User information from GitHub API.
855#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
856pub struct User {
857    pub id: UserId,
858    pub login: String,
859    #[serde(rename = "type")]
860    pub user_type: UserType,
861    pub avatar_url: Option<String>,
862    pub html_url: String,
863}
864
865/// Account information for installations.
866///
867/// Similar to User but represents the account where a GitHub App is installed.
868/// This can be either an organization or a user account.
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
870pub struct Account {
871    pub id: UserId,
872    pub login: String,
873    #[serde(rename = "type")]
874    pub account_type: TargetType,
875    pub avatar_url: Option<String>,
876    pub html_url: String,
877}
878
879/// Repository information from GitHub API.
880#[derive(Debug, Clone, Serialize, Deserialize)]
881pub struct Repository {
882    pub id: RepositoryId,
883    pub name: String,
884    pub full_name: String,
885    pub owner: User,
886    pub private: bool,
887    pub html_url: String,
888    pub default_branch: String,
889}
890
891impl Repository {
892    /// Create a new repository.
893    pub fn new(
894        id: RepositoryId,
895        name: String,
896        full_name: String,
897        owner: User,
898        private: bool,
899    ) -> Self {
900        Self {
901            id,
902            name: name.clone(),
903            full_name: full_name.clone(),
904            owner,
905            private,
906            html_url: format!("https://github.com/{}", full_name),
907            default_branch: "main".to_string(), // Default assumption
908        }
909    }
910
911    /// Get repository owner name.
912    pub fn owner_name(&self) -> &str {
913        &self.owner.login
914    }
915
916    /// Get repository name without owner.
917    pub fn repo_name(&self) -> &str {
918        &self.name
919    }
920
921    /// Get full repository name (owner/name).
922    pub fn full_name(&self) -> &str {
923        &self.full_name
924    }
925}
926
927/// Installation information from GitHub API.
928///
929/// Represents a GitHub App installation on an organization or user account.
930/// Includes permissions, repository access, and subscription information.
931///
932/// # Examples
933///
934/// ```no_run
935/// # use github_bot_sdk::auth::{Installation, TargetType};
936/// # fn example(installation: Installation) {
937/// match installation.target_type {
938///     TargetType::Organization => {
939///         println!("Installed on organization: {}", installation.account.login);
940///     }
941///     TargetType::User => {
942///         println!("Installed on user: {}", installation.account.login);
943///     }
944/// }
945/// # }
946/// ```
947#[derive(Debug, Clone, Serialize, Deserialize)]
948pub struct Installation {
949    pub id: InstallationId,
950    pub account: Account,
951    pub access_tokens_url: String,
952    pub repositories_url: String,
953    pub html_url: String,
954    pub app_id: GitHubAppId,
955    pub target_type: TargetType,
956    pub repository_selection: RepositorySelection,
957    pub permissions: InstallationPermissions,
958    pub events: Vec<String>,
959    pub created_at: DateTime<Utc>,
960    pub updated_at: DateTime<Utc>,
961    #[serde(default)]
962    pub single_file_name: Option<String>,
963    #[serde(default)]
964    pub has_multiple_single_files: bool,
965    pub suspended_at: Option<DateTime<Utc>>,
966    pub suspended_by: Option<User>,
967}
968
969/// Repository selection for an installation.
970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971#[serde(rename_all = "lowercase")]
972pub enum RepositorySelection {
973    All,
974    Selected,
975}
976
977/// Private key for JWT signing.
978///
979/// Stores the cryptographic key material for signing JWTs. The key data
980/// is never exposed in Debug output for security.
981#[derive(Clone)]
982pub struct PrivateKey {
983    key_data: Vec<u8>,
984    algorithm: KeyAlgorithm,
985}
986
987impl PrivateKey {
988    /// Create a new private key.
989    ///
990    /// # Arguments
991    ///
992    /// * `key_data` - The raw key bytes (PEM or DER format)
993    /// * `algorithm` - The signing algorithm (typically RS256)
994    pub fn new(key_data: Vec<u8>, algorithm: KeyAlgorithm) -> Self {
995        Self {
996            key_data,
997            algorithm,
998        }
999    }
1000
1001    /// Get the key data.
1002    pub fn key_data(&self) -> &[u8] {
1003        &self.key_data
1004    }
1005
1006    /// Get the signing algorithm.
1007    pub fn algorithm(&self) -> &KeyAlgorithm {
1008        &self.algorithm
1009    }
1010}
1011
1012// Security: Don't expose key data in debug output
1013impl std::fmt::Debug for PrivateKey {
1014    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1015        f.debug_struct("PrivateKey")
1016            .field("algorithm", &self.algorithm)
1017            .field("key_data", &"<REDACTED>")
1018            .finish()
1019    }
1020}
1021
1022/// Key algorithm for JWT signing.
1023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1024pub enum KeyAlgorithm {
1025    RS256,
1026}
1027
1028/// JWT claims structure for GitHub App authentication.
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1030pub struct JwtClaims {
1031    /// Issuer (GitHub App ID)
1032    pub iss: GitHubAppId,
1033    /// Issued at (Unix timestamp)
1034    pub iat: i64,
1035    /// Expiration (Unix timestamp, max 10 minutes from iat)
1036    pub exp: i64,
1037}
1038
1039/// Rate limit information from GitHub API.
1040#[derive(Debug, Clone, Serialize, Deserialize)]
1041pub struct RateLimitInfo {
1042    pub limit: u32,
1043    pub remaining: u32,
1044    pub reset_at: DateTime<Utc>,
1045    pub used: u32,
1046}
1047
1048// ============================================================================
1049// Trait Definitions (Interfaces for later tasks)
1050// ============================================================================
1051
1052/// Main interface for GitHub App authentication operations.
1053///
1054/// Provides two authentication levels:
1055/// - **App-level**: JWT tokens for operations as the GitHub App (discovering installations, managing app)
1056/// - **Installation-level**: Installation tokens for operations within a specific installation context
1057///
1058/// See `docs/spec/architecture/app-level-authentication.md` for detailed usage patterns.
1059#[async_trait::async_trait]
1060pub trait AuthenticationProvider: Send + Sync {
1061    /// Get JWT token for app-level GitHub API operations.
1062    ///
1063    /// Use this for operations that require authentication as the GitHub App itself, such as:
1064    /// - Listing installations (`GET /app/installations`)
1065    /// - Getting app information (`GET /app`)
1066    /// - Managing installations (`GET /app/installations/{installation_id}`)
1067    ///
1068    /// This method handles caching and automatic refresh of JWTs.
1069    ///
1070    /// # Examples
1071    ///
1072    /// ```no_run
1073    /// # use github_bot_sdk::auth::AuthenticationProvider;
1074    /// # use github_bot_sdk::error::AuthError;
1075    /// # async fn example(auth: &dyn AuthenticationProvider) -> Result<(), AuthError> {
1076    /// // Get JWT for app-level operations
1077    /// let jwt = auth.app_token().await?;
1078    /// // Use jwt.token() in Authorization: Bearer header
1079    /// # Ok(())
1080    /// # }
1081    /// ```
1082    async fn app_token(&self) -> Result<JsonWebToken, AuthError>;
1083
1084    /// Get installation token for installation-level API operations.
1085    ///
1086    /// Use this for operations within a specific installation context, such as:
1087    /// - Repository operations (reading files, creating issues/PRs)
1088    /// - Organization operations (team management, webhooks)
1089    /// - Any operation scoped to the installation's permissions
1090    ///
1091    /// This method handles caching and automatic refresh of installation tokens.
1092    ///
1093    /// # Arguments
1094    ///
1095    /// * `installation_id` - The installation to get a token for
1096    ///
1097    /// # Examples
1098    ///
1099    /// ```no_run
1100    /// # use github_bot_sdk::auth::{AuthenticationProvider, InstallationId};
1101    /// # use github_bot_sdk::error::AuthError;
1102    /// # async fn example(auth: &dyn AuthenticationProvider) -> Result<(), AuthError> {
1103    /// let installation_id = InstallationId::new(123456);
1104    /// let token = auth.installation_token(installation_id).await?;
1105    /// // Use token.token() in Authorization: Bearer header
1106    /// # Ok(())
1107    /// # }
1108    /// ```
1109    async fn installation_token(
1110        &self,
1111        installation_id: InstallationId,
1112    ) -> Result<InstallationToken, AuthError>;
1113
1114    /// Refresh installation token (force new token generation).
1115    ///
1116    /// Bypasses cache and requests a new installation token from GitHub.
1117    /// Use sparingly as it counts against rate limits.
1118    async fn refresh_installation_token(
1119        &self,
1120        installation_id: InstallationId,
1121    ) -> Result<InstallationToken, AuthError>;
1122
1123    /// List all installations for this GitHub App.
1124    ///
1125    /// Requires app-level authentication. This is a convenience method that combines
1126    /// app_token() with the list installations API call.
1127    async fn list_installations(&self) -> Result<Vec<Installation>, AuthError>;
1128
1129    /// Get repositories accessible by installation.
1130    ///
1131    /// Requires installation-level authentication. This is a convenience method that combines
1132    /// installation_token() with the list repositories API call.
1133    async fn get_installation_repositories(
1134        &self,
1135        installation_id: InstallationId,
1136    ) -> Result<Vec<Repository>, AuthError>;
1137}
1138
1139/// Interface for retrieving GitHub App secrets from secure storage.
1140#[async_trait::async_trait]
1141pub trait SecretProvider: Send + Sync {
1142    /// Get private key for JWT signing.
1143    async fn get_private_key(&self) -> Result<PrivateKey, SecretError>;
1144
1145    /// Get GitHub App ID.
1146    async fn get_app_id(&self) -> Result<GitHubAppId, SecretError>;
1147
1148    /// Get webhook secret for signature validation.
1149    async fn get_webhook_secret(&self) -> Result<String, SecretError>;
1150
1151    /// Get cache duration for secrets.
1152    fn cache_duration(&self) -> Duration;
1153}
1154
1155/// Interface for caching authentication tokens securely.
1156#[async_trait::async_trait]
1157pub trait TokenCache: Send + Sync {
1158    /// Get cached JWT token.
1159    async fn get_jwt(&self, app_id: GitHubAppId) -> Result<Option<JsonWebToken>, CacheError>;
1160
1161    /// Store JWT token in cache.
1162    async fn store_jwt(&self, jwt: JsonWebToken) -> Result<(), CacheError>;
1163
1164    /// Get cached installation token.
1165    async fn get_installation_token(
1166        &self,
1167        installation_id: InstallationId,
1168    ) -> Result<Option<InstallationToken>, CacheError>;
1169
1170    /// Store installation token in cache.
1171    async fn store_installation_token(&self, token: InstallationToken) -> Result<(), CacheError>;
1172
1173    /// Invalidate installation token.
1174    async fn invalidate_installation_token(
1175        &self,
1176        installation_id: InstallationId,
1177    ) -> Result<(), CacheError>;
1178
1179    /// Cleanup expired tokens.
1180    fn cleanup_expired_tokens(&self);
1181}
1182
1183/// Interface for JWT token generation and signing.
1184#[async_trait::async_trait]
1185pub trait JwtSigner: Send + Sync {
1186    /// Sign JWT with private key.
1187    async fn sign_jwt(
1188        &self,
1189        claims: JwtClaims,
1190        private_key: &PrivateKey,
1191    ) -> Result<JsonWebToken, SigningError>;
1192
1193    /// Validate private key format.
1194    fn validate_private_key(&self, key: &PrivateKey) -> Result<(), ValidationError>;
1195}
1196
1197/// Interface for GitHub API client operations.
1198#[async_trait::async_trait]
1199pub trait GitHubApiClient: Send + Sync {
1200    /// Create installation access token via GitHub API.
1201    async fn create_installation_access_token(
1202        &self,
1203        installation_id: InstallationId,
1204        jwt: &JsonWebToken,
1205    ) -> Result<InstallationToken, ApiError>;
1206
1207    /// List installations for the GitHub App.
1208    async fn list_app_installations(
1209        &self,
1210        jwt: &JsonWebToken,
1211    ) -> Result<Vec<Installation>, ApiError>;
1212
1213    /// Get repositories for installation.
1214    async fn list_installation_repositories(
1215        &self,
1216        installation_id: InstallationId,
1217        token: &InstallationToken,
1218    ) -> Result<Vec<Repository>, ApiError>;
1219
1220    /// Get repository information.
1221    async fn get_repository(
1222        &self,
1223        repo_id: RepositoryId,
1224        token: &InstallationToken,
1225    ) -> Result<Repository, ApiError>;
1226
1227    /// Check API rate limits.
1228    async fn get_rate_limit(&self, token: &InstallationToken) -> Result<RateLimitInfo, ApiError>;
1229}
1230
1231// ============================================================================
1232// Submodules
1233// ============================================================================
1234
1235pub mod cache;
1236pub mod jwt;
1237pub mod tokens;
1238
1239#[cfg(test)]
1240#[path = "mod_tests.rs"]
1241mod tests;