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;