//! Keycloak sign-in through the site MCP's desktop flow. //! //! The launcher listens on a random 127.0.0.1 port and opens the system browser //! at `{mcp}/auth/desktop/start?cb=http://127.0.0.1:/cb`. The MCP runs //! Keycloak (Auth Code + PKCE), mints an Engine token, and redirects the browser //! back to the loopback with a one-time `code` (or `error`). The launcher then //! swaps that code at `POST {mcp}/auth/desktop/exchange` for the token, so the //! token itself never appears in a browser URL. use std::collections::HashMap; use std::time::Duration; use serde::Deserialize; use tauri::AppHandle; use tauri_plugin_opener::OpenerExt; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Notify; use crate::tokens::SiteToken; /// The MCP forgets a pending sign-in after 300 s. const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(300); #[derive(Deserialize)] struct ExchangePayload { #[serde(default)] access_token: String, #[serde(default)] expires_at: i64, #[serde(default)] username: String, #[serde(default)] dataless: bool, /// Only issued for `client=launcher`; an MCP without launcher sessions leaves it out. #[serde(default)] launcher_token: Option, } enum Callback { Code(String), Error(String), } pub enum SignInError { /// The operator cancelled from the launcher; not an error to show. Cancelled, Failed(String), } impl From for SignInError { fn from(message: String) -> Self { SignInError::Failed(message) } } pub async fn sign_in( app: &AppHandle, http: &reqwest::Client, mcp_url: &str, cancel: &Notify, ) -> Result { if mcp_url.is_empty() { return Err("Set the DHP's sign-in address (MCP URL) in Settings first.".to_string().into()); } let listener = TcpListener::bind("127.0.0.1:0") .await .map_err(|e| format!("cannot open the sign-in callback port: {e}"))?; let port = listener.local_addr().map_err(|e| e.to_string())?.port(); let callback = format!("http://127.0.0.1:{port}/cb"); // client=launcher asks the MCP for a launcher session too, so the operator is remembered until Sign out. let start = url::Url::parse_with_params( &format!("{mcp_url}/auth/desktop/start"), &[("cb", callback.as_str()), ("client", "launcher")], ) .map_err(|e| format!("the MCP URL is not valid: {e}"))?; app .opener() .open_url(start.as_str(), None::<&str>) .map_err(|e| format!("cannot open the browser: {e}"))?; let waited = tokio::select! { waited = tokio::time::timeout(SIGN_IN_TIMEOUT, wait_for_callback(listener)) => waited, _ = cancel.notified() => return Err(SignInError::Cancelled), }; let code = match waited { Err(_) => return Err("Sign-in timed out. Try again.".to_string().into()), Ok(Err(e)) => return Err(e.into()), Ok(Ok(Callback::Error(error))) => return Err(describe_error(&error).into()), Ok(Ok(Callback::Code(code))) => code, }; let response = http .post(format!("{mcp_url}/auth/desktop/exchange")) .json(&serde_json::json!({ "code": code })) .timeout(Duration::from_secs(15)) .send() .await .map_err(|e| format!("the sign-in service did not answer: {e}"))?; if !response.status().is_success() { return Err(format!("sign-in failed: the MCP answered HTTP {}", response.status()).into()); } let payload: ExchangePayload = response.json().await.map_err(|e| format!("unexpected sign-in answer: {e}"))?; if payload.access_token.is_empty() || payload.username.is_empty() { return Err("sign-in failed: the MCP returned no token".to_string().into()); } if payload.dataless { // A dataless session means the site's Engine has no database bound: nothing to install from. return Err("The DHP's Engine has no database connected. Ask an administrator.".to_string().into()); } Ok(SiteToken { access_token: payload.access_token, expires_at: payload.expires_at, username: payload.username, launcher_token: payload.launcher_token.filter(|t| !t.is_empty()), }) } fn describe_error(error: &str) -> String { match error { "access_denied" => "Sign-in was cancelled.".to_string(), "auth_failed" => "Keycloak did not accept the sign-in.".to_string(), "mint_failed" => "You signed in, but this DHP has no ODIAN account for you. Ask an administrator.".to_string(), "keycloak_unavailable" => "The DHP's Keycloak is unreachable.".to_string(), other => format!("Sign-in failed ({other})."), } } async fn wait_for_callback(listener: TcpListener) -> Result { loop { let (mut stream, _) = listener.accept().await.map_err(|e| format!("sign-in callback failed: {e}"))?; let Some(target) = read_request_target(&mut stream).await else { continue; }; if !target.starts_with("/cb") { respond(&mut stream, "404 Not Found", "").await; continue; } let query: HashMap = url::Url::parse(&format!("http://127.0.0.1{target}")) .map(|u| u.query_pairs().into_owned().collect()) .unwrap_or_default(); if let Some(code) = query.get("code").filter(|c| !c.is_empty()) { respond(&mut stream, "200 OK", &page("Signed in", "You can close this tab and return to the ODIAN Launcher.")).await; return Ok(Callback::Code(code.clone())); } let error = query.get("error").cloned().unwrap_or_else(|| "no_code".to_string()); respond(&mut stream, "200 OK", &page("Sign-in failed", "Return to the ODIAN Launcher for details.")).await; return Ok(Callback::Error(error)); } } /// The path of a `GET` request line, or None for anything else (or a client that stalls). async fn read_request_target(stream: &mut TcpStream) -> Option { let mut buf = vec![0u8; 8192]; let mut len = 0; let read = async { while len < buf.len() { let n = stream.read(&mut buf[len..]).await.ok()?; if n == 0 { break; } len += n; if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") { break; } } Some(()) }; tokio::time::timeout(Duration::from_secs(5), read).await.ok()??; let head = String::from_utf8_lossy(&buf[..len]); let mut parts = head.lines().next()?.split_whitespace(); if parts.next()? != "GET" { return None; } parts.next().map(str::to_string) } async fn respond(stream: &mut TcpStream, status: &str, body: &str) { let response = format!( "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", body.len() ); let _ = stream.write_all(response.as_bytes()).await; let _ = stream.shutdown().await; } fn page(title: &str, message: &str) -> String { format!( "ODIAN — {title}\ \
ODIAN
\

{title}

{message}

" ) }