//! OAuth flow orchestration for local MCP servers. //! //! Uses rmcp's `AuthorizationManager` for RFC-compliant discovery (RFC 8414 + //! 9728), DCR, PKCE, and token exchange. Proactive discovery determines auth //! requirements before connecting (not reactively after a 401), and `AuthClient` //! wraps the transport for transparent token injection and refresh. //! //! This module handles the interactive browser-based consent flow and //! cross-process dedup. Credential persistence is delegated to //! [`crate::credentials::McpCredentialStoreAdapter`] (implements rmcp's //! `CredentialStore` trait). use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use tokio::sync::{Mutex, oneshot, watch}; use crate::oauth_config::McpOAuthConfig; use crate::rmcp::transport::auth::{AuthorizationManager, OAuthClientConfig}; /// Client name advertised to MCP servers during Dynamic Client Registration /// (RFC 7591). Surfaces as the application name on third-party OAuth consent /// screens (e.g. Linear, GitHub), so keep this human-recognizable. const MCP_OAUTH_CLIENT_NAME: &str = "Kigi"; /// How often the interactive OAuth flow polls the credential store to detect /// a login completed in another window or process. const CREDENTIAL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); // Two-layer dedup: prevents duplicate browser tabs both within one process // (multiple async tasks / sessions) and across separate processes (leader // mode disabled, multiple `kigi` invocations). // // Layer 1 (cross-process): filesystem lock at $KIGI_SHARE_DIR/mcp_auth_{safe_name}.lock // Layer 2 (in-process): watch channel so only one task runs the flow /// In-process in-flight auth tracker. Keyed by server name. /// Each entry has a generation counter so that when a forced override evicts /// a stale leader, the old leader's cleanup doesn't clobber the new entry. struct InFlightEntry { rx: watch::Receiver>>, generation: u64, } #[allow(clippy::type_complexity)] static IN_FLIGHT_AUTH: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// Run the browser-based OAuth flow with dedup. /// /// If another task or process is already running the flow for the same server, /// waits for it instead of opening another browser tab. The provided /// `AuthorizationManager` must already have metadata set (from /// `discover_metadata`). On success, the manager's credentials are updated /// and persisted via its `CredentialStore`. /// /// When `force` is true (user-initiated auth), any existing in-flight entry /// is evicted so a fresh browser flow starts immediately. The old leader's /// browser tab becomes orphaned but its cleanup is generation-safe. pub async fn authenticate_mcp_server_dedup( server_name: &str, server_url: &str, auth_manager: &Arc>, byo_config: Option<&McpOAuthConfig>, force: bool, ) -> Result<(), String> { // Layer 2: in-process dedup via watch channel let mut in_flight = IN_FLIGHT_AUTH.lock().await; // Remove stale entries left by panicked leaders (sender dropped). if in_flight .get(server_name) .is_some_and(|e| e.rx.has_changed().is_err()) { in_flight.remove(server_name); } if let Some(entry) = in_flight.get(server_name) { if force { tracing::info!( server = server_name, "User-initiated auth override; evicting stale in-flight entry" ); in_flight.remove(server_name); } else { let mut rx = entry.rx.clone(); drop(in_flight); tracing::info!( server = server_name, "Another task in this process is already authenticating; waiting..." ); loop { let snapshot = rx.borrow_and_update().clone(); if let Some(result) = snapshot { if result.is_ok() { let mut mgr = auth_manager.lock().await; let _ = mgr.initialize_from_store().await; } return result; } if rx.changed().await.is_err() { return Err("Auth leader dropped".to_string()); } } } } // We are the in-process leader. let generation = GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let (tx, rx) = watch::channel::>>(None); in_flight.insert(server_name.to_string(), InFlightEntry { rx, generation }); drop(in_flight); // Layer 1: cross-process dedup via filesystem lock (Unix only) // When force is set, skip the fs lock — the old leader may still hold it // and we don't want to block behind a stale browser flow. #[cfg(unix)] let result = if force { run_browser_auth_flow(server_name, server_url, auth_manager, byo_config).await } else { authenticate_with_fs_lock(server_name, server_url, auth_manager, byo_config).await }; #[cfg(not(unix))] let result = run_browser_auth_flow(server_name, server_url, auth_manager, byo_config).await; // Broadcast to in-process followers and clean up. // Only remove if our generation is still current (a force override may // have replaced us). let _ = tx.send(Some(result.clone())); let mut in_flight = IN_FLIGHT_AUTH.lock().await; if in_flight .get(server_name) .is_some_and(|e| e.generation == generation) { in_flight.remove(server_name); } result } /// Acquire a filesystem lock, then either run the auth flow (if we're the /// first process) or reload credentials from disk (if another process /// already completed auth while we waited for the lock). #[cfg(unix)] async fn authenticate_with_fs_lock( server_name: &str, server_url: &str, auth_manager: &Arc>, byo_config: Option<&McpOAuthConfig>, ) -> Result<(), String> { use oauth2::TokenResponse as _; let lock_path = auth_lock_path(server_name); if let Some(parent) = lock_path.parent() { let _ = std::fs::create_dir_all(parent); } // Snapshot the current access token before waiting for the lock. // After acquiring, we compare to detect if another process authed. let token_before = { let mgr = auth_manager.lock().await; mgr.get_credentials() .await .ok() .and_then(|(_, tok)| tok) .map(|t| t.access_token().secret().to_string()) }; let lock_file = match std::fs::OpenOptions::new() .create(true) .truncate(false) .write(true) .open(&lock_path) { Ok(f) => f, Err(e) => { tracing::warn!(%e, "Failed to create auth lock file; proceeding without cross-process dedup"); return run_browser_auth_flow(server_name, server_url, auth_manager, byo_config).await; } }; let lock_file = tokio::task::spawn_blocking(move || { use std::os::unix::io::AsRawFd; let fd = lock_file.as_raw_fd(); loop { if unsafe { libc::flock(fd, libc::LOCK_EX) } == 0 { return Some(lock_file); } let err = std::io::Error::last_os_error(); if err.kind() == std::io::ErrorKind::Interrupted { continue; } return None; } }) .await .ok() .flatten(); let Some(_lock_guard) = lock_file else { tracing::warn!("Failed to acquire auth lock; proceeding without cross-process dedup"); return run_browser_auth_flow(server_name, server_url, auth_manager, byo_config).await; }; // We hold the lock. Reload from disk and check if another process // wrote a DIFFERENT token while we waited (not just any token). { let mut mgr = auth_manager.lock().await; if let Ok(true) = mgr.initialize_from_store().await { let token_after = mgr .get_credentials() .await .ok() .and_then(|(_, tok)| tok) .map(|t| t.access_token().secret().to_string()); if token_after != token_before { tracing::info!( server = server_name, "Another process already authenticated; reusing fresh token" ); return Ok(()); } } } run_browser_auth_flow(server_name, server_url, auth_manager, byo_config).await } #[cfg(unix)] fn auth_lock_path(server_name: &str) -> std::path::PathBuf { let safe: String = server_name .chars() .map(|c| { if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' } }) .collect(); kigi_config::kigi_home().join(format!("mcp_auth_{safe}.lock")) } /// Run the interactive browser-based OAuth flow. /// /// Drives the `AuthorizationManager` (which must already have metadata set) /// through client setup, authorization URL generation, browser consent, and /// code exchange. Credentials are auto-persisted by the manager's /// `CredentialStore` on successful token exchange. async fn run_browser_auth_flow( server_name: &str, server_url: &str, auth_manager: &Arc>, byo_config: Option<&McpOAuthConfig>, ) -> Result<(), String> { // 1. Try token refresh first (no browser needed). { let mgr = auth_manager.lock().await; match mgr.refresh_token().await { Ok(_) => { tracing::info!( server = server_name, "Token refreshed successfully (no browser)" ); return Ok(()); } Err(e) => { tracing::info!( server = server_name, %e, "Token refresh failed, falling through to browser auth" ); } } } // 2. Bind the loopback callback port (fixed BYO port if set, else ephemeral). let requested_port = byo_config.and_then(|b| b.callback_port).unwrap_or(0); let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], requested_port))) .await .map_err(|e| format!("Failed to bind loopback port {requested_port}: {e}"))?; let port = listener .local_addr() .map_err(|e| format!("Failed to get loopback port: {e}"))? .port(); let redirect_uri = format!("http://127.0.0.1:{port}/callback"); // 3. Configure client and get authorization URL (lock held briefly). let byo_scopes: Vec = byo_config .and_then(|b| b.scopes.as_ref()) .cloned() .unwrap_or_default(); let auth_url = { let mut mgr = auth_manager.lock().await; let scopes: Vec; if let Some(byo) = byo_config && let Some(client_id) = byo.client_id.clone() { tracing::info!( server = server_name, "Using BYO client credentials (oauth_client_id from config)" ); scopes = byo_scopes; let mut config = OAuthClientConfig::new(client_id, redirect_uri.clone()).with_scopes(scopes.clone()); config.client_secret = byo.client_secret.clone(); mgr.configure_client(config) .map_err(|e| format!("Failed to configure BYO client: {e}"))?; } else { scopes = if byo_scopes.is_empty() { mgr.select_scopes(None, &[]) } else { byo_scopes }; let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); mgr.register_client(MCP_OAUTH_CLIENT_NAME, &redirect_uri, &scope_refs) .await .map_err(|e| format!("Dynamic client registration failed: {e}"))?; } let scopes: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); mgr.get_authorization_url(&scopes) .await .map_err(|e| format!("Failed to get authorization URL: {e}"))? }; // Lock released — browser flow can take minutes. // Snapshot token before browser opens so we can detect if another flow // (e.g. the old evicted leader, or another process) writes fresh tokens. let token_before_browser = { let mgr = auth_manager.lock().await; mgr.get_credentials() .await .ok() .and_then(|(_, tok)| tok) .map(|t| { use oauth2::TokenResponse as _; t.access_token().secret().to_string() }) }; // 4. Open browser for user consent. tracing::info!(server = server_name, "Opening browser for OAuth consent"); if let Err(e) = webbrowser::open(&auth_url) { // eprintln! corrupts the TUI alternate screen (in-process, fd 2). // TODO: surface auth URL via ACP notification instead. tracing::warn!(%e, url = %auth_url, "Failed to open browser for MCP OAuth; user must visit URL manually"); } // 5. Wait for the OAuth callback OR for tokens to appear on disk. // The credential store poll catches the case where a force-evicted // old leader (or another process) completed auth via a different // browser tab while we're waiting for our own callback. // // IMPORTANT: peek the on-disk file directly via `McpCredentialStore::load_default` // rather than calling `mgr.initialize_from_store()`. The latter has the // side effect of running `configure_client_id(stored.client_id)`, which // replaces `oauth_client`'s freshly-DCR'd `client_id` and ephemeral // `redirect_uri` with the *old* stored values (`redirect_uri = base_url`). // If the user then completes the browser flow before another process // writes new tokens, `exchange_code_for_token` would use the clobbered // config and the server would reject with `invalid_grant: Invalid redirect_uri`. let parsed_server_url = match url::Url::parse(server_url) { Ok(u) => Some(u), Err(e) => { tracing::warn!( server = server_name, url = server_url, error = %e, "could not parse server URL for credential-store poll; falling back to callback-only auth-completion detection" ); None } }; let server_name_for_poll = server_name.to_string(); let token_snapshot = token_before_browser.clone(); let poll_store = async move { let Some(url) = parsed_server_url else { // No credential-store key — disable the poll. Callback path still works. std::future::pending::<()>().await; return; }; loop { tokio::time::sleep(CREDENTIAL_POLL_INTERVAL).await; let Ok(store) = crate::credentials::McpCredentialStore::load_default() else { continue; }; let token_now = store .get(&server_name_for_poll, &url) .and_then(|entry| entry.token_response.as_ref()) .map(|t| { use oauth2::TokenResponse as _; t.access_token().secret().to_string() }); if token_now.is_some() && token_now != token_snapshot { return; } } }; let (callback_server, callback_rx) = start_oauth_callback_server(listener); tokio::select! { result = callback_rx => { callback_server.abort(); let (code, csrf_state) = result .map_err(|_| "Callback channel dropped".to_string())? .map_err(|e| format!("OAuth callback failed: {e}"))?; // 6. Exchange code for tokens (auto-persists via CredentialStore). let mgr = auth_manager.lock().await; mgr.exchange_code_for_token(&code, &csrf_state) .await .map_err(|e| format!("Token exchange failed: {e}"))?; tracing::info!(server = server_name, "MCP OAuth authentication successful"); } _ = poll_store => { callback_server.abort(); tracing::info!( server = server_name, "Fresh tokens detected on disk from another auth flow; skipping callback wait" ); } } Ok(()) } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// Start a loopback HTTP server for the OAuth callback. /// /// Returns the server task handle (for cleanup) and a oneshot receiver /// that resolves with `(code, state)` when the callback arrives. /// /// The caller is responsible for aborting the server handle. #[allow(clippy::type_complexity)] fn start_oauth_callback_server( listener: tokio::net::TcpListener, ) -> ( tokio::task::JoinHandle<()>, oneshot::Receiver>, ) { use axum::{Router, extract::Query, response::Html, routing::get}; let (tx, rx) = oneshot::channel::>(); let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); let handler = { let tx = tx.clone(); move |Query(params): Query>| { let tx = tx.clone(); async move { let result = if let Some(error) = params.get("error") { let desc = params .get("error_description") .cloned() .unwrap_or_else(|| "Unknown error".to_string()); Err(format!("OAuth error: {error} - {desc}")) } else { match (params.get("code"), params.get("state")) { (Some(code), Some(state)) => Ok((code.clone(), state.clone())), (None, _) => Err("Missing authorization code".to_string()), (_, None) => Err("Missing state parameter".to_string()), } }; let html = match &result { Ok(_) => { r#"Authorization Complete

Authorization Complete

You can close this window and return to the terminal.

"# .to_string() } Err(e) => { let msg = html_escape(e); format!( r#"Authorization Failed

Authorization Failed

{msg}

You can close this window and return to the terminal.

"# ) } }; if let Some(tx) = tx.lock().await.take() { let _ = tx.send(result); } Html(html) } } }; let app = Router::new().route("/callback", get(handler.clone()).post(handler)); let server = tokio::spawn(async move { let _ = axum::serve(listener, app).await; }); (server, rx) }