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 struct PkcePair {
116 pub verifier: String,
117 pub challenge: String,
118}
119
120pub fn pkce_pair() -> PkcePair {
124 let a = uuid::Uuid::new_v4();
125 let b = uuid::Uuid::new_v4();
126 let mut raw = [0u8; 32];
127 raw[..16].copy_from_slice(a.as_bytes());
128 raw[16..].copy_from_slice(b.as_bytes());
129 let verifier = URL_SAFE_NO_PAD.encode(raw);
130
131 let mut hasher = Sha256::new();
132 hasher.update(verifier.as_bytes());
133 let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
134
135 PkcePair { verifier, challenge }
136}
137
138pub fn random_state() -> String {
140 uuid::Uuid::new_v4().to_string()
141}
142
143const SESSION_TIMEOUT: Duration = Duration::from_secs(300);
146
147#[derive(Debug, Clone)]
149pub enum OAuthStatus {
150 Pending,
154 UrlAvailable { auth_url: String },
156 CodeEmitted { user_code: String, verification_uri: String },
158 Success { account_id: String },
160 Failed { error: String },
161}
162
163impl OAuthStatus {
164 pub fn is_terminal(&self) -> bool {
165 matches!(self, OAuthStatus::Success { .. } | OAuthStatus::Failed { .. })
166 }
167}
168
169struct OAuthSession {
170 status: OAuthStatus,
171 started_at: Instant,
172}
173
174#[derive(Default)]
175pub struct OAuthSessionManager {
176 sessions: Mutex<HashMap<String, OAuthSession>>,
177}
178
179const SESSION_RETENTION: Duration = Duration::from_secs(900);
182
183impl OAuthSessionManager {
184 fn new_session(&self, status: OAuthStatus) -> String {
185 let id = format!("oauth-{}", uuid::Uuid::new_v4());
186 let mut guard = self.sessions.lock().unwrap();
187 guard.retain(|_, s| s.started_at.elapsed() < SESSION_RETENTION);
190 guard.insert(id.clone(), OAuthSession { status, started_at: Instant::now() });
191 id
192 }
193
194 fn set_status(&self, id: &str, status: OAuthStatus) {
195 if let Some(s) = self.sessions.lock().unwrap().get_mut(id) {
196 s.status = status;
197 }
198 }
199
200 pub fn poll(&self, id: &str) -> Option<OAuthStatus> {
202 let mut guard = self.sessions.lock().unwrap();
203 let s = guard.get_mut(id)?;
204 if !s.status.is_terminal() && s.started_at.elapsed() > SESSION_TIMEOUT {
205 s.status = OAuthStatus::Failed { error: "authorization timed out".into() };
206 }
207 Some(s.status.clone())
208 }
209
210 pub fn cancel(&self, id: &str) -> bool {
211 let mut guard = self.sessions.lock().unwrap();
212 if let Some(s) = guard.get_mut(id) {
213 if !s.status.is_terminal() {
214 s.status = OAuthStatus::Failed { error: "cancelled".into() };
215 }
216 return true;
217 }
218 false
219 }
220}
221
222pub fn manager() -> &'static OAuthSessionManager {
226 static M: OnceLock<OAuthSessionManager> = OnceLock::new();
227 M.get_or_init(OAuthSessionManager::default)
228}
229
230#[derive(Debug, Clone)]
233pub struct ByoCredentials {
234 pub client_id: String,
235 pub client_secret: Option<String>,
236}
237
238pub fn resolve_client(
242 cfg: &ServiceOAuthConfig,
243 byo: Option<&ByoCredentials>,
244) -> Result<(String, Option<String>), String> {
245 let client_id = byo
246 .map(|b| b.client_id.clone())
247 .or_else(|| cfg.client_id.map(|s| s.to_string()))
248 .ok_or_else(|| {
249 format!(
250 "{} OAuth is not configured yet — no client id available. \
251 Supply your own OAuth app credentials (BYO) to continue.",
252 cfg.provider
253 )
254 })?;
255 if cfg.requires_secret && byo.and_then(|b| b.client_secret.as_ref()).is_none() {
256 return Err(format!(
257 "{} requires a client secret — register your own OAuth app and \
258 supply its client id + secret (BYO).",
259 cfg.provider
260 ));
261 }
262 Ok((client_id, byo.and_then(|b| b.client_secret.clone())))
263}
264
265use crate::backend::storage::store::{IdentityAccount, SecretRef, Store};
268use crate::util::open_browser;
269
270fn http() -> &'static reqwest::Client {
271 static C: OnceLock<reqwest::Client> = OnceLock::new();
272 C.get_or_init(|| {
273 reqwest::Client::builder()
274 .timeout(Duration::from_secs(20))
275 .user_agent("AgentMux")
276 .build()
277 .expect("reqwest client build failed")
278 })
279}
280
281fn token_blob(json: &serde_json::Value, now: i64) -> serde_json::Value {
284 let expires_in = json.get("expires_in").and_then(|v| v.as_i64()).unwrap_or(0);
285 serde_json::json!({
286 "access_token": json.get("access_token").and_then(|v| v.as_str()).unwrap_or(""),
287 "refresh_token": json.get("refresh_token").and_then(|v| v.as_str()),
288 "expires_at": if expires_in > 0 { now + expires_in } else { 0 },
289 })
290}
291
292fn persist_oauth_account(
295 mstore: &Arc<Store>,
296 identity_store: &Arc<Store>,
297 provider: &str,
298 name: &str,
299 tokens: &serde_json::Value,
300) -> Result<String, String> {
301 let account_id = uuid::Uuid::new_v4().to_string();
302 let blob = serde_json::to_string(tokens).map_err(|e| e.to_string())?;
303 crate::identity::secret_store::put(&account_id, &blob)?;
304 let now = chrono::Utc::now().timestamp_millis();
305 let account = IdentityAccount {
306 id: account_id.clone(),
307 name: name.to_string(),
308 provider: provider.to_string(),
309 kind: "oauth".to_string(),
310 display_name: String::new(),
311 secret_ref: SecretRef::Keychain {
312 service: crate::identity::secret_store::SERVICE.to_string(),
313 account: crate::identity::secret_store::account_key(&account_id),
314 },
315 context: serde_json::json!({ "oauth": true }),
316 status: "valid".to_string(),
317 created_at: now,
318 updated_at: now,
319 };
320 if let Err(e) = mstore.identity_upsert_with_mirror(identity_store, &account) {
321 let _ = crate::identity::secret_store::delete(&account_id);
322 return Err(format!("persist failed: {e}"));
323 }
324 Ok(account_id)
325}
326
327fn enc(s: &str) -> String {
329 let mut out = String::with_capacity(s.len());
330 for b in s.bytes() {
331 match b {
332 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
333 _ => out.push_str(&format!("%{b:02X}")),
334 }
335 }
336 out
337}
338
339pub fn start(
343 provider: &str,
344 name: String,
345 byo: Option<ByoCredentials>,
346 mstore: Arc<Store>,
347 identity_store: Arc<Store>,
348) -> Result<(String, OAuthStatus), String> {
349 let cfg = config_for(provider).ok_or_else(|| format!("unknown OAuth provider: {provider}"))?;
350 let (client_id, client_secret) = resolve_client(&cfg, byo.as_ref())?;
351
352 match cfg.flow {
353 OAuthFlow::AuthCodePkce => start_code_flow(cfg, client_id, client_secret, name, mstore, identity_store),
354 OAuthFlow::Device => start_device_flow(cfg, client_id, name, mstore, identity_store),
355 }
356}
357
358fn start_code_flow(
359 cfg: ServiceOAuthConfig,
360 client_id: String,
361 client_secret: Option<String>,
362 name: String,
363 mstore: Arc<Store>,
364 identity_store: Arc<Store>,
365) -> Result<(String, OAuthStatus), String> {
366 use tokio::io::{AsyncReadExt, AsyncWriteExt};
367 use tokio::net::TcpListener;
368
369 let pkce = pkce_pair();
370 let state = random_state();
371 let scopes = cfg.scopes.join(" ");
372
373 let session_id = manager().new_session(OAuthStatus::Pending);
374 let sid = session_id.clone();
375
376 tokio::spawn(async move {
377 let listener = match TcpListener::bind("127.0.0.1:0").await {
380 Ok(l) => l,
381 Err(e) => {
382 manager().set_status(&sid, OAuthStatus::Failed { error: format!("bind failed: {e}") });
383 return;
384 }
385 };
386 let port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
387 let redirect_uri = format!("http://127.0.0.1:{port}/callback");
388
389 let auth_url = format!(
390 "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
391 cfg.auth_url,
392 enc(&client_id),
393 enc(&redirect_uri),
394 enc(&scopes),
395 enc(&state),
396 enc(&pkce.challenge),
397 );
398 manager().set_status(&sid, OAuthStatus::UrlAvailable { auth_url: auth_url.clone() });
399 open_browser(&auth_url);
400
401 let accept = tokio::time::timeout(SESSION_TIMEOUT, listener.accept()).await;
404 let mut stream = match accept {
405 Ok(Ok((s, _))) => s,
406 _ => {
407 manager().set_status(&sid, OAuthStatus::Failed { error: "no callback received".into() });
408 return;
409 }
410 };
411 let mut buf = [0u8; 4096];
412 let n = stream.read(&mut buf).await.unwrap_or(0);
413 let req = String::from_utf8_lossy(&buf[..n]);
414 let (code, got_state) = parse_callback(&req);
415 let _ = stream
416 .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>")
417 .await;
418
419 if got_state.as_deref() != Some(state.as_str()) {
421 manager().set_status(&sid, OAuthStatus::Failed { error: "state mismatch — possible CSRF, aborted".into() });
422 return;
423 }
424 let code = match code {
425 Some(c) => c,
426 None => {
427 manager().set_status(&sid, OAuthStatus::Failed { error: "no authorization code in callback".into() });
428 return;
429 }
430 };
431
432 let mut params = vec![
434 ("grant_type", "authorization_code".to_string()),
435 ("client_id", client_id.clone()),
436 ("code", code),
437 ("redirect_uri", redirect_uri),
438 ("code_verifier", pkce.verifier.clone()),
439 ];
440 if let Some(secret) = client_secret.as_ref() {
441 params.push(("client_secret", secret.clone()));
442 }
443 match exchange(cfg.token_url, ¶ms).await {
444 Ok(json) => {
445 let now = chrono::Utc::now().timestamp();
446 let tokens = token_blob(&json, now);
447 match persist_oauth_account(&mstore, &identity_store, cfg.provider, &name, &tokens) {
448 Ok(account_id) => manager().set_status(&sid, OAuthStatus::Success { account_id }),
449 Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
450 }
451 }
452 Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
453 }
454 });
455
456 Ok((session_id, OAuthStatus::UrlAvailable { auth_url: String::new() }))
457}
458
459fn start_device_flow(
460 cfg: ServiceOAuthConfig,
461 client_id: String,
462 name: String,
463 mstore: Arc<Store>,
464 identity_store: Arc<Store>,
465) -> Result<(String, OAuthStatus), String> {
466 let device_url = cfg.device_url.ok_or("provider has no device endpoint")?.to_string();
467 let scopes = cfg.scopes.join(" ");
468 let session_id = manager().new_session(OAuthStatus::Pending);
469 let sid = session_id.clone();
470
471 tokio::spawn(async move {
472 let params = [("client_id", client_id.clone()), ("scope", scopes)];
474 let resp = match http().post(&device_url).header("Accept", "application/json").form(¶ms).send().await {
475 Ok(r) => r,
476 Err(e) => {
477 manager().set_status(&sid, OAuthStatus::Failed { error: format!("device request failed: {e}") });
478 return;
479 }
480 };
481 let json: serde_json::Value = resp.json().await.unwrap_or_else(|_| serde_json::json!({}));
482 let device_code = json.get("device_code").and_then(|v| v.as_str()).unwrap_or("").to_string();
483 let user_code = json.get("user_code").and_then(|v| v.as_str()).unwrap_or("").to_string();
484 let verification_uri = json
485 .get("verification_uri")
486 .and_then(|v| v.as_str())
487 .unwrap_or("https://github.com/login/device")
488 .to_string();
489 let mut interval = json.get("interval").and_then(|v| v.as_u64()).unwrap_or(5);
490 if device_code.is_empty() {
491 manager().set_status(&sid, OAuthStatus::Failed { error: "no device_code in response".into() });
492 return;
493 }
494 manager().set_status(&sid, OAuthStatus::CodeEmitted { user_code, verification_uri });
495
496 loop {
498 tokio::time::sleep(Duration::from_secs(interval)).await;
499 match manager().poll(&sid) {
501 Some(s) if s.is_terminal() => return,
502 None => return,
503 _ => {}
504 }
505 let p = [
506 ("client_id", client_id.clone()),
507 ("device_code", device_code.clone()),
508 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code".to_string()),
509 ];
510 let r = match http().post(cfg.token_url).header("Accept", "application/json").form(&p).send().await {
511 Ok(r) => r,
512 Err(e) => {
513 manager().set_status(&sid, OAuthStatus::Failed { error: format!("poll failed: {e}") });
514 return;
515 }
516 };
517 let j: serde_json::Value = r.json().await.unwrap_or_else(|_| serde_json::json!({}));
518 if j.get("access_token").is_some() {
519 let now = chrono::Utc::now().timestamp();
520 let tokens = token_blob(&j, now);
521 match persist_oauth_account(&mstore, &identity_store, cfg.provider, &name, &tokens) {
522 Ok(account_id) => manager().set_status(&sid, OAuthStatus::Success { account_id }),
523 Err(e) => manager().set_status(&sid, OAuthStatus::Failed { error: e }),
524 }
525 return;
526 }
527 match j.get("error").and_then(|v| v.as_str()) {
528 Some("authorization_pending") => {}
529 Some("slow_down") => interval += 5, Some("access_denied") => {
531 manager().set_status(&sid, OAuthStatus::Failed { error: "access denied".into() });
532 return;
533 }
534 Some("expired_token") | Some(_) => {
535 manager().set_status(&sid, OAuthStatus::Failed { error: "device code expired".into() });
536 return;
537 }
538 None => {}
539 }
540 }
541 });
542
543 Ok((session_id, OAuthStatus::CodeEmitted { user_code: String::new(), verification_uri: String::new() }))
544}
545
546async fn exchange(token_url: &str, params: &[(&str, String)]) -> Result<serde_json::Value, String> {
547 let resp = http()
548 .post(token_url)
549 .header("Accept", "application/json")
550 .form(params)
551 .send()
552 .await
553 .map_err(|e| format!("token exchange failed: {e}"))?;
554 if !resp.status().is_success() {
555 return Err(format!("token endpoint returned {}", resp.status()));
556 }
557 resp.json().await.map_err(|e| format!("token response parse failed: {e}"))
558}
559
560fn parse_callback(req: &str) -> (Option<String>, Option<String>) {
563 let first = req.lines().next().unwrap_or("");
564 let path = first.split_whitespace().nth(1).unwrap_or("");
565 let query = path.split('?').nth(1).unwrap_or("");
566 let mut code = None;
567 let mut state = None;
568 for pair in query.split('&') {
569 let mut it = pair.splitn(2, '=');
570 match (it.next(), it.next()) {
571 (Some("code"), Some(v)) => code = Some(percent_decode(v)),
572 (Some("state"), Some(v)) => state = Some(percent_decode(v)),
573 _ => {}
574 }
575 }
576 (code, state)
577}
578
579fn percent_decode(s: &str) -> String {
580 let bytes = s.replace('+', " ");
581 let bytes = bytes.as_bytes();
582 let mut out = Vec::with_capacity(bytes.len());
583 let mut i = 0;
584 while i < bytes.len() {
585 if bytes[i] == b'%' && i + 2 < bytes.len() {
586 if let Ok(b) = u8::from_str_radix(&String::from_utf8_lossy(&bytes[i + 1..i + 3]), 16) {
587 out.push(b);
588 i += 3;
589 continue;
590 }
591 }
592 out.push(bytes[i]);
593 i += 1;
594 }
595 String::from_utf8_lossy(&out).to_string()
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn pkce_challenge_is_s256_of_verifier() {
604 let p = pkce_pair();
605 let mut h = Sha256::new();
607 h.update(p.verifier.as_bytes());
608 let expected = URL_SAFE_NO_PAD.encode(h.finalize());
609 assert_eq!(p.challenge, expected);
610 assert!(!p.challenge.contains('='));
612 assert!(!p.challenge.contains('+'));
613 assert!(!p.challenge.contains('/'));
614 assert!(p.verifier.len() >= 43 && p.verifier.len() <= 128);
616 }
617
618 #[test]
619 fn verifiers_are_unique() {
620 assert_ne!(pkce_pair().verifier, pkce_pair().verifier);
621 assert_ne!(random_state(), random_state());
622 }
623
624 #[test]
625 fn catalog_flow_choices() {
626 assert_eq!(config_for("github").unwrap().flow, OAuthFlow::Device);
627 assert_eq!(config_for("google").unwrap().flow, OAuthFlow::AuthCodePkce);
628 assert!(config_for("slack").unwrap().requires_secret);
629 assert!(!config_for("google").unwrap().requires_secret);
630 assert!(config_for("nope").is_none());
631 assert!(config_for("microsoft").is_some());
632 }
633
634 #[test]
635 fn unconfigured_provider_is_gated() {
636 let cfg = config_for("google").unwrap();
637 assert!(resolve_client(&cfg, None).is_err());
639 let byo = ByoCredentials { client_id: "abc".into(), client_secret: None };
641 assert_eq!(resolve_client(&cfg, Some(&byo)).unwrap().0, "abc");
642 }
643
644 #[test]
645 fn parses_code_and_state_from_callback() {
646 let req = "GET /callback?code=abc123&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
647 let (code, state) = parse_callback(req);
648 assert_eq!(code.as_deref(), Some("abc123"));
649 assert_eq!(state.as_deref(), Some("xyz"));
650 }
651
652 #[test]
653 fn percent_decodes_callback_values() {
654 let req = "GET /callback?code=a%2Fb%2Bc&state=s HTTP/1.1\r\n\r\n";
655 let (code, _) = parse_callback(req);
656 assert_eq!(code.as_deref(), Some("a/b+c"));
657 }
658
659 #[test]
660 fn secret_mandatory_provider_requires_byo_secret() {
661 let cfg = config_for("slack").unwrap();
662 let id_only = ByoCredentials { client_id: "abc".into(), client_secret: None };
663 assert!(resolve_client(&cfg, Some(&id_only)).is_err());
664 let with_secret = ByoCredentials {
665 client_id: "abc".into(),
666 client_secret: Some("shh".into()),
667 };
668 assert!(resolve_client(&cfg, Some(&with_secret)).is_ok());
669 }
670}