Skip to main content

josh_github_auth/
app_flow.rs

1use 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
16/// JWT lifetime: 10 minutes (GitHub maximum).
17const JWT_LIFETIME_SECS: u64 = 600;
18
19/// Buffer before expiry to trigger a refresh.
20const 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    /// Create a new GitHub App auth handle and fetch the initial installation token.
50    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    /// Return a valid installation token, refreshing if the cached one is stale.
68    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
143/// Parse GitHub's ISO 8601 `expires_at` into a tokio `Instant`.
144fn parse_expires_at(expires_at: &str) -> Result<Instant> {
145    // GitHub returns e.g. "2024-01-01T01:00:00Z"
146    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}