1use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use anyhow::{Context, Result};
19use async_trait::async_trait;
20use parking_lot::Mutex;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Serialize)]
26#[serde(rename_all = "camelCase", tag = "status")]
27pub enum PollOutcome {
29 Pending,
31 Success {
34 access_token: String,
35 refresh_token: Option<String>,
36 expires_in: i64,
37 scope: Option<String>,
38 },
39 Expired,
41 Denied,
43}
44
45#[async_trait]
47pub trait OAuthProvider: Send + Sync {
48 fn name(&self) -> &str;
50
51 async fn start(&self, scopes: &[String]) -> Result<DeviceCode>;
54
55 async fn poll(&self, device_code: &str) -> Result<PollOutcome>;
58
59 async fn refresh(&self, refresh_token: &str) -> Result<String>;
62
63 async fn revoke(&self, token: &str) -> Result<()>;
65}
66
67#[derive(Debug, Clone)]
69pub struct DeviceCode {
70 pub device_code: String,
72 pub user_code: String,
74 pub verification_url: String,
76 pub expires_in: u64,
78 pub interval: u64,
80}
81
82#[derive(Debug, Clone, Serialize)]
84#[serde(rename_all = "camelCase")]
85pub struct DeviceCodeResponse {
86 pub handle: String,
88 pub user_code: String,
89 pub verification_url: String,
90 pub expires_in: u64,
91}
92
93#[derive(Debug, Clone, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct PollResponse {
98 pub status: String,
100}
101
102impl PollResponse {
103 fn pending() -> Self {
104 Self {
105 status: "pending".into(),
106 }
107 }
108 fn success() -> Self {
109 Self {
110 status: "success".into(),
111 }
112 }
113 fn from_outcome(o: &PollOutcome) -> Self {
114 match o {
115 PollOutcome::Pending => Self::pending(),
116 PollOutcome::Success { .. } => Self::success(),
117 PollOutcome::Expired => Self {
118 status: "expired".into(),
119 },
120 PollOutcome::Denied => Self {
121 status: "denied".into(),
122 },
123 }
124 }
125}
126
127pub struct GitHubProvider {
134 client: reqwest::Client,
135 client_id: String,
138}
139
140const GITHUB_DEVICE_URL: &str = "https://github.com/login/device/code";
141const GITHUB_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
142
143impl GitHubProvider {
144 pub fn new() -> Self {
146 Self {
147 client: reqwest::Client::new(),
148 client_id: std::env::var("OXIOS_GITHUB_CLIENT_ID")
152 .unwrap_or_else(|_| "OxiosOAuthAppPlaceholder".into()),
153 }
154 }
155
156 #[allow(dead_code)]
158 pub fn with_client_id(client_id: String) -> Self {
159 Self {
160 client: reqwest::Client::new(),
161 client_id,
162 }
163 }
164}
165
166impl Default for GitHubProvider {
167 fn default() -> Self {
168 Self::new()
169 }
170}
171
172#[derive(Debug, Deserialize)]
173struct GhDeviceResp {
174 device_code: String,
175 user_code: String,
176 verification_uri: String,
177 expires_in: u64,
178 interval: u64,
179}
180
181#[derive(Debug, Deserialize)]
182struct GhTokenResp {
183 access_token: Option<String>,
184 error: Option<String>,
185}
186
187#[async_trait]
188impl OAuthProvider for GitHubProvider {
189 fn name(&self) -> &str {
190 "github"
191 }
192
193 async fn start(&self, scopes: &[String]) -> Result<DeviceCode> {
194 let resp = self
195 .client
196 .post(GITHUB_DEVICE_URL)
197 .header("Accept", "application/json")
198 .form(&[
199 ("client_id", self.client_id.as_str()),
200 ("scope", &scopes.join(" ")),
201 ])
202 .send()
203 .await
204 .context("github device-code request")?;
205 let body: GhDeviceResp = resp
206 .json()
207 .await
208 .context("parsing github device response")?;
209 Ok(DeviceCode {
210 device_code: body.device_code,
211 user_code: body.user_code,
212 verification_url: body.verification_uri,
213 expires_in: body.expires_in,
214 interval: body.interval,
215 })
216 }
217
218 async fn poll(&self, device_code: &str) -> Result<PollOutcome> {
219 let grant = "urn:ietf:params:oauth:grant-type:device_code";
220 let resp = self
221 .client
222 .post(GITHUB_TOKEN_URL)
223 .header("Accept", "application/json")
224 .form(&[
225 ("client_id", self.client_id.as_str()),
226 ("device_code", device_code),
227 ("grant_type", grant),
228 ])
229 .send()
230 .await
231 .context("github token poll request")?;
232 let body: GhTokenResp = resp.json().await.context("parsing github token response")?;
233
234 if let Some(token) = body.access_token {
235 return Ok(PollOutcome::Success {
239 access_token: token,
240 refresh_token: None,
241 expires_in: 0,
242 scope: None,
243 });
244 }
245 match body.error.as_deref() {
246 Some("authorization_pending") => Ok(PollOutcome::Pending),
247 Some("slow_down") => Ok(PollOutcome::Pending),
248 Some("expired_token") => Ok(PollOutcome::Expired),
249 Some("access_denied") => Ok(PollOutcome::Denied),
250 Some(other) => anyhow::bail!("github oauth error: {other}"),
251 None => anyhow::bail!("github oauth: no token and no error"),
252 }
253 }
254
255 async fn refresh(&self, _refresh_token: &str) -> Result<String> {
256 anyhow::bail!("github device-flow tokens do not support refresh")
258 }
259
260 async fn revoke(&self, token: &str) -> Result<()> {
261 let _ = self
266 .client
267 .delete(format!(
268 "https://api.github.com/applications/{}/token",
269 self.client_id
270 ))
271 .basic_auth(&self.client_id, Some(""))
272 .json(&serde_json::json!({ "access_token": token }))
273 .send()
274 .await;
275 Ok(())
276 }
277}
278
279struct PendingFlow {
283 provider_name: String,
284 device_code: String,
285 store_key: String,
286 expires_at: Instant,
287}
288
289pub struct OAuthBroker {
292 providers: HashMap<String, Arc<dyn OAuthProvider>>,
293 flows: Mutex<HashMap<String, PendingFlow>>,
294}
295
296impl OAuthBroker {
297 pub fn new() -> Self {
299 let mut providers: HashMap<String, Arc<dyn OAuthProvider>> = HashMap::new();
300 let gh: Arc<dyn OAuthProvider> = Arc::new(GitHubProvider::new());
301 providers.insert(gh.name().to_string(), gh);
302 Self {
303 providers,
304 flows: Mutex::new(HashMap::new()),
305 }
306 }
307
308 fn provider(&self, name: &str) -> Result<Arc<dyn OAuthProvider>> {
309 self.providers
310 .get(name)
311 .cloned()
312 .with_context(|| format!("unknown OAuth provider '{name}'"))
313 }
314
315 pub async fn start(
318 &self,
319 provider_name: &str,
320 store_key: &str,
321 scopes: &[String],
322 ) -> Result<DeviceCodeResponse> {
323 let provider = self.provider(provider_name)?;
324 let dc = provider.start(scopes).await?;
325 let handle = format!("oc_{}", uuid::Uuid::new_v4().simple());
326 let expires_at = Instant::now() + Duration::from_secs(dc.expires_in.max(1));
327 self.flows.lock().insert(
328 handle.clone(),
329 PendingFlow {
330 provider_name: provider_name.to_string(),
331 device_code: dc.device_code,
332 store_key: store_key.to_string(),
333 expires_at,
334 },
335 );
336 Ok(DeviceCodeResponse {
337 handle,
338 user_code: dc.user_code,
339 verification_url: dc.verification_url,
340 expires_in: dc.expires_in,
341 })
342 }
343
344 pub async fn poll(&self, handle: &str) -> Result<PollResponse> {
348 let entry = {
349 let mut flows = self.flows.lock();
350 if let Some(flow) = flows.get(handle)
352 && Instant::now() > flow.expires_at
353 {
354 flows.remove(handle);
355 return Ok(PollResponse {
356 status: "expired".into(),
357 });
358 }
359 flows.get(handle).map(|f| {
360 (
361 f.provider_name.clone(),
362 f.device_code.clone(),
363 f.store_key.clone(),
364 )
365 })
366 };
367 let (provider_name, device_code, store_key) =
368 entry.ok_or_else(|| anyhow::anyhow!("unknown or expired OAuth handle"))?;
369 let provider = self.provider(&provider_name)?;
370 let outcome = provider.poll(&device_code).await?;
371 let (terminal, response) = match outcome {
375 PollOutcome::Success {
376 access_token,
377 refresh_token,
378 expires_in,
379 scope,
380 } => {
381 let bundle = oxi_sdk::TokenBundle {
382 access_token,
383 refresh_token,
384 token_type: "Bearer".to_string(),
385 obtained_at: chrono::Utc::now(),
386 expires_in: expires_in.max(0) as u64,
387 scope,
388 };
389 crate::credential::CredentialStore::store_token(&store_key, bundle)?;
392 (true, PollResponse::success())
393 }
394 PollOutcome::Pending => (false, PollResponse::pending()),
395 ref other => (true, PollResponse::from_outcome(other)),
396 };
397 if terminal {
398 self.flows.lock().remove(handle);
399 }
400 Ok(response)
401 }
402
403 pub async fn revoke(&self, provider_name: &str, token: &str) -> Result<()> {
405 let provider = self.provider(provider_name)?;
406 let _ = provider.revoke(token).await;
408 Ok(())
409 }
410}
411
412impl Default for OAuthBroker {
413 fn default() -> Self {
414 Self::new()
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 struct PendingProvider;
425 #[async_trait]
426 impl OAuthProvider for PendingProvider {
427 fn name(&self) -> &str {
428 "pending"
429 }
430 async fn start(&self, _scopes: &[String]) -> Result<DeviceCode> {
431 Ok(DeviceCode {
432 device_code: "dev-secret".into(),
433 user_code: "USER-CODE".into(),
434 verification_url: "https://example.com/device".into(),
435 expires_in: 1,
436 interval: 1,
437 })
438 }
439 async fn poll(&self, _device_code: &str) -> Result<PollOutcome> {
440 Ok(PollOutcome::Pending)
441 }
442 async fn refresh(&self, _: &str) -> Result<String> {
443 unreachable!()
444 }
445 async fn revoke(&self, _: &str) -> Result<()> {
446 Ok(())
447 }
448 }
449
450 #[tokio::test]
451 async fn start_returns_no_device_code() {
452 let broker = OAuthBroker {
453 providers: {
454 let mut m: HashMap<String, Arc<dyn OAuthProvider>> = HashMap::new();
455 m.insert("pending".into(), Arc::new(PendingProvider));
456 m
457 },
458 flows: Mutex::new(HashMap::new()),
459 };
460 let resp = broker.start("pending", "pending", &[]).await.unwrap();
461 let json = serde_json::to_string(&resp).unwrap();
463 assert!(json.contains("user_code"));
464 assert!(json.contains("handle"));
465 assert!(
466 !json.contains("dev-secret"),
467 "device_code leaked to client!"
468 );
469 }
470
471 #[tokio::test]
472 async fn expired_handle_returns_expired() {
473 let broker = OAuthBroker {
474 providers: {
475 let mut m: HashMap<String, Arc<dyn OAuthProvider>> = HashMap::new();
476 m.insert("pending".into(), Arc::new(PendingProvider));
477 m
478 },
479 flows: Mutex::new(HashMap::new()),
480 };
481 let resp = broker.start("pending", "pending", &[]).await.unwrap();
482 tokio::time::sleep(Duration::from_millis(1100)).await;
484 let pr = broker.poll(&resp.handle).await.unwrap();
485 assert_eq!(pr.status, "expired");
486 }
487
488 #[tokio::test]
489 async fn unknown_handle_errors() {
490 let broker = OAuthBroker::new();
491 assert!(broker.poll("nonexistent").await.is_err());
492 }
493}