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 let jitter_ms = (rand::random::<u64>() % (backoff_secs * 1000 / 2)) as u64;
161 let delay = Duration::from_secs(backoff_secs) + Duration::from_millis(jitter_ms);
162 sleep(delay).await;
163 }
164
165 let payload = CreateSshKeyRequest {
166 key: public_key.to_string(),
167 title: title.to_string(),
168 };
169
170 let resp = client
171 .post("https://api.github.com/user/ssh_signing_keys")
172 .header("Authorization", format!("Bearer {}", access_token))
173 .header("User-Agent", "auths-cli")
174 .header("Accept", "application/vnd.github+json")
175 .json(&payload)
176 .send()
177 .await;
178
179 let resp = match resp {
180 Ok(r) => r,
181 Err(e) => {
182 let net_err = map_reqwest_error(e, "api.github.com");
183 if attempt < MAX_RETRIES {
184 continue;
185 }
186 return Err(PlatformError::Network(net_err));
187 }
188 };
189
190 let status = resp.status().as_u16();
191
192 if status == 201 {
194 match resp.json::<SshKeyResponse>().await {
195 Ok(key) => return Ok(key.id.to_string()),
196 Err(_e) => {
197 return Ok("created".to_string());
200 }
201 }
202 }
203
204 if status == 422 {
206 return Ok("duplicate".to_string());
207 }
208
209 if status == 401 {
211 return Err(PlatformError::Platform {
212 message: "GitHub authentication failed. Check your token and try again."
213 .to_string(),
214 });
215 }
216
217 if status == 403 {
219 return Err(PlatformError::Platform {
220 message:
221 "Insufficient GitHub scope. Run 'auths id update-scope github' to re-authorize."
222 .to_string(),
223 });
224 }
225
226 if status == 429 {
228 if let Some(retry_after) = resp.headers().get("retry-after")
229 && let Ok(retry_str) = retry_after.to_str()
230 && let Ok(retry_secs) = retry_str.parse::<u64>()
231 {
232 sleep(Duration::from_secs(retry_secs)).await;
233 continue;
234 }
235 if attempt < MAX_RETRIES {
236 continue;
237 }
238 return Err(PlatformError::Platform {
239 message: "GitHub rate limit exceeded. Try again later.".to_string(),
240 });
241 }
242
243 if (500..600).contains(&status) {
245 if attempt < MAX_RETRIES {
246 continue;
247 }
248 return Err(PlatformError::Platform {
249 message: format!("GitHub service error (HTTP {status}). Try again later."),
250 });
251 }
252
253 let body = resp.text().await.unwrap_or_default();
255 return Err(PlatformError::Platform {
256 message: format!("SSH key upload failed (HTTP {status}): {body}"),
257 });
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn uploader_constructs() {
267 let _uploader = HttpGitHubSshKeyUploader::new();
268 }
269
270 #[test]
271 fn upload_signing_key_returns_key_id_on_201() {
272 let _uploader = HttpGitHubSshKeyUploader::new();
273
274 let access_token = "test_token";
275 let public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHK5hkxLPKx6KLwlzQ";
276 let title = "test/key";
277
278 assert!(!access_token.is_empty());
281 assert!(!public_key.is_empty());
282 assert!(!title.is_empty());
283 }
284
285 #[test]
286 fn ssh_key_response_deserializes() {
287 let json =
288 r#"{"id": 12345, "key": "ssh-ed25519 AAAA...", "title": "test-key", "verified": true}"#;
289 let key: Result<SshKeyResponse, _> = serde_json::from_str(json);
290 assert!(key.is_ok());
291 let key = key.unwrap();
292 assert_eq!(key.id, 12345);
293 }
294
295 #[test]
296 fn create_ssh_key_request_serializes() {
297 let req = CreateSshKeyRequest {
298 key: "ssh-ed25519 AAAA...".to_string(),
299 title: "test".to_string(),
300 };
301 let json = serde_json::to_string(&req).unwrap();
302 assert!(json.contains("ssh-ed25519"));
303 assert!(json.contains("test"));
304 }
305}