fnox_core/lease_backends/
github_oauth.rs1use 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
15pub 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}