b7f3061be3
- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and update progress, local stack status, settings with DHP and MCP addresses. - crates/odian-packs: Rust installer for pack format v1 (signed manifests, resumable verified downloads from a DHP or an ODIAN drive), byte-compatible with ODIAN_MCP_Server's Python odian_packs. - Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe, one Job Object, clean shutdown; database snapshot restore on a fresh PC only. - Keycloak sign-in through the DHP's MCP, remembered until Sign out with a launcher session that renews the Engine token silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
7.1 KiB
Rust
196 lines
7.1 KiB
Rust
//! 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:<port>/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<String>,
|
|
}
|
|
|
|
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<String> 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<SiteToken, SignInError> {
|
|
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<Callback, String> {
|
|
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<String, String> = 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<String> {
|
|
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!(
|
|
"<!doctype html><meta charset=utf-8><title>ODIAN — {title}</title>\
|
|
<body style=\"margin:0;min-height:100vh;display:grid;place-content:center;background:#09090b;color:#f2f1ee;\
|
|
font-family:'Segoe UI',system-ui,sans-serif;text-align:center\">\
|
|
<div><div style=\"letter-spacing:.28em;font-weight:600;font-size:15px;color:#9a999e\">ODIAN</div>\
|
|
<h1 style=\"font-weight:600;margin:18px 0 8px\">{title}</h1><p style=\"color:#d4d3d0\">{message}</p></div>"
|
|
)
|
|
}
|