1use std::collections::HashMap;
29use std::sync::{Arc, Mutex, OnceLock};
30use std::time::{Duration, Instant};
31
32use base64::Engine as _;
33use base64::engine::general_purpose::URL_SAFE_NO_PAD;
34use sha2::{Digest, Sha256};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum OAuthFlow {
39 AuthCodePkce,
41 Device,
43}
44
45#[derive(Debug, Clone)]
48pub struct ServiceOAuthConfig {
49 pub provider: &'static str,
50 pub flow: OAuthFlow,
51 pub client_id: Option<&'static str>,
53 pub auth_url: &'static str,
54 pub token_url: &'static str,
55 pub device_url: Option<&'static str>,
57 pub scopes: &'static [&'static str],
58 pub requires_secret: bool,
61}
62
63pub fn config_for(provider: &str) -> Option<ServiceOAuthConfig> {
67 match provider {
68 "google" => Some(ServiceOAuthConfig {
69 provider: "google",
70 flow: OAuthFlow::AuthCodePkce,
71 client_id: None, auth_url: "https://accounts.google.com/o/oauth2/v2/auth",
73 token_url: "https://oauth2.googleapis.com/token",
74 device_url: None,
75 scopes: &["openid", "email", "profile"],
76 requires_secret: false,
77 }),
78 "microsoft" => Some(ServiceOAuthConfig {
79 provider: "microsoft",
80 flow: OAuthFlow::AuthCodePkce,
81 client_id: None, auth_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
83 token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
84 device_url: None,
85 scopes: &["openid", "profile", "email", "offline_access", "User.Read"],
86 requires_secret: false,
87 }),
88 "github" => Some(ServiceOAuthConfig {
89 provider: "github",
90 flow: OAuthFlow::Device, client_id: None, auth_url: "https://github.com/login/oauth/authorize",
93 token_url: "https://github.com/login/oauth/access_token",
94 device_url: Some("https://github.com/login/device/code"),
95 scopes: &["repo", "read:org", "user:email"],
96 requires_secret: false,
97 }),
98 "slack" => Some(ServiceOAuthConfig {
99 provider: "slack",
100 flow: OAuthFlow::AuthCodePkce,
101 client_id: None, auth_url: "https://slack.com/oauth/v2/authorize",
103 token_url: "https://slack.com/api/oauth.v2.access",
104 device_url: None,
105 scopes: &["users:read"],
106 requires_secret: true,
107 }),
108 _ => None,
109 }
110}
111
112pub fn supports_oauth(provider: &str) -> bool {
114 config_for(provider).is_some()
115}
116
117pub struct PkcePair {
121 pub verifier: String,
122 pub challenge: String,
123}
124
125pub fn pkce_pair() -> PkcePair {
129 let a = uuid::Uuid::new_v4();
130 let b = uuid::Uuid::new_v4();
131 let mut raw = [0u8; 32];
132 raw[..16].copy_from_slice(a.as_bytes());
133 raw[16..].copy_from_slice(b.as_bytes());
134 let verifier = URL_SAFE_NO_PAD.encode(raw);
135
136 let mut hasher = Sha256::new();
137 hasher.update(verifier.as_bytes());
138 let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
139
140 PkcePair { verifier, challenge }
141}
142
143pub fn random_state() -> String {
145 uuid::Uuid::new_v4().to_string()
146}
147
148const SESSION_TIMEOUT: Duration = Duration::from_secs(300);
151
152#[derive(Debug, Clone)]
154pub enum OAuthStatus {
155 Pending,
159 UrlAvailable { auth_url: String },
161 CodeEmitted { user_code: String, verification_uri: String },
163 Success { account_id: String },
165 Failed { error: String },
166}
167
168impl OAuthStatus {
169 pub fn is_terminal(&self) -> bool {
170 matches!(self, OAuthStatus::Success { .. } | OAuthStatus::Failed { .. })
171 }
172}
173
174struct OAuthSession {
175 status: OAuthStatus,
176 started_at: Instant,
177}
178
179#[derive(Default)]
180pub struct OAuthSessionManager {
181 sessions: Mutex<HashMap<String, OAuthSession>>,
182}
183
184const SESSION_RETENTION: Duration = Duration::from_secs(900);
187
188impl OAuthSessionManager {
189 fn new_session(&self, status: OAuthStatus) -> String {
190 let id = format!("oauth-{}", uuid::Uuid::new_v4());
191 let mut guard = self.sessions.lock().unwrap();
192 guard.retain(|_, s| s.started_at.elapsed() < SESSION_RETENTION);
195 guard.insert(id.clone(), OAuthSession { status, started_at: Instant::now() });
196 id
197 }
198
199 fn set_status(&self, id: &str, status: OAuthStatus) {
200 if let Some(s) = self.sessions.lock().unwrap().get_mut(id) {
201 s.status = status;
202 }
203 }
204
205 pub fn poll(&self, id: &str) -> Option<OAuthStatus> {
207 let mut guard = self.sessions.lock().unwrap();
208 let s = guard.get_mut(id)?;
209 if !s.status.is_terminal() && s.started_at.elapsed() > SESSION_TIMEOUT {
210 s.status = OAuthStatus::Failed { error: "authorization timed out".into() };
211 }
212 Some(s.status.clone())
213 }
214
215 pub fn cancel(&self, id: &str) -> bool {
216 let mut guard = self.sessions.lock().unwrap();
217 if let Some(s) = guard.get_mut(id) {
218 if !s.status.is_terminal() {
219 s.status = OAuthStatus::Failed { error: "cancelled".into() };
220 }
221 return true;
222 }
223 false
224 }
225}
226
227pub fn manager() -> &'static OAuthSessionManager {
231 static M: OnceLock<OAuthSessionManager> = OnceLock::new();
232 M.get_or_init(OAuthSessionManager::default)
233}
234
235#[derive(Debug, Clone)]
238pub struct ByoCredentials {
239 pub client_id: String,
240 pub client_secret: Option<String>,
241}
242
243pub fn resolve_client(
247 cfg: &ServiceOAuthConfig,
248 byo: Option<&ByoCredentials>,
249) -> Result<(String, Option<String>), String> {
250 let client_id = byo
251 .map(|b| b.client_id.clone())
252 .or_else(|| cfg.client_id.map(|s| s.to_string()))
253 .ok_or_else(|| {
254 format!(
255 "{} OAuth is not configured yet — no client id available. \
256 Supply your own OAuth app credentials (BYO) to continue.",
257 cfg.provider
258 )
259 })?;
260 if cfg.requires_secret && byo.and_then(|b| b.client_secret.as_ref()).is_none() {
261 return Err(format!(
262 "{} requires a client secret — register your own OAuth app and \
263 supply its client id + secret (BYO).",
264 cfg.provider
265 ));
266 }
267 Ok((client_id, byo.and_then(|b| b.client_secret.clone())))
268}
269
270use crate::backend::storage::store::{IdentityAccount, SecretRef, Store};
273
274fn http() -> &'static reqwest::Client {
275 static C: OnceLock<reqwest::Client> = OnceLock::new();
276 C.get_or_init(|| {
277 reqwest::Client::builder()
278 .timeout(Duration::from_secs(20))
279 .user_agent("AgentMux")
280 .build()
281 .expect("reqwest client build failed")
282 })
283}
284
285fn open_browser(url: &str) {
286 #[cfg(target_os = "windows")]
287 {
288 let _ = std::process::Command::new("cmd").args(["/C", "start", "", url]).spawn();
289 }
290 #[cfg(target_os = "macos")]
291 {
292 let _ = std::process::Command::new("open").arg(url).spawn();
293 }
294 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
295 {
296 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
297 }
298}
299
300fn token_blob(json: &serde_json::Value, now: i64) -> serde_json::Value {
303 let expires_in = json.get("expires_in").and_then(|v| v.as_i64()).unwrap_or(0);
304 serde_json::json!({
305 "access_token": json.get("access_token").and_then(|v| v.as_str()).unwrap_or(""),
306 "refresh_token": json.get("refresh_token").and_then(|v| v.as_str()),
307 "expires_at": if expires_in > 0 { now + expires_in } else { 0 },
308 })
309}
310
311fn persist_oauth_account(
314 wstore: &Arc<Store>,
315 provider: &str,
316 name: &str,
317 tokens: &serde_json::Value,
318) -> Result<String, String> {
319 let account_id = uuid::Uuid::new_v4().to_string();
320 let blob = serde_json::to_string(tokens).map_err(|e| e.to_string())?;
321 crate::identity::secret_store::put(&account_id, &blob)?;
322 let now = chrono::Utc::now().timestamp_millis();
323 let account = IdentityAccount {
324 id: account_id.clone(),
325 name: name.to_string(),
326 provider: provider.to_string(),
327 kind: "oauth".to_string(),
328 display_name: String::new(),
329 secret_ref: SecretRef::Keychain {
330 service: crate::identity::secret_store::SERVICE.to_string(),
331 account: crate::identity::secret_store::account_key(&account_id),
332 },
333 context: serde_json::json!({ "oauth": true }),
334 status: "valid".to_string(),
335 created_at: now,
336 updated_at: now,
337 };
338 if let Err(e) = wstore.identity_upsert(&account) {
339 let _ = crate::identity::secret_store::delete(&account_id);
340 return Err(format!("persist failed: {e}"));
341 }
342 Ok(account_id)
343}
344
345fn enc(s: &str) -> String {
347 let mut out = String::with_capacity(s.len());
348 for b in s.bytes() {
349 match b {
350 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
351 _ => out.push_str(&format!("%{b:02X}")),
352 }
353 }
354 out
355}
356
357pub fn start(
361 provider: &str,
362 name: String,
363 byo: Option<ByoCredentials>,
364 wstore: Arc<Store>,
365) -> Result<(String, OAuthStatus), String> {
366 let cfg = config_for(provider).ok_or_else(|| format!("unknown OAuth provider: {provider}"))?;
367 let (client_id, client_secret) = resolve_client(&cfg, byo.as_ref())?;
368
369 match cfg.flow {
370 OAuthFlow::AuthCodePkce => start_code_flow(cfg, client_id, client_secret, name, wstore),
371 OAuthFlow::Device => start_device_flow(cfg, client_id, name, wstore),
372 }
373}
374
375fn start_code_flow(
376 cfg: ServiceOAuthConfig,
377 client_id: String,
378 client_secret: Option<String>,
379 name: String,
380 wstore: Arc<Store>,
381) -> Result<(String, OAuthStatus), String> {
382 use tokio::io::{AsyncReadExt, AsyncWriteExt};
383 use tokio::net::TcpListener;
384
385 let pkce = pkce_pair();
386 let state = random_state();
387 let scopes = cfg.scopes.join(" ");
388
389 let session_id = manager().new_session(OAuthStatus::Pending);
390 let sid = session_id.clone();
391
392 tokio::spawn(async move {
393 let listener = match TcpListener::bind("127.0.0.1:0").await {
396 Ok(l) => l,
397 Err(e) => {
398 manager().set_status(&sid, OAuthStatus::Failed { error: format!("bind failed: {e}") });
399 return;
400 }
401 };
402 let port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
403 let redirect_uri = format!("http://127.0.0.1:{port}/callback");
404
405 let auth_url = format!(
406 "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
407 cfg.auth_url,
408 enc(&client_id),
409 enc(&redirect_uri),
410 enc(&scopes),
411 enc(&state),
412 enc(&pkce.challenge),
413 );
414 manager().set_status(&sid, OAuthStatus::UrlAvailable { auth_url: auth_url.clone() });
415 open_browser(&auth_url);
416
417 let accept = tokio::time::timeout(SESSION_TIMEOUT, listener.accept()).await;
420 let mut stream = match accept {
421 Ok(Ok((s, _))) => s,
422 _ => {
423 manager().set_status(&sid, OAuthStatus::Failed { error: "no callback received".into() });
424 return;
425 }
426 };
427 let mut buf = [0u8; 4096];
428 let n = stream.read(&mut buf).await.unwrap_or(0);
429 let req = String::from_utf8_lossy(&buf[..n]);
430 let (code, got_state) = parse_callback(&req);
431 let _ = stream
432 .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body>You can close this tab and return to AgentMux.</body></html>")
433 .await;
434
435 if got_state.as_deref() != Some(state.as_str()) {
437 manager().set_status(&sid, OAuthStatus::Failed { error: "state mismatch — possible CSRF, aborted".into() });
438 return;
439 }
440 let code = match code {
441 Some(c) => c,
442 None => {
443 manager().set_status(&sid, OAuthStatus::Failed { error: "no authorization code in callback".into() });
444 return;
445 }
446 };
447
448 let mut params = vec![
450 ("grant_type", "authorization_code".to_string()),
451 ("client_id", client_id.clone()),
452 ("code", code),
453 ("redirect_uri", redirect_uri),
454 ("code_verifier", pkce.verifier.clone()),
455 ];
456 if let Some(secret) = client_secret.as_ref() {
457 params.push(("client_secret", secret.clone()));
458 }
459 match exchange(cfg.token_url, ¶ms).await {
460 Ok(json) => {
461 let now = chrono::Utc::now().timestamp();
462 let tokens = token_blob(&json, now);
463 match persist_oauth_account(&wstore, cfg.provider, &name, &tokens) {
464 Ok(account_id) => manager().set_status(&sid, OAuthStatus::Success { account_id }),
465 Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
466 }
467 }
468 Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
469 }
470 });
471
472 Ok((session_id, OAuthStatus::UrlAvailable { auth_url: String::new() }))
473}
474
475fn start_device_flow(
476 cfg: ServiceOAuthConfig,
477 client_id: String,
478 name: String,
479 wstore: Arc<Store>,
480) -> Result<(String, OAuthStatus), String> {
481 let device_url = cfg.device_url.ok_or("provider has no device endpoint")?.to_string();
482 let scopes = cfg.scopes.join(" ");
483 let session_id = manager().new_session(OAuthStatus::Pending);
484 let sid = session_id.clone();
485
486 tokio::spawn(async move {
487 let params = [("client_id", client_id.clone()), ("scope", scopes)];
489 let resp = match http().post(&device_url).header("Accept", "application/json").form(¶ms).send().await {
490 Ok(r) => r,
491 Err(e) => {
492 manager().set_status(&sid, OAuthStatus::Failed { error: format!("device request failed: {e}") });
493 return;
494 }
495 };
496 let json: serde_json::Value = resp.json().await.unwrap_or_else(|_| serde_json::json!({}));
497 let device_code = json.get("device_code").and_then(|v| v.as_str()).unwrap_or("").to_string();
498 let user_code = json.get("user_code").and_then(|v| v.as_str()).unwrap_or("").to_string();
499 let verification_uri = json
500 .get("verification_uri")
501 .and_then(|v| v.as_str())
502 .unwrap_or("https://github.com/login/device")
503 .to_string();
504 let mut interval = json.get("interval").and_then(|v| v.as_u64()).unwrap_or(5);
505 if device_code.is_empty() {
506 manager().set_status(&sid, OAuthStatus::Failed { error: "no device_code in response".into() });
507 return;
508 }
509 manager().set_status(&sid, OAuthStatus::CodeEmitted { user_code, verification_uri });
510
511 loop {
513 tokio::time::sleep(Duration::from_secs(interval)).await;
514 match manager().poll(&sid) {
516 Some(s) if s.is_terminal() => return,
517 None => return,
518 _ => {}
519 }
520 let p = [
521 ("client_id", client_id.clone()),
522 ("device_code", device_code.clone()),
523 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code".to_string()),
524 ];
525 let r = match http().post(cfg.token_url).header("Accept", "application/json").form(&p).send().await {
526 Ok(r) => r,
527 Err(e) => {
528 manager().set_status(&sid, OAuthStatus::Failed { error: format!("poll failed: {e}") });
529 return;
530 }
531 };
532 let j: serde_json::Value = r.json().await.unwrap_or_else(|_| serde_json::json!({}));
533 if j.get("access_token").is_some() {
534 let now = chrono::Utc::now().timestamp();
535 let tokens = token_blob(&j, now);
536 match persist_oauth_account(&wstore, cfg.provider, &name, &tokens) {
537 Ok(account_id) => manager().set_status(&sid, OAuthStatus::Success { account_id }),
538 Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
539 }
540 return;
541 }
542 match j.get("error").and_then(|v| v.as_str()) {
543 Some("authorization_pending") => {}
544 Some("slow_down") => interval += 5, Some("access_denied") => {
546 manager().set_status(&sid, OAuthStatus::Failed { error: "access denied".into() });
547 return;
548 }
549 Some("expired_token") | Some(_) => {
550 manager().set_status(&sid, OAuthStatus::Failed { error: "device code expired".into() });
551 return;
552 }
553 None => {}
554 }
555 }
556 });
557
558 Ok((session_id, OAuthStatus::CodeEmitted { user_code: String::new(), verification_uri: String::new() }))
559}
560
561async fn exchange(token_url: &str, params: &[(&str, String)]) -> Result<serde_json::Value, String> {
562 let resp = http()
563 .post(token_url)
564 .header("Accept", "application/json")
565 .form(params)
566 .send()
567 .await
568 .map_err(|e| format!("token exchange failed: {e}"))?;
569 if !resp.status().is_success() {
570 return Err(format!("token endpoint returned {}", resp.status()));
571 }
572 resp.json().await.map_err(|e| format!("token response parse failed: {e}"))
573}
574
575fn parse_callback(req: &str) -> (Option<String>, Option<String>) {
578 let first = req.lines().next().unwrap_or("");
579 let path = first.split_whitespace().nth(1).unwrap_or("");
580 let query = path.split('?').nth(1).unwrap_or("");
581 let mut code = None;
582 let mut state = None;
583 for pair in query.split('&') {
584 let mut it = pair.splitn(2, '=');
585 match (it.next(), it.next()) {
586 (Some("code"), Some(v)) => code = Some(percent_decode(v)),
587 (Some("state"), Some(v)) => state = Some(percent_decode(v)),
588 _ => {}
589 }
590 }
591 (code, state)
592}
593
594fn percent_decode(s: &str) -> String {
595 let bytes = s.replace('+', " ");
596 let bytes = bytes.as_bytes();
597 let mut out = Vec::with_capacity(bytes.len());
598 let mut i = 0;
599 while i < bytes.len() {
600 if bytes[i] == b'%' && i + 2 < bytes.len() {
601 if let Ok(b) = u8::from_str_radix(&String::from_utf8_lossy(&bytes[i + 1..i + 3]), 16) {
602 out.push(b);
603 i += 3;
604 continue;
605 }
606 }
607 out.push(bytes[i]);
608 i += 1;
609 }
610 String::from_utf8_lossy(&out).to_string()
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 #[test]
618 fn pkce_challenge_is_s256_of_verifier() {
619 let p = pkce_pair();
620 let mut h = Sha256::new();
622 h.update(p.verifier.as_bytes());
623 let expected = URL_SAFE_NO_PAD.encode(h.finalize());
624 assert_eq!(p.challenge, expected);
625 assert!(!p.challenge.contains('='));
627 assert!(!p.challenge.contains('+'));
628 assert!(!p.challenge.contains('/'));
629 assert!(p.verifier.len() >= 43 && p.verifier.len() <= 128);
631 }
632
633 #[test]
634 fn verifiers_are_unique() {
635 assert_ne!(pkce_pair().verifier, pkce_pair().verifier);
636 assert_ne!(random_state(), random_state());
637 }
638
639 #[test]
640 fn catalog_flow_choices() {
641 assert_eq!(config_for("github").unwrap().flow, OAuthFlow::Device);
642 assert_eq!(config_for("google").unwrap().flow, OAuthFlow::AuthCodePkce);
643 assert!(config_for("slack").unwrap().requires_secret);
644 assert!(!config_for("google").unwrap().requires_secret);
645 assert!(config_for("nope").is_none());
646 assert!(supports_oauth("microsoft"));
647 }
648
649 #[test]
650 fn unconfigured_provider_is_gated() {
651 let cfg = config_for("google").unwrap();
652 assert!(resolve_client(&cfg, None).is_err());
654 let byo = ByoCredentials { client_id: "abc".into(), client_secret: None };
656 assert_eq!(resolve_client(&cfg, Some(&byo)).unwrap().0, "abc");
657 }
658
659 #[test]
660 fn parses_code_and_state_from_callback() {
661 let req = "GET /callback?code=abc123&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
662 let (code, state) = parse_callback(req);
663 assert_eq!(code.as_deref(), Some("abc123"));
664 assert_eq!(state.as_deref(), Some("xyz"));
665 }
666
667 #[test]
668 fn percent_decodes_callback_values() {
669 let req = "GET /callback?code=a%2Fb%2Bc&state=s HTTP/1.1\r\n\r\n";
670 let (code, _) = parse_callback(req);
671 assert_eq!(code.as_deref(), Some("a/b+c"));
672 }
673
674 #[test]
675 fn secret_mandatory_provider_requires_byo_secret() {
676 let cfg = config_for("slack").unwrap();
677 let id_only = ByoCredentials { client_id: "abc".into(), client_secret: None };
678 assert!(resolve_client(&cfg, Some(&id_only)).is_err());
679 let with_secret = ByoCredentials {
680 client_id: "abc".into(),
681 client_secret: Some("shh".into()),
682 };
683 assert!(resolve_client(&cfg, Some(&with_secret)).is_ok());
684 }
685}