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}