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