auths_infra_http/
github_ssh_keys.rs1use std::future::Future;
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7use tokio::time::sleep;
8
9use auths_core::ports::platform::{PlatformError, SshSigningKeyUploader};
10
11use crate::default_http_client;
12use crate::error::map_reqwest_error;
13
14#[derive(Deserialize, Debug)]
15struct SshKeyResponse {
16 id: u64,
17 key: String,
18 #[serde(default)]
19 #[allow(dead_code)]
20 title: String,
21 #[allow(dead_code)]
22 verified: bool,
23}
24
25#[derive(Serialize)]
26struct CreateSshKeyRequest {
27 key: String,
28 title: String,
29}
30
31pub struct HttpGitHubSshKeyUploader {
42 client: reqwest::Client,
43}
44
45impl HttpGitHubSshKeyUploader {
46 pub fn new() -> Self {
48 Self {
49 client: default_http_client(),
50 }
51 }
52}
53
54impl Default for HttpGitHubSshKeyUploader {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl SshSigningKeyUploader for HttpGitHubSshKeyUploader {
61 fn upload_signing_key(
62 &self,
63 access_token: &str,
64 public_key: &str,
65 title: &str,
66 ) -> impl Future<Output = Result<String, PlatformError>> + Send {
67 let client = self.client.clone();
68 let access_token = access_token.to_string();
69 let public_key = public_key.to_string();
70 let title = title.to_string();
71
72 async move { upload_signing_key_impl(&client, &access_token, &public_key, &title).await }
73 }
74}
75
76async fn upload_signing_key_impl(
77 client: &reqwest::Client,
78 access_token: &str,
79 public_key: &str,
80 title: &str,
81) -> Result<String, PlatformError> {
82 if let Ok(existing_id) = check_existing_key(client, access_token, public_key).await {
84 return Ok(existing_id);
85 }
86
87 post_ssh_key_with_retry(client, access_token, public_key, title).await
89}
90
91async fn check_existing_key(
92 client: &reqwest::Client,
93 access_token: &str,
94 public_key: &str,
95) -> Result<String, PlatformError> {
96 let resp = client
97 .get("https://api.github.com/user/ssh_signing_keys")
98 .header("Authorization", format!("Bearer {}", access_token))
99 .header("User-Agent", "auths-cli")
100 .header("Accept", "application/vnd.github+json")
101 .send()
102 .await
103 .map_err(|e| PlatformError::Network(map_reqwest_error(e, "api.github.com")))?;
104
105 let status = resp.status().as_u16();
106 if status == 401 {
107 return Err(PlatformError::Platform {
108 message: "GitHub authentication failed. Check your token and try again.".to_string(),
109 });
110 }
111 if status == 403 {
112 return Err(PlatformError::Platform {
113 message:
114 "Insufficient GitHub scope. Run 'auths id update-scope github' to re-authorize."
115 .to_string(),
116 });
117 }
118 if !resp.status().is_success() {
119 return Err(PlatformError::Network(
120 auths_core::ports::network::NetworkError::InvalidResponse {
121 detail: format!("HTTP {}", status),
122 },
123 ));
124 }
125
126 let keys: Vec<SshKeyResponse> = resp.json().await.map_err(|e| PlatformError::Platform {
127 message: format!("failed to parse SSH keys response: {e}"),
128 })?;
129
130 for key in keys {
132 if key.key == public_key {
133 return Ok(key.id.to_string());
134 }
135 }
136
137 Err(PlatformError::Platform {
138 message: "key not found".to_string(),
139 })
140}
141
142async fn post_ssh_key_with_retry(
143 client: &reqwest::Client,
144 access_token: &str,
145 public_key: &str,
146 title: &str,
147) -> Result<String, PlatformError> {
148 const MAX_RETRIES: u32 = 3;
149 let mut attempt = 0;
150
151 loop {
152 attempt += 1;
153 let backoff_secs = if attempt > 1 {
154 2_u64.pow(attempt - 2)
155 } else {
156 0
157 };
158
159 if attempt > 1 {
160 use rand::RngCore;
164 use rand::rngs::OsRng;
165 let jitter_ms = OsRng.next_u64() % (backoff_secs * 1000 / 2);
166 let delay = Duration::from_secs(backoff_secs) + Duration::from_millis(jitter_ms);
167 sleep(delay).await;
168 }
169
170 let payload = CreateSshKeyRequest {
171 key: public_key.to_string(),
172 title: title.to_string(),
173 };
174
175 let resp = client
176 .post("https://api.github.com/user/ssh_signing_keys")
177 .header("Authorization", format!("Bearer {}", access_token))
178 .header("User-Agent", "auths-cli")
179 .header("Accept", "application/vnd.github+json")
180 .json(&payload)
181 .send()
182 .await;
183
184 let resp = match resp {
185 Ok(r) => r,
186 Err(e) => {
187 let net_err = map_reqwest_error(e, "api.github.com");
188 if attempt < MAX_RETRIES {
189 continue;
190 }
191 return Err(PlatformError::Network(net_err));
192 }
193 };
194
195 let status = resp.status().as_u16();
196
197 if status == 201 {
199 match resp.json::<SshKeyResponse>().await {
200 Ok(key) => return Ok(key.id.to_string()),
201 Err(_e) => {
202 return Ok("created".to_string());
205 }
206 }
207 }
208
209 if status == 422 {
211 return Ok("duplicate".to_string());
212 }
213
214 if status == 401 {
216 return Err(PlatformError::Platform {
217 message: "GitHub authentication failed. Check your token and try again."
218 .to_string(),
219 });
220 }
221
222 if status == 403 {
224 return Err(PlatformError::Platform {
225 message:
226 "Insufficient GitHub scope. Run 'auths id update-scope github' to re-authorize."
227 .to_string(),
228 });
229 }
230
231 if status == 429 {
233 if let Some(retry_after) = resp.headers().get("retry-after")
234 && let Ok(retry_str) = retry_after.to_str()
235 && let Ok(retry_secs) = retry_str.parse::<u64>()
236 {
237 sleep(Duration::from_secs(retry_secs)).await;
238 continue;
239 }
240 if attempt < MAX_RETRIES {
241 continue;
242 }
243 return Err(PlatformError::Platform {
244 message: "GitHub rate limit exceeded. Try again later.".to_string(),
245 });
246 }
247
248 if (500..600).contains(&status) {
250 if attempt < MAX_RETRIES {
251 continue;
252 }
253 return Err(PlatformError::Platform {
254 message: format!("GitHub service error (HTTP {status}). Try again later."),
255 });
256 }
257
258 let body = resp.text().await.unwrap_or_default();
260 return Err(PlatformError::Platform {
261 message: format!("SSH key upload failed (HTTP {status}): {body}"),
262 });
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn uploader_constructs() {
272 let _uploader = HttpGitHubSshKeyUploader::new();
273 }
274
275 #[test]
276 fn upload_signing_key_returns_key_id_on_201() {
277 let _uploader = HttpGitHubSshKeyUploader::new();
278
279 let access_token = "test_token";
280 let public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHK5hkxLPKx6KLwlzQ";
281 let title = "test/key";
282
283 assert!(!access_token.is_empty());
286 assert!(!public_key.is_empty());
287 assert!(!title.is_empty());
288 }
289
290 #[test]
291 fn ssh_key_response_deserializes() {
292 let json =
293 r#"{"id": 12345, "key": "ssh-ed25519 AAAA...", "title": "test-key", "verified": true}"#;
294 let key: Result<SshKeyResponse, _> = serde_json::from_str(json);
295 assert!(key.is_ok());
296 let key = key.unwrap();
297 assert_eq!(key.id, 12345);
298 }
299
300 #[test]
301 fn create_ssh_key_request_serializes() {
302 let req = CreateSshKeyRequest {
303 key: "ssh-ed25519 AAAA...".to_string(),
304 title: "test".to_string(),
305 };
306 let json = serde_json::to_string(&req).unwrap();
307 assert!(json.contains("ssh-ed25519"));
308 assert!(json.contains("test"));
309 }
310}