diff --git a/Cargo.lock b/Cargo.lock
index 44c70e2..1a9f8e6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -208,6 +208,36 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "axum-extra"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
+dependencies = [
+ "axum",
+ "axum-core",
+ "bytes",
+ "fastrand",
+ "futures-util",
+ "headers",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "multer",
+ "pin-project-lite",
+ "serde",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -292,8 +322,11 @@ name = "bot"
version = "0.1.0"
dependencies = [
"axum",
+ "axum-extra",
+ "base64",
"chrono",
"dashmap",
+ "jsonwebtoken",
"migration",
"reqwest",
"sea-orm",
@@ -519,6 +552,18 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -529,6 +574,33 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "darling"
version = "0.20.11"
@@ -681,6 +753,44 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "either"
version = "1.15.0"
@@ -690,6 +800,27 @@ dependencies = [
"serde",
]
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -743,6 +874,22 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -896,6 +1043,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -927,6 +1075,17 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
[[package]]
name = "h2"
version = "0.4.13"
@@ -987,6 +1146,30 @@ dependencies = [
"hashbrown 0.15.5",
]
+[[package]]
+name = "headers"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
+dependencies = [
+ "base64",
+ "bytes",
+ "headers-core",
+ "http",
+ "httpdate",
+ "mime",
+ "sha1",
+]
+
+[[package]]
+name = "headers-core"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
+dependencies = [
+ "http",
+]
+
[[package]]
name = "heck"
version = "0.4.1"
@@ -1348,6 +1531,29 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonwebtoken"
+version = "10.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
+dependencies = [
+ "base64",
+ "ed25519-dalek",
+ "getrandom 0.2.17",
+ "hmac",
+ "js-sys",
+ "p256",
+ "p384",
+ "pem",
+ "rand",
+ "rsa",
+ "serde",
+ "serde_json",
+ "sha2",
+ "signature",
+ "simple_asn1",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1472,6 +1678,23 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin",
+ "version_check",
+]
+
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -1651,6 +1874,30 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -1680,6 +1927,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -1785,6 +2042,15 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
@@ -2005,6 +2271,16 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -2332,6 +2608,20 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -2490,6 +2780,18 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+[[package]]
+name = "simple_asn1"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror",
+ "time",
+]
+
[[package]]
name = "siphasher"
version = "1.0.2"
diff --git a/Cargo.toml b/Cargo.toml
index 9988979..e995879 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,3 +20,6 @@ uuid = { version = "1.8", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tokio-cron-scheduler = "0.15.1"
dashmap = "6.1.0"
+jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] }
+base64 = "0.22.1"
+axum-extra = { version = "0.9", features = ["typed-header"] }
diff --git a/frontend/index.html b/frontend/index.html
index a24eab3..6567fa6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -14,7 +14,23 @@
-
+
+
+
▲
+
Agency
+
Secure Agent Dashboard
+
+
+
+
+
+
+
+
Authenticating with Authentik...
+
+
+
+
@@ -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()