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}