Skip to main content

fnox_core/lease_backends/
github_oauth.rs

1use crate::error::{FnoxError, Result};
2use crate::lease_backends::{Lease, LeaseBackend};
3use async_trait::async_trait;
4use indexmap::IndexMap;
5use keyring_core::Entry;
6use serde::{Deserialize, Serialize};
7use std::time::Duration;
8
9const URL: &str = "https://fnox.jdx.dev/leases/github-oauth";
10const PROVIDER: &str = "GitHub OAuth";
11const GRANT_DEVICE_CODE: &str = "urn:ietf:params:oauth:grant-type:device_code";
12const DEFAULT_TOKEN_SECS: i64 = 8 * 60 * 60;
13const CACHE_REUSE_BUFFER_SECS: i64 = 300;
14
15/// All env var names the GitHub OAuth backend may consume at runtime.
16pub const CONSUMED_ENV_VARS: &[&str] = &[];
17
18pub fn check_prerequisites() -> Option<String> {
19    None
20}
21
22pub fn required_env_vars() -> Vec<(&'static str, &'static str)> {
23    vec![]
24}
25
26#[derive(Debug, Clone)]
27pub struct GitHubOauthBackend {
28    client_id: String,
29    scope: String,
30    env_var: String,
31    keyring_service: String,
32    keyring_cache: bool,
33    open_browser: bool,
34    auth_base: String,
35    api_base: String,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39struct DeviceCodeResponse {
40    device_code: String,
41    user_code: String,
42    verification_uri: String,
43    expires_in: u64,
44    #[serde(default = "default_poll_interval")]
45    interval: u64,
46}
47
48#[derive(Debug, Clone, Deserialize)]
49struct TokenResponse {
50    access_token: Option<String>,
51    expires_in: Option<i64>,
52    refresh_token: Option<String>,
53    refresh_token_expires_in: Option<i64>,
54    error: Option<String>,
55    error_description: Option<String>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59struct UserResponse {
60    login: Option<String>,
61    message: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65struct CachedToken {
66    access_token: String,
67    expires_at: chrono::DateTime<chrono::Utc>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    refresh_token: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    refresh_expires_at: Option<chrono::DateTime<chrono::Utc>>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    login: Option<String>,
74}
75
76impl GitHubOauthBackend {
77    #[allow(clippy::too_many_arguments)]
78    pub fn new(
79        client_id: String,
80        scope: String,
81        env_var: String,
82        keyring_service: String,
83        keyring_cache: bool,
84        open_browser: bool,
85        auth_base: String,
86        api_base: String,
87    ) -> Self {
88        Self {
89            client_id,
90            scope,
91            env_var,
92            keyring_service,
93            keyring_cache,
94            open_browser,
95            auth_base,
96            api_base,
97        }
98    }
99
100    fn cache_key(&self) -> String {
101        let hash = blake3::hash(
102            format!(
103                "{}|{}|{}|{}",
104                self.client_id, self.scope, self.auth_base, self.api_base
105            )
106            .as_bytes(),
107        );
108        format!("{}-{}", self.client_id, &hash.to_hex()[..16])
109    }
110
111    fn keyring_entry(&self) -> Result<Entry> {
112        crate::keyring_store::init();
113        Entry::new(&self.keyring_service, &self.cache_key()).map_err(|e| {
114            FnoxError::ProviderApiError {
115                provider: PROVIDER.to_string(),
116                details: format!("Failed to create keyring entry: {e}"),
117                hint: "Check that the OS keyring is available, or set keyring_cache = false"
118                    .to_string(),
119                url: URL.to_string(),
120            }
121        })
122    }
123
124    fn read_cached_token(&self) -> Option<CachedToken> {
125        if !self.keyring_cache {
126            return None;
127        }
128        let entry = self.keyring_entry().ok()?;
129        let value = entry.get_password().ok()?;
130        serde_json::from_str(&value).ok()
131    }
132
133    fn write_cached_token(&self, token: &CachedToken) {
134        if !self.keyring_cache {
135            return;
136        }
137        let Ok(entry) = self.keyring_entry() else {
138            return;
139        };
140        let Ok(value) = serde_json::to_string(token) else {
141            return;
142        };
143        if let Err(e) = entry.set_password(&value) {
144            tracing::warn!("Failed to cache GitHub OAuth token in keyring: {e}");
145        }
146    }
147
148    async fn create_device_code(&self) -> Result<DeviceCodeResponse> {
149        let url = format!("{}/device/code", self.device_auth_base());
150        crate::http::http_client()
151            .post(url)
152            .header("Accept", "application/json")
153            .form(&[
154                ("client_id", self.client_id.as_str()),
155                ("scope", self.scope.as_str()),
156            ])
157            .send()
158            .await
159            .map_err(|e| api_error(format!("Failed to request device code: {e}")))?
160            .json::<DeviceCodeResponse>()
161            .await
162            .map_err(|e| invalid_response(format!("Invalid device code response: {e}")))
163    }
164
165    fn device_auth_base(&self) -> &str {
166        self.auth_base
167            .trim_end_matches('/')
168            .strip_suffix("/oauth")
169            .unwrap_or_else(|| self.auth_base.trim_end_matches('/'))
170    }
171
172    async fn poll_access_token(&self, device: &DeviceCodeResponse) -> Result<TokenResponse> {
173        let deadline = chrono::Utc::now() + chrono::Duration::seconds(device.expires_in as i64);
174        let mut interval = device.interval.max(1);
175        let url = format!("{}/access_token", self.auth_base.trim_end_matches('/'));
176
177        loop {
178            if chrono::Utc::now() >= deadline {
179                return Err(auth_failed("Device authorization expired".to_string()));
180            }
181
182            let response = crate::http::http_client()
183                .post(&url)
184                .header("Accept", "application/json")
185                .form(&[
186                    ("client_id", self.client_id.as_str()),
187                    ("device_code", device.device_code.as_str()),
188                    ("grant_type", GRANT_DEVICE_CODE),
189                ])
190                .send()
191                .await
192                .map_err(|e| api_error(format!("Failed to poll access token: {e}")))?
193                .json::<TokenResponse>()
194                .await
195                .map_err(|e| invalid_response(format!("Invalid token response: {e}")))?;
196
197            match response.error.as_deref() {
198                None => return Ok(response),
199                Some("authorization_pending") => {
200                    tokio::time::sleep(Duration::from_secs(interval)).await;
201                    continue;
202                }
203                Some("slow_down") => {
204                    interval += 5;
205                    tokio::time::sleep(Duration::from_secs(interval)).await;
206                    continue;
207                }
208                Some("expired_token") => {
209                    return Err(auth_failed("Device authorization expired".to_string()));
210                }
211                Some("access_denied") => {
212                    return Err(auth_failed("Device authorization was denied".to_string()));
213                }
214                Some(error) => {
215                    let details = response
216                        .error_description
217                        .unwrap_or_else(|| error.to_string());
218                    return Err(api_error(details));
219                }
220            }
221        }
222    }
223
224    async fn refresh_access_token(&self, cached: &CachedToken) -> Result<Option<CachedToken>> {
225        let Some(refresh_token) = cached.refresh_token.as_deref() else {
226            return Ok(None);
227        };
228        if cached
229            .refresh_expires_at
230            .is_some_and(|exp| exp <= chrono::Utc::now())
231        {
232            return Ok(None);
233        }
234
235        let url = format!("{}/access_token", self.auth_base.trim_end_matches('/'));
236        let response = crate::http::http_client()
237            .post(url)
238            .header("Accept", "application/json")
239            .form(&[
240                ("client_id", self.client_id.as_str()),
241                ("grant_type", "refresh_token"),
242                ("refresh_token", refresh_token),
243            ])
244            .send()
245            .await
246            .map_err(|e| api_error(format!("Failed to refresh access token: {e}")))?
247            .json::<TokenResponse>()
248            .await
249            .map_err(|e| invalid_response(format!("Invalid refresh response: {e}")))?;
250
251        if let Some(err) = &response.error {
252            tracing::debug!(
253                error = err.as_str(),
254                description = response.error_description.as_deref().unwrap_or(""),
255                "GitHub OAuth refresh token rejected; falling back to device flow"
256            );
257            return Ok(None);
258        }
259
260        let mut refreshed = self
261            .token_response_to_cache(response, cached.login.clone())
262            .await?;
263        if refreshed.login.is_none() {
264            refreshed.login = cached.login.clone();
265        }
266        Ok(Some(refreshed))
267    }
268
269    async fn token_response_to_cache(
270        &self,
271        response: TokenResponse,
272        login: Option<String>,
273    ) -> Result<CachedToken> {
274        let access_token = response.access_token.ok_or_else(|| {
275            invalid_response("Token response missing 'access_token' field".to_string())
276        })?;
277        let now = chrono::Utc::now();
278        let expires_at =
279            now + chrono::Duration::seconds(response.expires_in.unwrap_or(DEFAULT_TOKEN_SECS));
280        let refresh_expires_at = response
281            .refresh_token_expires_in
282            .map(|secs| now + chrono::Duration::seconds(secs));
283        let login = match login {
284            Some(login) => Some(login),
285            None => self.get_login(&access_token).await.ok(),
286        };
287
288        Ok(CachedToken {
289            access_token,
290            expires_at,
291            refresh_token: response.refresh_token,
292            refresh_expires_at,
293            login,
294        })
295    }
296
297    async fn get_login(&self, access_token: &str) -> Result<String> {
298        let url = format!("{}/user", self.api_base.trim_end_matches('/'));
299        let response = crate::http::http_client()
300            .get(url)
301            .header("Accept", "application/vnd.github+json")
302            .header("X-GitHub-Api-Version", "2022-11-28")
303            .bearer_auth(access_token)
304            .send()
305            .await
306            .map_err(|e| api_error(format!("Failed to fetch authenticated GitHub user: {e}")))?
307            .json::<UserResponse>()
308            .await
309            .map_err(|e| invalid_response(format!("Invalid GitHub user response: {e}")))?;
310        response.login.ok_or_else(|| {
311            invalid_response(
312                response
313                    .message
314                    .unwrap_or_else(|| "Response missing 'login' field".to_string()),
315            )
316        })
317    }
318
319    fn print_device_instructions(&self, device: &DeviceCodeResponse) {
320        eprintln!(
321            "Open {} and enter code {} to authorize GitHub access.",
322            device.verification_uri, device.user_code
323        );
324        if self.open_browser {
325            let url = device.verification_uri.clone();
326            std::mem::drop(tokio::task::spawn_blocking(move || {
327                let _ = open_browser(&url);
328            }));
329        }
330    }
331
332    async fn create_or_load_token(&self) -> Result<CachedToken> {
333        if let Some(cached) = self.read_cached_token() {
334            let buffer = chrono::Duration::seconds(CACHE_REUSE_BUFFER_SECS);
335            if cached.expires_at - buffer > chrono::Utc::now() {
336                return Ok(cached);
337            }
338            if let Some(refreshed) = self.refresh_access_token(&cached).await? {
339                self.write_cached_token(&refreshed);
340                return Ok(refreshed);
341            }
342        }
343
344        let device = self.create_device_code().await?;
345        self.print_device_instructions(&device);
346        let response = self.poll_access_token(&device).await?;
347        let token = self.token_response_to_cache(response, None).await?;
348        self.write_cached_token(&token);
349        Ok(token)
350    }
351}
352
353#[async_trait]
354impl LeaseBackend for GitHubOauthBackend {
355    async fn create_lease(&self, _duration: Duration, _label: &str) -> Result<Lease> {
356        let token = self.create_or_load_token().await?;
357
358        let mut credentials = IndexMap::new();
359        credentials.insert(self.env_var.clone(), token.access_token.clone());
360
361        Ok(Lease {
362            credentials,
363            expires_at: Some(token.expires_at),
364            lease_id: super::generate_lease_id("github-oauth"),
365        })
366    }
367
368    fn max_lease_duration(&self) -> Duration {
369        Duration::from_secs(DEFAULT_TOKEN_SECS as u64)
370    }
371}
372
373fn default_poll_interval() -> u64 {
374    5
375}
376
377fn invalid_response(details: String) -> FnoxError {
378    FnoxError::ProviderInvalidResponse {
379        provider: PROVIDER.to_string(),
380        details,
381        hint: "Unexpected response from GitHub OAuth API".to_string(),
382        url: URL.to_string(),
383    }
384}
385
386fn api_error(details: String) -> FnoxError {
387    FnoxError::ProviderApiError {
388        provider: PROVIDER.to_string(),
389        details,
390        hint: "Failed to create GitHub user access token".to_string(),
391        url: URL.to_string(),
392    }
393}
394
395fn auth_failed(details: String) -> FnoxError {
396    FnoxError::ProviderAuthFailed {
397        provider: PROVIDER.to_string(),
398        details,
399        hint: "Run the command again and approve the device authorization prompt".to_string(),
400        url: URL.to_string(),
401    }
402}
403
404fn open_browser(url: &str) -> std::io::Result<()> {
405    #[cfg(target_os = "macos")]
406    {
407        std::process::Command::new("open").arg(url).status()?;
408    }
409    #[cfg(target_os = "windows")]
410    {
411        std::process::Command::new("cmd")
412            .args(["/C", "start", "", url])
413            .status()?;
414    }
415    #[cfg(all(unix, not(target_os = "macos")))]
416    {
417        std::process::Command::new("xdg-open").arg(url).status()?;
418    }
419    Ok(())
420}