@@ -127,7 +148,7 @@
- + @@ -147,8 +168,7 @@
diff --git a/frontend/src/main.js b/frontend/src/main.js index 9218ef0..5535086 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -2,6 +2,13 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; const API_URL = 'http://localhost:3000'; +// These should ideally be environment-specific +const AUTH_CONFIG = { + issuer: 'https://idm.flegr.me/application/o/bot/', + clientId: 'CicDk8mpSBY1SW4ofCamO3B583ttmvKTSPDQwvpb', + redirectUri: window.location.origin + '/callback', + authorizeEndpoint: 'https://idm.flegr.me/application/o/authorize/', +}; const state = { tasks: [], @@ -9,9 +16,15 @@ const state = { selectedRunId: null, currentView: 'dashboard', // 'dashboard' or 'task' isEditing: false, + token: localStorage.getItem('auth_token') }; // DOM elements +const loginOverlay = document.getElementById('login-overlay'); +const callbackOverlay = document.getElementById('callback-overlay'); +const loginBtn = document.getElementById('login-btn'); +const appEl = document.getElementById('app'); +const logoutBtn = document.getElementById('logout-btn'); const taskListEl = document.getElementById('task-list'); const newTaskBtn = document.getElementById('new-task-btn'); const modalContainer = document.getElementById('modal-container'); @@ -42,9 +55,31 @@ const toggleCustomCronBtn = document.getElementById('toggle-custom-cron'); const customCronContainer = document.getElementById('custom-cron-container'); const presetBtns = document.querySelectorAll('.btn-preset'); +// Wrapper for fetch to include Authorization header +async function fetchWithAuth(url, options = {}) { + if (!state.token) { + showLogin(); + throw new Error('Not authenticated'); + } + + const headers = { + ...options.headers, + 'Authorization': `Bearer ${state.token}` + }; + + const response = await fetch(url, { ...options, headers }); + + if (response.status === 401) { + logout(); + throw new Error('Session expired'); + } + + return response; +} + async function fetchTasks() { try { - const response = await fetch(`${API_URL}/tasks`); + const response = await fetchWithAuth(`${API_URL}/tasks`); const newTasks = await response.json(); // Check if we should follow the latest run (if we were already watching it) @@ -88,7 +123,7 @@ async function fetchTasks() { async function fetchRecentRuns() { try { - const response = await fetch(`${API_URL}/runs/recent`); + const response = await fetchWithAuth(`${API_URL}/runs/recent`); const recentRuns = await response.json(); renderDashboard(recentRuns); } catch (error) { @@ -230,8 +265,11 @@ function showTaskView(task) { if (!run) { viewStatusEl.textContent = 'No runs'; - logsOutputEl.innerHTML = ''; - answerContainerEl.classList.add('hidden'); + viewStatusEl.className = 'status-badge pending'; + viewDateEl.textContent = '-'; + logsOutputEl.innerHTML = '
No logs available. Click "Run Task" to start the agent.
'; + answerContainerEl.classList.remove('hidden'); + answerOutputEl.innerHTML = '
Waiting for the first execution...
'; return; } @@ -245,12 +283,12 @@ function showTaskView(task) { minute: '2-digit' }); + answerContainerEl.classList.remove('hidden'); if (run.answer) { - answerContainerEl.classList.remove('hidden'); const rawHtml = marked.parse(run.answer); answerOutputEl.innerHTML = DOMPurify.sanitize(rawHtml); } else { - answerContainerEl.classList.add('hidden'); + answerOutputEl.innerHTML = '
Agent is working on the final answer...
'; } // Check if we should auto-scroll @@ -279,7 +317,7 @@ rerunBtn.addEventListener('click', async () => { if (!state.selectedTaskId) return; try { - const response = await fetch(`${API_URL}/tasks/${state.selectedTaskId}/runs`, { + const response = await fetchWithAuth(`${API_URL}/tasks/${state.selectedTaskId}/runs`, { method: 'POST', }); const updatedTask = await response.json(); @@ -289,15 +327,15 @@ rerunBtn.addEventListener('click', async () => { } selectTask(updatedTask.id); } catch (error) { - console.error('Error re-running task:', error); - alert('Failed to re-run task.'); + console.error('Error running task:', error); + alert('Failed to run task.'); } }); newTaskBtn.addEventListener('click', () => { state.isEditing = false; modalTitle.textContent = 'New Agent Task'; - submitTaskBtn.textContent = 'Execute Directive'; + submitTaskBtn.textContent = 'Save Task'; goalTextarea.value = ''; cronInput.value = ''; updateScheduleUI(''); @@ -316,8 +354,8 @@ editTaskBtn.addEventListener('click', () => { if (!task) return; state.isEditing = true; - modalTitle.textContent = 'Edit Directive'; - submitTaskBtn.textContent = 'Update Directive'; + modalTitle.textContent = 'Edit Agent Task'; + submitTaskBtn.textContent = 'Save Task'; goalTextarea.value = task.goal; cronInput.value = task.cron || ''; updateScheduleUI(task.cron || ''); @@ -383,13 +421,13 @@ newTaskForm.addEventListener('submit', async (e) => { try { let response; if (state.isEditing) { - response = await fetch(`${API_URL}/tasks/${state.selectedTaskId}`, { + response = await fetchWithAuth(`${API_URL}/tasks/${state.selectedTaskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ goal, cron }), }); } else { - response = await fetch(`${API_URL}/tasks`, { + response = await fetchWithAuth(`${API_URL}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ goal, cron }), @@ -415,11 +453,6 @@ newTaskForm.addEventListener('submit', async (e) => { } }); -// Initial load -// Initial fetch -fetchTasks(); - -// Auto-refresh every 3 seconds let isPolling = false; async function startAutoRefresh() { setInterval(async () => { @@ -433,4 +466,71 @@ async function startAutoRefresh() { }, 3000); } -startAutoRefresh(); +async function showLogin() { + loginOverlay.classList.remove('hidden'); + appEl.classList.add('hidden'); +} + +async function logout() { + state.token = null; + localStorage.removeItem('auth_token'); + showLogin(); +} + +async function handleCallback() { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + if (!code) return; + + window.history.replaceState({}, document.title, "/"); + callbackOverlay.classList.remove('hidden'); + loginOverlay.classList.add('hidden'); + + try { + const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`); + const data = await response.json(); + + if (data.access_token) { + state.token = data.access_token; + localStorage.setItem('auth_token', data.access_token); + callbackOverlay.classList.add('hidden'); + appEl.classList.remove('hidden'); + initializeApp(); + } else { + throw new Error('No access token in response'); + } + } catch (error) { + console.error('Auth callback failed:', error); + alert('Authentication failed.'); + showLogin(); + } +} + +loginBtn.addEventListener('click', () => { + const authUrl = `${AUTH_CONFIG.authorizeEndpoint}?client_id=${AUTH_CONFIG.clientId}&response_type=code&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}&scope=openid profile email`; + window.location.href = authUrl; +}); + +logoutBtn.addEventListener('click', () => { + logout(); +}); + +async function initializeApp() { + if (!state.token) { + showLogin(); + return; + } + + appEl.classList.remove('hidden'); + loginOverlay.classList.add('hidden'); + + await fetchTasks(); + startAutoRefresh(); +} + +// Check for callback on load +if (window.location.pathname === '/callback' || window.location.search.includes('code=')) { + handleCallback(); +} else { + initializeApp(); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index d6738f5..a6e72c5 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -434,6 +434,21 @@ body { padding-bottom: 8px; } +.answer-output { + min-height: 100px; +} + +.waiting-placeholder { + color: var(--text-dim); + font-style: italic; + display: flex; + align-items: center; + justify-content: center; + height: 100px; + background: rgba(255, 255, 255, 0.02); + border-radius: 8px; +} + .answer-output h2 { font-size: 20px; } @@ -711,4 +726,91 @@ textarea:focus { ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); +} + +.login-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + background: rgba(10, 10, 15, 0.8); + backdrop-filter: blur(20px); +} + +.login-box { + text-align: center; + max-width: 400px; + width: 90%; + padding: 40px; + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; + align-items: center; +} + +.login-box .logo-icon { + font-size: 64px; + margin-bottom: 16px; + display: block; +} + +.login-title { + font-size: 32px; + font-weight: 700; + margin-bottom: 8px; + letter-spacing: -0.02em; +} + +.login-box p { + color: var(--text-dim); + margin-bottom: 32px; +} + +.loader { + width: 48px; + height: 48px; + border: 4px solid rgba(255, 255, 255, 0.1); + border-left-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.sidebar-footer { + padding: 16px; + border-top: 1px solid var(--glass-border); + margin-top: auto; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.logout-btn { + /* Styles for logout button, based on common patterns */ + background: transparent; + color: var(--text-dim); + border: none; + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: color 0.2s ease; +} + +.logout-btn:hover { + color: var(--primary); } \ No newline at end of file diff --git a/src/agent.rs b/src/agent.rs index 61abd05..1865a22 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -21,11 +21,12 @@ impl Agent { initial_message: String, ) -> Self { let intro = format!( - "You are an autonomous agent. You have access to tools that can help - you achieve your goals. Use them wisely. Do not ask for clarification and use the - answer tool once you to give your final answer. current date is {}", - Utc::now().to_rfc3339() - ); + "You are an autonomous agent. You have access to tools that can help + you achieve your goals. Use them wisely. The user is unable to respond to you + so do not ask for clarification and use the + answer tool once you to give your final answer. current date is {}", + Utc::now().to_rfc3339() + ); println!("initial_message: {}", intro); let messages = vec![ Message { diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..a294d4d --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,149 @@ +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub exp: usize, + pub iat: usize, + pub iss: String, + pub aud: String, +} + +#[derive(Debug, Deserialize)] +struct Jwk { + kty: String, + kid: String, + n: String, + e: String, + alg: Option, +} + +#[derive(Debug, Deserialize)] +struct Jwks { + keys: Vec, +} + +pub struct JwksVerifier { + issuer: String, + jwks_uri: String, + keys: Arc>>, + client: Client, +} + +impl JwksVerifier { + pub async fn new(issuer: String) -> Result> { + let client = Client::new(); + // Authentik OIDC discovery + let discovery_url = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + let config: serde_json::Value = client.get(&discovery_url).send().await?.json().await?; + + let jwks_uri = config["jwks_uri"] + .as_str() + .ok_or("Missing jwks_uri in discovery")? + .to_string(); + + let verifier = Self { + issuer, + jwks_uri, + keys: Arc::new(RwLock::new(Vec::new())), + client, + }; + + verifier.refresh_keys().await?; + Ok(verifier) + } + + pub async fn refresh_keys(&self) -> Result<(), Box> { + let jwks: Jwks = self.client.get(&self.jwks_uri).send().await?.json().await?; + let mut keys = self.keys.write().await; + *keys = jwks.keys; + Ok(()) + } + + pub async fn verify(&self, token: &str) -> Result> { + let header = decode_header(token)?; + let kid = header.kid.ok_or("Missing kid in token header")?; + + let keys = self.keys.read().await; + let jwk = keys + .iter() + .find(|k| k.kid == kid) + .ok_or("Key not found in JWKS")?; + + let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?; + + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&[self.issuer.clone()]); + // Aud validation might need careful config, usually it's the client_id + validation.validate_aud = false; + + let token_data = decode::(token, &decoding_key, &validation)?; + Ok(token_data.claims) + } +} + +pub struct Authenticator { + client_id: String, + client_secret: String, + token_url: String, + client: Client, +} + +impl Authenticator { + pub async fn new( + issuer: String, + client_id: String, + client_secret: String, + ) -> Result> { + let client = Client::new(); + let discovery_url = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + let config: serde_json::Value = client.get(&discovery_url).send().await?.json().await?; + + let token_url = config["token_endpoint"] + .as_str() + .ok_or("Missing token_endpoint in discovery")? + .to_string(); + + Ok(Self { + client_id, + client_secret, + token_url, + client, + }) + } + + pub async fn exchange_code( + &self, + code: String, + redirect_uri: String, + ) -> Result> { + let params = [ + ("grant_type", "authorization_code"), + ("code", &code), + ("redirect_uri", &redirect_uri), + ("client_id", &self.client_id), + ("client_secret", &self.client_secret), + ]; + + let res = self + .client + .post(&self.token_url) + .form(¶ms) + .send() + .await? + .json() + .await?; + + Ok(res) + } +} diff --git a/src/main.rs b/src/main.rs index a25ab72..14ad58e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod agent; mod api; +mod auth; mod entities; mod scheduler; mod server; diff --git a/src/server.rs b/src/server.rs index 70489ff..baac0da 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,16 +1,20 @@ use axum::{ - Json, Router, - extract::{Path, State}, - http::StatusCode, + Json, RequestPartsExt, Router, + extract::{FromRef, FromRequestParts, Path, Query, State}, + http::{StatusCode, request::Parts}, routing::{get, post}, }; +use axum_extra::{ + TypedHeader, + headers::{Authorization, authorization::Bearer}, +}; use chrono::Utc; use sea_orm::{ ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tower_http::cors::CorsLayer; +use tower_http::cors::{Any, CorsLayer}; use uuid::Uuid; use crate::agent::Agent; @@ -25,6 +29,8 @@ pub struct AppState { pub scheduler: Arc, pub zen_api_key: Option, pub tavily_api_key: Option, + pub verifier: Arc, + pub authenticator: Arc, } #[derive(Deserialize)] @@ -84,20 +90,43 @@ pub async fn start(db_url: &str) -> Result<(), Box> { } } + let authentik_issuer = + std::env::var("AUTHENTIK_ISSUER").map_err(|_| "AUTHENTIK_ISSUER not set")?; + let authentik_client_id = + std::env::var("AUTHENTIK_CLIENT_ID").map_err(|_| "AUTHENTIK_CLIENT_ID not set")?; + let authentik_client_secret = + std::env::var("AUTHENTIK_CLIENT_SECRET").map_err(|_| "AUTHENTIK_CLIENT_SECRET not set")?; + + let verifier = Arc::new(crate::auth::JwksVerifier::new(authentik_issuer.clone()).await?); + let authenticator = Arc::new( + crate::auth::Authenticator::new( + authentik_issuer, + authentik_client_id, + authentik_client_secret, + ) + .await?, + ); + let state = Arc::new(AppState { db, scheduler, zen_api_key, tavily_api_key, + verifier, + authenticator, }); - let cors = CorsLayer::permissive(); + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); let app = Router::new() .route("/tasks", post(create_task).get(list_tasks)) .route("/tasks/:id", get(get_task).put(update_task)) .route("/tasks/:id/runs", post(rerun_task)) .route("/runs/recent", get(get_recent_runs)) + .route("/auth/callback", get(auth_callback)) .layer(cors) .with_state(state); @@ -109,6 +138,7 @@ pub async fn start(db_url: &str) -> Result<(), Box> { } async fn list_tasks( + _user: AuthenticatedUser, State(state): State>, ) -> Result>, (StatusCode, String)> { let tasks = Task::find() @@ -142,6 +172,7 @@ async fn list_tasks( } async fn create_task( + _user: AuthenticatedUser, State(state): State>, Json(payload): Json, ) -> Result, (StatusCode, String)> { @@ -166,10 +197,11 @@ async fn create_task( let _ = state.scheduler.remove_task_job(task_id).await; } - execute_agent_run(state, task_id, payload.goal).await + get_task_inner(task_id, &state).await.map(Json) } async fn rerun_task( + _user: AuthenticatedUser, State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { @@ -234,10 +266,11 @@ async fn execute_agent_run( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - get_task(Path(task_id), State(state)).await + get_task_inner(task_id, &state).await.map(Json) } async fn update_task( + _user: AuthenticatedUser, State(state): State>, Path(id): Path, Json(payload): Json, @@ -262,29 +295,95 @@ async fn update_task( let _ = state.scheduler.remove_task_job(id).await; } - get_task(Path(id), State(state)).await + get_task_inner(id, &state).await.map(Json) +} + +pub struct AuthenticatedUser(pub crate::auth::Claims); + +#[axum::async_trait] +impl FromRequestParts for AuthenticatedUser +where + Arc: axum::extract::FromRef, + S: Send + Sync, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = Arc::::from_ref(state); + + let TypedHeader(Authorization(bearer)) = parts + .extract::>>() + .await + .map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + "Missing or invalid Authorization header".to_string(), + ) + })?; + + let claims = app_state + .verifier + .verify(bearer.token()) + .await + .map_err(|e| { + ( + StatusCode::UNAUTHORIZED, + format!("Token verification failed: {}", e), + ) + })?; + + Ok(AuthenticatedUser(claims)) + } +} + +#[derive(Deserialize)] +pub struct AuthCallbackQuery { + pub code: String, + pub redirect_uri: String, +} + +async fn auth_callback( + State(state): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + state + .authenticator + .exchange_code(query.code, query.redirect_uri) + .await + .map(Json) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Token exchange failed: {}", e), + ) + }) } async fn get_task( + _user: AuthenticatedUser, Path(id): Path, State(state): State>, ) -> Result, (StatusCode, String)> { + get_task_inner(id, &state).await.map(Json) +} + +async fn get_task_inner(id: Uuid, state: &AppState) -> Result { let results = Task::find_by_id(id) .find_with_related(TaskRun) .all(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let (task, runs) = results + let (t, runs) = results .into_iter() .next() .ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?; - Ok(Json(TaskResponse { - id: task.id, - goal: task.goal, - cron: task.cron, - created_at: task.created_at, + Ok(TaskResponse { + id: t.id, + goal: t.goal, + cron: t.cron, + created_at: t.created_at, runs: runs .into_iter() .map(|r| TaskRunResponse { @@ -295,10 +394,11 @@ async fn get_task( created_at: r.created_at, }) .collect(), - })) + }) } async fn get_recent_runs( + _user: AuthenticatedUser, State(state): State>, ) -> Result>, (StatusCode, String)> { let results = TaskRun::find()