cadmus_core/github/
client.rs

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