josh_github_auth/
app_flow.rs1use anyhow::{Result, anyhow};
2use jsonwebtoken::{Algorithm, EncodingKey, Header};
3use reqwest::header;
4use secret_vault_value::SecretValue;
5use serde::{Deserialize, Serialize};
6use tokio::time::Instant;
7
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10const GITHUB_API_BASE: &str = "https://api.github.com";
11const USER_AGENT: &str = "josh-project";
12const API_VERSION_HEADER: &str = "X-GitHub-Api-Version";
13const API_VERSION: &str = "2022-11-28";
14const ACCEPT_HEADER: &str = "application/vnd.github+json";
15
16const JWT_LIFETIME_SECS: u64 = 600;
18
19const EXPIRY_BUFFER: Duration = Duration::from_secs(60);
21
22#[derive(Debug, Serialize)]
23struct Claims {
24 iat: u64,
25 exp: u64,
26 iss: String,
27}
28
29struct TokenInner {
30 value: String,
31 expires_at: Instant,
32}
33
34pub struct GithubAppAuth {
35 app_id: String,
36 installation_id: String,
37 private_key: SecretValue,
38 client: reqwest::Client,
39 cached_token: Option<TokenInner>,
40}
41
42#[derive(Debug, Deserialize)]
43struct InstallationTokenResponse {
44 token: String,
45 expires_at: String,
46}
47
48impl GithubAppAuth {
49 pub async fn authenticate(
51 app_id: String,
52 installation_id: String,
53 key: SecretValue,
54 ) -> Result<Self> {
55 let mut auth = Self {
56 app_id,
57 installation_id,
58 private_key: key,
59 client: reqwest::Client::new(),
60 cached_token: None,
61 };
62
63 auth.refresh().await?;
64 Ok(auth)
65 }
66
67 pub async fn get_or_refresh(&mut self) -> Result<String> {
69 if let Some(ref inner) = self.cached_token {
70 if Instant::now() + EXPIRY_BUFFER < inner.expires_at {
71 return Ok(inner.value.clone());
72 }
73 }
74
75 self.refresh().await
76 }
77
78 async fn refresh(&mut self) -> Result<String> {
79 let jwt = self.generate_jwt()?;
80 let resp = self.request_token(&jwt).await?;
81
82 let expires_at = parse_expires_at(&resp.expires_at)?;
83
84 self.cached_token = Some(TokenInner {
85 value: resp.token.clone(),
86 expires_at,
87 });
88
89 Ok(resp.token)
90 }
91
92 fn generate_jwt(&self) -> Result<String> {
93 let now = SystemTime::now()
94 .duration_since(UNIX_EPOCH)
95 .map_err(|e| anyhow!("system time error: {}", e))?
96 .as_secs();
97
98 let claims = Claims {
99 iat: now.saturating_sub(60),
100 exp: now + JWT_LIFETIME_SECS,
101 iss: self.app_id.clone(),
102 };
103
104 let key = EncodingKey::from_rsa_pem(self.private_key.as_sensitive_bytes())
105 .map_err(|e| anyhow!("invalid RSA private key: {}", e))?;
106
107 jsonwebtoken::encode(&Header::new(Algorithm::RS256), &claims, &key)
108 .map_err(|e| anyhow!("JWT encoding failed: {}", e))
109 }
110
111 async fn request_token(&self, jwt: &str) -> Result<InstallationTokenResponse> {
112 let url = format!(
113 "{}/app/installations/{}/access_tokens",
114 GITHUB_API_BASE, self.installation_id
115 );
116
117 let resp = self
118 .client
119 .post(&url)
120 .header(header::AUTHORIZATION, format!("Bearer {}", jwt))
121 .header(header::ACCEPT, ACCEPT_HEADER)
122 .header(header::USER_AGENT, USER_AGENT)
123 .header(API_VERSION_HEADER, API_VERSION)
124 .send()
125 .await?;
126
127 let status = resp.status();
128 let body = resp.text().await?;
129
130 if !status.is_success() {
131 return Err(anyhow!(
132 "failed to get installation token ({}): {}",
133 status,
134 body
135 ));
136 }
137
138 let parsed: InstallationTokenResponse = serde_json::from_str(&body)?;
139 Ok(parsed)
140 }
141}
142
143fn parse_expires_at(expires_at: &str) -> Result<Instant> {
145 let ts: chrono::DateTime<chrono::Utc> = expires_at
147 .parse()
148 .map_err(|e| anyhow!("failed to parse expires_at '{}': {}", expires_at, e))?;
149
150 let expires_epoch = ts.timestamp() as u64;
151 let now_epoch = SystemTime::now()
152 .duration_since(UNIX_EPOCH)
153 .map_err(|e| anyhow!("system time error: {}", e))?
154 .as_secs();
155
156 if expires_epoch <= now_epoch {
157 return Err(anyhow!(
158 "installation token already expired at {}",
159 expires_at
160 ));
161 }
162
163 let remaining = Duration::from_secs(expires_epoch - now_epoch);
164 Ok(Instant::now() + remaining)
165}