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. Returns the new `TokenBundle` so the
60    /// broker can persist the full bundle (rotated `refresh_token`,
61    /// fresh `expires_in`, scope changes). Static-key providers leave
62    /// this unimplemented.
63    async fn refresh(&self, refresh_token: &str) -> Result<oxi_sdk::TokenBundle>;
64
65    /// Revoke a token at the provider (best-effort).
66    async fn revoke(&self, token: &str) -> Result<()>;
67}
68
69/// Device-authorization response from `start()`.
70#[derive(Debug, Clone)]
71pub struct DeviceCode {
72    /// The secret code the daemon uses to poll — NEVER sent to the client.
73    pub device_code: String,
74    /// The short code the user enters at the verification URL.
75    pub user_code: String,
76    /// The URL the user visits to authorize.
77    pub verification_url: String,
78    /// Seconds the user has to authorize.
79    pub expires_in: u64,
80    /// Minimum seconds between polls.
81    pub interval: u64,
82}
83
84/// What the client receives from `/oauth/start` (no `device_code`).
85#[derive(Debug, Clone, Serialize)]
86#[serde(rename_all = "camelCase")]
87pub struct DeviceCodeResponse {
88    /// Opaque handle for `/oauth/poll`. Maps to the daemon-held `device_code`.
89    pub handle: String,
90    pub user_code: String,
91    pub verification_url: String,
92    pub expires_in: u64,
93}
94
95/// What `/oauth/poll` returns — a simple client-facing status. The token data
96/// never leaves the daemon (it's persisted internally by the broker).
97#[derive(Debug, Clone, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct PollResponse {
100    /// `pending` | `success` | `expired` | `denied`.
101    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
129// ─── GitHub provider ─────────────────────────────────────────────────────────
130
131/// GitHub OAuth device-code provider.
132///
133/// GitHub's device flow (https://docs.github.com/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow)
134/// uses a public `client_id` and returns non-expiring tokens (no refresh).
135pub struct GitHubProvider {
136    client: reqwest::Client,
137    /// Oxios's registered OAuth App client_id. Must be set to a real value for
138    /// production; GitHub device flow accepts any OAuth App's client_id.
139    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    /// Build with the default Oxios client_id (override via `with_client_id`).
147    pub fn new() -> Self {
148        Self {
149            client: reqwest::Client::new(),
150            // TODO(RFC-041 §18): ship Oxios's own registered OAuth App client_id.
151            // Until then this is a placeholder — set OXIOS_GITHUB_CLIENT_ID to
152            // use your own OAuth App.
153            client_id: std::env::var("OXIOS_GITHUB_CLIENT_ID")
154                .unwrap_or_else(|_| "OxiosOAuthAppPlaceholder".into()),
155        }
156    }
157
158    /// Override the client_id (tests / private deployments).
159    #[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            // GitHub device-flow tokens don't carry a refresh_token or expiry.
238            // The provider does NOT persist — it returns the token data and the
239            // broker saves it under the correct store_key (H1 fix).
240            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        // GitHub device-flow tokens don't expire — nothing to refresh.
259        anyhow::bail!("github device-flow tokens do not support refresh")
260    }
261
262    async fn revoke(&self, token: &str) -> Result<()> {
263        // GitHub revocation requires the client_secret (basic auth). Best-effort;
264        // local removal proceeds even if this fails. With a placeholder
265        // client_id this 401s, which is acceptable (the local token is still
266        // deleted by the caller).
267        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
281// ─── Broker — owns device_codes behind opaque handles (H1) ───────────────────
282
283/// A pending device-code flow, keyed by an opaque handle.
284struct PendingFlow {
285    provider_name: String,
286    device_code: String,
287    store_key: String,
288    expires_at: Instant,
289}
290
291/// The OAuth broker. Holds in-flight device-code flows in a transient,
292/// auto-expiring map keyed by opaque handles. `device_code` never leaves here.
293pub struct OAuthBroker {
294    providers: HashMap<String, Arc<dyn OAuthProvider>>,
295    flows: Mutex<HashMap<String, PendingFlow>>,
296}
297
298impl OAuthBroker {
299    /// Assemble with the default provider set (GitHub).
300    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    /// Start a device-code flow. Returns the user-facing data (no `device_code`).
318    /// The `device_code` is stored under a fresh opaque `handle`.
319    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    /// Poll a flow by handle. Removes the flow on a terminal outcome.
347    /// Per H1 the client never sees the `device_code`; the daemon polls the
348    /// provider using the stored secret.
349    pub async fn poll(&self, handle: &str) -> Result<PollResponse> {
350        let entry = {
351            let mut flows = self.flows.lock();
352            // Expire stale flows.
353            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        // On Success, the broker persists the token under the flow's store_key
374        // (H1 fix: not hardcoded, not discarded, uses CredentialStore for legacy
375        // migration). Error propagates so the UI doesn't show a false "Connected".
376        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                // Persist under the flow's store_key via CredentialStore (legacy
392                // migration). Error propagates — no false "Connected".
393                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    /// Revoke + remove a stored token for `store_key` (DELETE credential).
406    pub async fn revoke(&self, provider_name: &str, token: &str) -> Result<()> {
407        let provider = self.provider(provider_name)?;
408        // Best-effort revoke; local removal is the caller's responsibility.
409        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    /// A provider that never succeeds — used to test broker handle/expiry logic
425    /// without hitting the network.
426    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        // The response must NOT contain the device_code — only user_code + handle.
464        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        // expires_in=1s; wait for expiry.
485        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}