Skip to main content

cadmus_core/github/
client.rs

1use super::types::{
2    AccessTokenResponse, DeviceCodeResponse, ScopeError, TokenPollResult, VerifyScopesError,
3};
4use crate::github::GithubError;
5use crate::http::{ChunkedDownloadError, Client};
6use reqwest::blocking::RequestBuilder;
7use secrecy::{ExposeSecret, SecretString};
8use std::path::PathBuf;
9
10/// GitHub OAuth App client ID, baked in at build time via `GH_OAUTH_CLIENT_ID` env var.
11///
12/// Kept private so callers never need to know or pass it; [`GithubClient`] uses it internally.
13const GITHUB_OAUTH_CLIENT_ID: &str = env!("GH_OAUTH_CLIENT_ID");
14
15/// OAuth scopes that the saved token must have for OTA operations to succeed.
16///
17/// This is the single source of truth for required scopes. Both
18/// [`GithubClient::initiate_device_flow`] and
19/// [`GithubClient::verify_token_scopes`] derive from this list, so adding or
20/// removing a scope here is the only change needed.
21///
22/// Current requirements:
23/// - `public_repo` — required to download Actions artifacts from public repositories
24pub const REQUIRED_SCOPES: &[&str] = &["public_repo"];
25
26/// Thin HTTP wrapper around the GitHub REST API.
27///
28/// Handles TLS setup, authentication headers, and base URL construction.
29/// Consumers (e.g. [`OtaClient`](crate::ota::OtaClient)) call the specific
30/// API methods they need.
31///
32/// # Examples
33///
34/// ```no_run
35/// use cadmus_core::github::GithubClient;
36///
37/// // Unauthenticated client for public endpoints
38/// let client = GithubClient::new(None).expect("failed to build client");
39/// ```
40///
41/// ```no_run
42/// use cadmus_core::github::GithubClient;
43/// use secrecy::SecretString;
44///
45/// // Authenticated client for private/token-gated endpoints
46/// let token = SecretString::from("ghp_…".to_owned());
47/// let client = GithubClient::new(Some(token)).expect("failed to build client");
48/// ```
49pub struct GithubClient {
50    http: Client,
51    token: Option<SecretString>,
52}
53
54impl GithubClient {
55    /// Creates a new client with optional GitHub token authentication.
56    ///
57    /// Uses `webpki-roots` certificates for TLS — no system cert store
58    /// required, which matters on Kobo devices that ship without a CA bundle.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the underlying HTTP client fails to build.
63    ///
64    /// # Examples
65    ///
66    /// ```no_run
67    /// use cadmus_core::github::GithubClient;
68    ///
69    /// let client = GithubClient::new(None).expect("failed to build client");
70    /// ```
71    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
72    pub fn new(token: Option<SecretString>) -> Result<Self, GithubError> {
73        tracing::debug!(token_provided = token.is_some(), "Building GitHub client");
74
75        let http = Client::new()?;
76
77        tracing::debug!("GitHub client built successfully");
78        Ok(Self { http, token })
79    }
80
81    /// Returns a GET request builder with the `Authorization` header set if a
82    /// token is present.
83    pub fn get(&self, url: &str) -> RequestBuilder {
84        self.with_auth(self.http.get(url))
85    }
86
87    /// Returns a POST request builder with the `Authorization` header set if a
88    /// token is present.
89    pub fn post(&self, url: &str) -> RequestBuilder {
90        self.with_auth(self.http.post(url))
91    }
92
93    /// Returns a GET request builder **without** any `Authorization` header.
94    ///
95    /// Used for public URLs (e.g. release asset downloads) where sending a
96    /// token would cause GitHub to reject the request with a 401.
97    pub fn get_unauthenticated(&self, url: &str) -> RequestBuilder {
98        self.http.get(url)
99    }
100
101    /// Downloads a file to `dest` using HTTP Range requests.
102    ///
103    /// Delegates to [`Client::download`]. `request_builder` is called once per
104    /// chunk (and per retry) to produce a `RequestBuilder` for the given URL.
105    ///
106    /// # Errors
107    ///
108    /// Returns `ChunkedDownloadError` if the file cannot be written or if all
109    /// retry attempts for any chunk fail.
110    #[cfg_attr(
111        feature = "tracing",
112        tracing::instrument(skip(self, request_builder, progress_callback))
113    )]
114    pub fn download<B, F>(
115        &self,
116        url: &str,
117        total_size: u64,
118        dest: &PathBuf,
119        request_builder: B,
120        progress_callback: &mut F,
121    ) -> Result<(), ChunkedDownloadError>
122    where
123        B: Fn(&str) -> RequestBuilder,
124        F: FnMut(u64, u64),
125    {
126        self.http
127            .download(url, total_size, dest, request_builder, progress_callback)
128    }
129
130    fn with_auth(&self, builder: RequestBuilder) -> RequestBuilder {
131        match &self.token {
132            Some(token) => {
133                builder.header("Authorization", format!("Bearer {}", token.expose_secret()))
134            }
135            None => {
136                tracing::warn!("Authentication requested but no token configured");
137                builder
138            }
139        }
140    }
141
142    /// Initiates GitHub device flow authentication.
143    ///
144    /// POSTs to `/login/device/code` to obtain a short user code and the
145    /// verification URL. The caller must display these to the user and then
146    /// call [`poll_device_token`](Self::poll_device_token) repeatedly until
147    /// authorization completes or the code expires.
148    ///
149    /// The required OAuth scopes are derived from [`REQUIRED_SCOPES`] so this
150    /// method and [`verify_token_scopes`](Self::verify_token_scopes) always
151    /// stay in sync.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the network request fails or GitHub returns a
156    /// non-2xx status.
157    ///
158    /// # Examples
159    ///
160    /// ```no_run
161    /// use cadmus_core::github::GithubClient;
162    ///
163    /// let client = GithubClient::new(None).expect("failed to build client");
164    /// let response = client.initiate_device_flow().expect("device flow failed");
165    /// println!("Go to {} and enter {}", response.verification_uri, response.user_code);
166    /// ```
167    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
168    pub fn initiate_device_flow(&self) -> Result<DeviceCodeResponse, String> {
169        tracing::info!(
170            client_id = GITHUB_OAUTH_CLIENT_ID,
171            "Initiating GitHub device auth flow"
172        );
173
174        let scope = REQUIRED_SCOPES.join(" ");
175        tracing::debug!(scope = %scope, "Requesting device code with scopes");
176
177        let response = self
178            .http
179            .post("https://github.com/login/device/code")
180            .header("Accept", "application/json")
181            .form(&[("client_id", GITHUB_OAUTH_CLIENT_ID), ("scope", &scope)])
182            .send()
183            .map_err(|e| format!("Device code request failed: {}", e))?
184            .error_for_status()
185            .map_err(|e| format!("Device code request error: {}", e))?;
186
187        let device_code_response = response
188            .json::<DeviceCodeResponse>()
189            .map_err(|e| format!("Failed to parse device code response: {}", e))?;
190
191        tracing::debug!(
192            verification_uri = %device_code_response.verification_uri,
193            expires_in = device_code_response.expires_in,
194            interval = device_code_response.interval,
195            "Device code obtained"
196        );
197
198        Ok(device_code_response)
199    }
200
201    /// Verifies that the current token has all scopes listed in
202    /// [`REQUIRED_SCOPES`].
203    ///
204    /// Makes a lightweight `GET /user` request and reads the
205    /// `X-OAuth-Scopes` response header, which GitHub includes on every
206    /// authenticated API call. Returns `Ok(())` if all required scopes are
207    /// present, or `Err(missing)` listing the absent scope names.
208    ///
209    /// Call this once before starting a download to catch stale tokens
210    /// early, rather than failing mid-download with confusing 403.
211    ///
212    /// # Errors
213    ///
214    /// Returns `Err` if the network request fails, GitHub returns a non-2xx
215    /// status, or one or more required scopes are absent.
216    ///
217    /// # Examples
218    ///
219    /// ```no_run
220    /// use cadmus_core::github::GithubClient;
221    /// use secrecy::SecretString;
222    ///
223    /// let token = SecretString::from("ghp_…".to_owned());
224    /// let client = GithubClient::new(Some(token)).expect("failed to build client");
225    ///
226    /// match client.verify_token_scopes() {
227    ///     Ok(()) => println!("Token has all required scopes"),
228    ///     Err(e) => println!("Error: {}", e),
229    /// }
230    /// ```
231    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
232    pub fn verify_token_scopes(&self) -> Result<(), VerifyScopesError> {
233        tracing::debug!("Verifying token scopes");
234
235        let response = self
236            .get("https://api.github.com/user")
237            .header("Accept", "application/json")
238            .send()?
239            .error_for_status()?;
240
241        let granted: Vec<&str> = response
242            .headers()
243            .get("x-oauth-scopes")
244            .and_then(|v| v.to_str().ok())
245            .map(|s| s.split(',').map(str::trim).collect())
246            .unwrap_or_default();
247
248        tracing::debug!(granted = ?granted, required = ?REQUIRED_SCOPES, "Comparing token scopes");
249
250        let missing: Vec<String> = REQUIRED_SCOPES
251            .iter()
252            .filter(|&&required| !granted.contains(&required))
253            .map(|&s| s.to_owned())
254            .collect();
255
256        if missing.is_empty() {
257            tracing::debug!("Token scopes verified — all required scopes present");
258            Ok(())
259        } else {
260            tracing::warn!(missing = ?missing, "Token is missing required scopes");
261            Err(VerifyScopesError::from(ScopeError::new(missing)))
262        }
263    }
264
265    /// Polls GitHub once to check if the user has authorized the device.
266    ///
267    /// Must be called at least `interval` seconds apart (from the
268    /// [`DeviceCodeResponse`]). GitHub returns `slow_down` if polled too
269    /// frequently; the caller must add 5 seconds to the interval before the
270    /// next attempt.
271    ///
272    /// # Arguments
273    ///
274    /// * `device_code` - The `device_code` from [`initiate_device_flow`](Self::initiate_device_flow)
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the network request fails.
279    ///
280    /// # Examples
281    ///
282    /// ```no_run
283    /// use cadmus_core::github::GithubClient;
284    /// use cadmus_core::github::TokenPollResult;
285    /// use std::time::Duration;
286    ///
287    /// let client = GithubClient::new(None).expect("failed to build client");
288    /// let flow = client.initiate_device_flow().expect("device flow failed");
289    ///
290    /// loop {
291    ///     std::thread::sleep(Duration::from_secs(flow.interval));
292    ///     match client.poll_device_token(&flow.device_code).expect("poll failed") {
293    ///         TokenPollResult::Complete(token) => break,
294    ///         TokenPollResult::Pending => continue,
295    ///         _ => break,
296    ///     }
297    /// }
298    /// ```
299    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
300    pub fn poll_device_token(&self, device_code: &str) -> Result<TokenPollResult, String> {
301        tracing::debug!("Polling GitHub for device token");
302
303        let response = self
304            .http
305            .post("https://github.com/login/oauth/access_token")
306            .header("Accept", "application/json")
307            .form(&[
308                ("client_id", GITHUB_OAUTH_CLIENT_ID),
309                ("device_code", device_code),
310                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
311            ])
312            .send()
313            .map_err(|e| format!("Token poll request failed: {}", e))?
314            .error_for_status()
315            .map_err(|e| format!("Token poll error response: {}", e))?;
316
317        let body: AccessTokenResponse = response
318            .json()
319            .map_err(|e| format!("Failed to parse token response: {}", e))?;
320
321        if let Some(token) = body.access_token {
322            tracing::info!("Device flow authorization complete");
323            return Ok(TokenPollResult::Complete(SecretString::from(token)));
324        }
325
326        match body.error.as_deref() {
327            Some("authorization_pending") => {
328                tracing::debug!("Device flow authorization pending");
329                Ok(TokenPollResult::Pending)
330            }
331            Some("slow_down") => {
332                tracing::warn!("Device flow polling too fast — caller must increase interval");
333                Ok(TokenPollResult::SlowDown)
334            }
335            Some("expired_token") => {
336                tracing::warn!("Device flow code expired");
337                Ok(TokenPollResult::Expired)
338            }
339            Some("access_denied") => {
340                tracing::info!("Device flow cancelled by user");
341                Ok(TokenPollResult::Cancelled)
342            }
343            Some(other) => {
344                tracing::error!(error = other, "Unexpected device flow error");
345                Err(format!("Unexpected device flow error: {}", other))
346            }
347            None => {
348                tracing::error!("Empty body from token poll endpoint");
349                Err("Empty response from token endpoint".to_owned())
350            }
351        }
352    }
353}