Skip to main content

oxios_kernel/host_tools/
oauth.rs

1//! OAuth device-code broker (RFC-041 Phase 3).
2//!
3//! Implements the device-authorization grant (RFC 8628). Per H1, the
4//! `device_code` is a polling bearer secret and **never leaves the daemon** —
5//! the client receives only `{ handle, user_code, verification_url, expires_in }`
6//! and polls an opaque `handle`. The daemon owns the `device_code` in a
7//! transient, auto-expiring in-memory map.
8//!
9//! Provider HTTP clients are Rust impls of [`OAuthProvider`] (D9): TOML selects
10//! *which* provider + scopes, Rust implements *how*. The first impl is GitHub.
11//! GitHub device-flow tokens do not expire by default, so `refresh` is a no-op
12//! for GitHub — the trait still supports expiring providers.
13
14use 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// ─── Provider trait (D9) ─────────────────────────────────────────────────────
24
25#[derive(Debug, Clone, Serialize)]
26#[serde(rename_all = "camelCase", tag = "status")]
27/// Outcome of polling a provider's token endpoint.
28pub enum PollOutcome {
29    /// User hasn't authorized yet — keep polling.
30    Pending,
31    /// Success — carries the token data so the broker can persist it under the
32    /// correct `store_key`. The provider does NOT save (it has no store_key).
33    Success {
34        access_token: String,
35        refresh_token: Option<String>,
36        expires_in: i64,
37        scope: Option<String>,
38    },
39    /// The device code expired — restart the flow.
40    Expired,
41    /// The user denied authorization.
42    Denied,
43}
44
45/// A Rust implementation of one OAuth provider's device-code flow.
46#[async_trait]
47pub trait OAuthProvider: Send + Sync {
48    /// Stable provider name (matches the registry's `credential.provider`).
49    fn name(&self) -> &str;
50
51    /// Request a device code. Returns the data the user needs (`user_code`,
52    /// `verification_url`) plus the secret `device_code` + polling interval.
53    async fn start(&self, scopes: &[String]) -> Result<DeviceCode>;
54
55    /// Poll the token endpoint once. On success, store the token and return
56    /// `Success`; otherwise return the pending/expired/denied state.
57    async fn poll(&self, device_code: &str) -> Result<PollOutcome>;
58
59    /// Refresh an expiring token (no-op for GitHub). Returns the refreshed
60    /// `access_token`.
61    async fn refresh(&self, refresh_token: &str) -> Result<String>;
62
63    /// Revoke a token at the provider (best-effort).
64    async fn revoke(&self, token: &str) -> Result<()>;
65}
66
67/// Device-authorization response from `start()`.
68#[derive(Debug, Clone)]
69pub struct DeviceCode {
70    /// The secret code the daemon uses to poll — NEVER sent to the client.
71    pub device_code: String,
72    /// The short code the user enters at the verification URL.
73    pub user_code: String,
74    /// The URL the user visits to authorize.
75    pub verification_url: String,
76    /// Seconds the user has to authorize.
77    pub expires_in: u64,
78    /// Minimum seconds between polls.
79    pub interval: u64,
80}
81
82/// What the client receives from `/oauth/start` (no `device_code`).
83#[derive(Debug, Clone, Serialize)]
84#[serde(rename_all = "camelCase")]
85pub struct DeviceCodeResponse {
86    /// Opaque handle for `/oauth/poll`. Maps to the daemon-held `device_code`.
87    pub handle: String,
88    pub user_code: String,
89    pub verification_url: String,
90    pub expires_in: u64,
91}
92
93/// What `/oauth/poll` returns — a simple client-facing status. The token data
94/// never leaves the daemon (it's persisted internally by the broker).
95#[derive(Debug, Clone, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct PollResponse {
98    /// `pending` | `success` | `expired` | `denied`.
99    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
127// ─── GitHub provider ─────────────────────────────────────────────────────────
128
129/// GitHub OAuth device-code provider.
130///
131/// GitHub's device flow (https://docs.github.com/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow)
132/// uses a public `client_id` and returns non-expiring tokens (no refresh).
133pub struct GitHubProvider {
134    client: reqwest::Client,
135    /// Oxios's registered OAuth App client_id. Must be set to a real value for
136    /// production; GitHub device flow accepts any OAuth App's client_id.
137    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    /// Build with the default Oxios client_id (override via `with_client_id`).
145    pub fn new() -> Self {
146        Self {
147            client: reqwest::Client::new(),
148            // TODO(RFC-041 §18): ship Oxios's own registered OAuth App client_id.
149            // Until then this is a placeholder — set OXIOS_GITHUB_CLIENT_ID to
150            // use your own OAuth App.
151            client_id: std::env::var("OXIOS_GITHUB_CLIENT_ID")
152                .unwrap_or_else(|_| "OxiosOAuthAppPlaceholder".into()),
153        }
154    }
155
156    /// Override the client_id (tests / private deployments).
157    #[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            // GitHub device-flow tokens don't carry a refresh_token or expiry.
236            // The provider does NOT persist — it returns the token data and the
237            // broker saves it under the correct store_key (H1 fix).
238            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        // GitHub device-flow tokens don't expire — nothing to refresh.
257        anyhow::bail!("github device-flow tokens do not support refresh")
258    }
259
260    async fn revoke(&self, token: &str) -> Result<()> {
261        // GitHub revocation requires the client_secret (basic auth). Best-effort;
262        // local removal proceeds even if this fails. With a placeholder
263        // client_id this 401s, which is acceptable (the local token is still
264        // deleted by the caller).
265        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
279// ─── Broker — owns device_codes behind opaque handles (H1) ───────────────────
280
281/// A pending device-code flow, keyed by an opaque handle.
282struct PendingFlow {
283    provider_name: String,
284    device_code: String,
285    store_key: String,
286    expires_at: Instant,
287}
288
289/// The OAuth broker. Holds in-flight device-code flows in a transient,
290/// auto-expiring map keyed by opaque handles. `device_code` never leaves here.
291pub struct OAuthBroker {
292    providers: HashMap<String, Arc<dyn OAuthProvider>>,
293    flows: Mutex<HashMap<String, PendingFlow>>,
294}
295
296impl OAuthBroker {
297    /// Assemble with the default provider set (GitHub).
298    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    /// Start a device-code flow. Returns the user-facing data (no `device_code`).
316    /// The `device_code` is stored under a fresh opaque `handle`.
317    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    /// Poll a flow by handle. Removes the flow on a terminal outcome.
345    /// Per H1 the client never sees the `device_code`; the daemon polls the
346    /// provider using the stored secret.
347    pub async fn poll(&self, handle: &str) -> Result<PollResponse> {
348        let entry = {
349            let mut flows = self.flows.lock();
350            // Expire stale flows.
351            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        // On Success, the broker persists the token under the flow's store_key
372        // (H1 fix: not hardcoded, not discarded, uses CredentialStore for legacy
373        // migration). Error propagates so the UI doesn't show a false "Connected".
374        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                // Persist under the flow's store_key via CredentialStore (legacy
390                // migration). Error propagates — no false "Connected".
391                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    /// Revoke + remove a stored token for `store_key` (DELETE credential).
404    pub async fn revoke(&self, provider_name: &str, token: &str) -> Result<()> {
405        let provider = self.provider(provider_name)?;
406        // Best-effort revoke; local removal is the caller's responsibility.
407        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    /// A provider that never succeeds — used to test broker handle/expiry logic
423    /// without hitting the network.
424    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        // The response must NOT contain the device_code — only user_code + handle.
462        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        // expires_in=1s; wait for expiry.
483        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}